// This file Copyright © 2007-2022 Mnemosyne LLC. // It may be used under GPLv2 (SPDX: GPL-2.0), GPLv3 (SPDX: GPL-3.0), // or any future license endorsed by Mnemosyne LLC. // License text can be found in the licenses/ folder. #include #include #include /* INT_MAX */ #include #include #include /* sscanf() */ #include /* abort() */ #include #include #include #include #include #include /* tr_free */ #include #include "Actions.h" #include "DetailsDialog.h" #include "FaviconCache.h" /* gtr_get_favicon() */ #include "FileList.h" #include "HigWorkarea.h" #include "Prefs.h" #include "PrefsDialog.h" #include "Session.h" #include "Utils.h" using namespace std::literals; class DetailsDialog::Impl { public: Impl(DetailsDialog& dialog, Glib::RefPtr const& core); ~Impl(); TR_DISABLE_COPY_MOVE(Impl) void set_torrents(std::vector const& torrent_ids); private: Gtk::Widget* info_page_new(); Gtk::Widget* peer_page_new(); Gtk::Widget* tracker_page_new(); Gtk::Widget* options_page_new(); void on_details_window_size_allocated(Gtk::Allocation& alloc); bool onPeerViewQueryTooltip(int x, int y, bool keyboard_tip, Glib::RefPtr const& tooltip); void onMorePeerInfoToggled(); bool trackerVisibleFunc(Gtk::TreeModel::const_iterator const& iter); void on_tracker_list_selection_changed(); void on_tracker_list_add_button_clicked(); void on_edit_trackers(); void on_tracker_list_remove_button_clicked(); void onScrapeToggled(); void onBackupToggled(); void on_add_tracker_response(int response, std::shared_ptr& dialog); void on_edit_trackers_response(int response, std::shared_ptr& dialog); void torrent_set_bool(tr_quark key, bool value); void torrent_set_int(tr_quark key, int value); void torrent_set_real(tr_quark key, double value); void refresh(); void refreshInfo(std::vector const& torrents); void refreshPeers(std::vector const& torrents); void refreshTracker(std::vector const& torrents); void refreshOptions(std::vector const& torrents); void refreshPeerList(std::vector const& torrents); void refreshWebseedList(std::vector const& torrents); int tracker_list_get_current_torrent_id() const; tr_torrent* tracker_list_get_current_torrent() const; std::vector getTorrents() const; private: DetailsDialog& dialog_; Gtk::CheckButton* honor_limits_check_ = nullptr; Gtk::CheckButton* up_limited_check_ = nullptr; Gtk::SpinButton* up_limit_sping_ = nullptr; Gtk::CheckButton* down_limited_check_ = nullptr; Gtk::SpinButton* down_limit_spin_ = nullptr; Gtk::ComboBox* bandwidth_combo_ = nullptr; Gtk::ComboBox* ratio_combo_ = nullptr; Gtk::SpinButton* ratio_spin_ = nullptr; Gtk::ComboBox* idle_combo_ = nullptr; Gtk::SpinButton* idle_spin_ = nullptr; Gtk::SpinButton* max_peers_spin_ = nullptr; sigc::connection honor_limits_check_tag_; sigc::connection up_limited_check_tag_; sigc::connection down_limited_check_tag_; sigc::connection down_limit_spin_tag_; sigc::connection up_limit_spin_tag_; sigc::connection bandwidth_combo_tag_; sigc::connection ratio_combo_tag_; sigc::connection ratio_spin_tag_; sigc::connection idle_combo_tag_; sigc::connection idle_spin_tag_; sigc::connection max_peers_spin_tag_; Gtk::Label* size_lb_ = nullptr; Gtk::Label* state_lb_ = nullptr; Gtk::Label* have_lb_ = nullptr; Gtk::Label* dl_lb_ = nullptr; Gtk::Label* ul_lb_ = nullptr; Gtk::Label* error_lb_ = nullptr; Gtk::Label* date_started_lb_ = nullptr; Gtk::Label* eta_lb_ = nullptr; Gtk::Label* last_activity_lb_ = nullptr; Gtk::Label* hash_lb_ = nullptr; Gtk::Label* privacy_lb_ = nullptr; Gtk::Label* origin_lb_ = nullptr; Gtk::Label* destination_lb_ = nullptr; Glib::RefPtr comment_buffer_; std::unordered_map peer_hash_; std::unordered_map webseed_hash_; Glib::RefPtr peer_store_; Glib::RefPtr webseed_store_; Gtk::ScrolledWindow* webseed_view_ = nullptr; Gtk::TreeView* peer_view_ = nullptr; Gtk::CheckButton* more_peer_details_check_ = nullptr; Glib::RefPtr tracker_store_; std::unordered_map tracker_hash_; Glib::RefPtr trackers_filtered_; Gtk::Button* add_tracker_button_ = nullptr; Gtk::Button* edit_trackers_button_ = nullptr; Gtk::Button* remove_tracker_button_ = nullptr; Gtk::TreeView* tracker_view_ = nullptr; Gtk::CheckButton* scrape_check_ = nullptr; Gtk::CheckButton* all_check_ = nullptr; FileList* file_list_ = nullptr; Gtk::Label* file_label_ = nullptr; std::vector ids_; Glib::RefPtr const core_; sigc::connection periodic_refresh_tag_; Glib::Quark const TORRENT_ID_KEY = Glib::Quark("tr-torrent-id-key"); Glib::Quark const TEXT_BUFFER_KEY = Glib::Quark("tr-text-buffer-key"); Glib::Quark const URL_ENTRY_KEY = Glib::Quark("tr-url-entry-key"); }; std::vector DetailsDialog::Impl::getTorrents() const { std::vector torrents; torrents.reserve(ids_.size()); for (auto const id : ids_) { if (auto* torrent = core_->find_torrent(id); torrent != nullptr) { torrents.push_back(torrent); } } return torrents; } /**** ***** ***** OPTIONS TAB ***** ****/ namespace { void set_togglebutton_if_different(Gtk::ToggleButton* toggle, sigc::connection& tag, bool value) { bool const currentValue = toggle->get_active(); if (currentValue != value) { tag.block(); toggle->set_active(value); tag.unblock(); } } void set_int_spin_if_different(Gtk::SpinButton* spin, sigc::connection& tag, int value) { int const currentValue = spin->get_value_as_int(); if (currentValue != value) { tag.block(); spin->set_value(value); tag.unblock(); } } void set_double_spin_if_different(Gtk::SpinButton* spin, sigc::connection& tag, double value) { double const currentValue = spin->get_value(); if ((int)(currentValue * 100) != (int)(value * 100)) { tag.block(); spin->set_value(value); tag.unblock(); } } void unset_combo(Gtk::ComboBox* combobox, sigc::connection& tag) { tag.block(); combobox->set_active(-1); tag.unblock(); } } // namespace void DetailsDialog::Impl::refreshOptions(std::vector const& torrents) { /*** **** Options Page ***/ /* honor_limits_check */ if (!torrents.empty()) { bool const baseline = tr_torrentUsesSessionLimits(torrents.front()); bool const is_uniform = std::all_of( torrents.begin(), torrents.end(), [baseline](auto const* torrent) { return baseline == tr_torrentUsesSessionLimits(torrent); }); if (is_uniform) { set_togglebutton_if_different(honor_limits_check_, honor_limits_check_tag_, baseline); } } /* down_limited_check */ if (!torrents.empty()) { bool const baseline = tr_torrentUsesSpeedLimit(torrents.front(), TR_DOWN); bool const is_uniform = std::all_of( torrents.begin(), torrents.end(), [baseline](auto const* torrent) { return baseline == tr_torrentUsesSpeedLimit(torrent, TR_DOWN); }); if (is_uniform) { set_togglebutton_if_different(down_limited_check_, down_limited_check_tag_, baseline); } } /* down_limit_spin */ if (!torrents.empty()) { auto const baseline = tr_torrentGetSpeedLimit_KBps(torrents.front(), TR_DOWN); bool const is_uniform = std::all_of( torrents.begin(), torrents.end(), [baseline](auto const* torrent) { return baseline == tr_torrentGetSpeedLimit_KBps(torrent, TR_DOWN); }); if (is_uniform) { set_int_spin_if_different(down_limit_spin_, down_limit_spin_tag_, baseline); } } /* up_limited_check */ if (!torrents.empty()) { bool const baseline = tr_torrentUsesSpeedLimit(torrents.front(), TR_UP); bool const is_uniform = std::all_of( torrents.begin(), torrents.end(), [baseline](auto const* torrent) { return baseline == tr_torrentUsesSpeedLimit(torrent, TR_UP); }); if (is_uniform) { set_togglebutton_if_different(up_limited_check_, up_limited_check_tag_, baseline); } } /* up_limit_sping */ if (!torrents.empty()) { auto const baseline = tr_torrentGetSpeedLimit_KBps(torrents.front(), TR_UP); bool const is_uniform = std::all_of( torrents.begin(), torrents.end(), [baseline](auto const* torrent) { return baseline == tr_torrentGetSpeedLimit_KBps(torrent, TR_UP); }); if (is_uniform) { set_int_spin_if_different(up_limit_sping_, up_limit_spin_tag_, baseline); } } /* bandwidth_combo */ if (!torrents.empty()) { auto const baseline = tr_torrentGetPriority(torrents.front()); bool const is_uniform = std::all_of( torrents.begin(), torrents.end(), [baseline](auto const* torrent) { return baseline == tr_torrentGetPriority(torrent); }); if (is_uniform) { bandwidth_combo_tag_.block(); gtr_priority_combo_set_value(*bandwidth_combo_, baseline); bandwidth_combo_tag_.unblock(); } else { unset_combo(bandwidth_combo_, bandwidth_combo_tag_); } } /* ratio_combo */ if (!torrents.empty()) { auto const baseline = tr_torrentGetRatioMode(torrents.front()); bool const is_uniform = std::all_of( torrents.begin(), torrents.end(), [baseline](auto const* torrent) { return baseline == tr_torrentGetRatioMode(torrent); }); if (is_uniform) { ratio_combo_tag_.block(); gtr_combo_box_set_active_enum(*ratio_combo_, baseline); gtr_widget_set_visible(*ratio_spin_, baseline == TR_RATIOLIMIT_SINGLE); ratio_combo_tag_.unblock(); } } /* ratio_spin */ if (!torrents.empty()) { auto const baseline = tr_torrentGetRatioLimit(torrents.front()); set_double_spin_if_different(ratio_spin_, ratio_spin_tag_, baseline); } /* idle_combo */ if (!torrents.empty()) { auto const baseline = tr_torrentGetIdleMode(torrents.front()); bool const is_uniform = std::all_of( torrents.begin(), torrents.end(), [baseline](auto const* torrent) { return baseline == tr_torrentGetIdleMode(torrent); }); if (is_uniform) { idle_combo_tag_.block(); gtr_combo_box_set_active_enum(*idle_combo_, baseline); gtr_widget_set_visible(*idle_spin_, baseline == TR_IDLELIMIT_SINGLE); idle_combo_tag_.unblock(); } } /* idle_spin */ if (!torrents.empty()) { auto const baseline = tr_torrentGetIdleLimit(torrents.front()); set_int_spin_if_different(idle_spin_, idle_spin_tag_, baseline); } /* max_peers_spin */ if (!torrents.empty()) { auto const baseline = tr_torrentGetPeerLimit(torrents.front()); set_int_spin_if_different(max_peers_spin_, max_peers_spin_tag_, baseline); } } void DetailsDialog::Impl::torrent_set_bool(tr_quark key, bool value) { tr_variant top; tr_variantInitDict(&top, 2); tr_variantDictAddStrView(&top, TR_KEY_method, "torrent-set"sv); tr_variant* const args = tr_variantDictAddDict(&top, TR_KEY_arguments, 2); tr_variantDictAddBool(args, key, value); tr_variant* const ids = tr_variantDictAddList(args, TR_KEY_ids, ids_.size()); for (auto const id : ids_) { tr_variantListAddInt(ids, id); } core_->exec(&top); tr_variantFree(&top); } void DetailsDialog::Impl::torrent_set_int(tr_quark key, int value) { tr_variant top; tr_variantInitDict(&top, 2); tr_variantDictAddStrView(&top, TR_KEY_method, "torrent-set"sv); tr_variant* const args = tr_variantDictAddDict(&top, TR_KEY_arguments, 2); tr_variantDictAddInt(args, key, value); tr_variant* const ids = tr_variantDictAddList(args, TR_KEY_ids, ids_.size()); for (auto const id : ids_) { tr_variantListAddInt(ids, id); } core_->exec(&top); tr_variantFree(&top); } void DetailsDialog::Impl::torrent_set_real(tr_quark key, double value) { tr_variant top; tr_variantInitDict(&top, 2); tr_variantDictAddStrView(&top, TR_KEY_method, "torrent-set"sv); tr_variant* const args = tr_variantDictAddDict(&top, TR_KEY_arguments, 2); tr_variantDictAddReal(args, key, value); tr_variant* const ids = tr_variantDictAddList(args, TR_KEY_ids, ids_.size()); for (auto const id : ids_) { tr_variantListAddInt(ids, id); } core_->exec(&top); tr_variantFree(&top); } Gtk::Widget* DetailsDialog::Impl::options_page_new() { guint row; row = 0; auto* t = Gtk::make_managed(); t->add_section_title(row, _("Speed")); honor_limits_check_ = t->add_wide_checkbutton(row, _("Honor global _limits"), 0); honor_limits_check_tag_ = honor_limits_check_->signal_toggled().connect( [this]() { torrent_set_bool(TR_KEY_honorsSessionLimits, honor_limits_check_->get_active()); }); down_limited_check_ = Gtk::make_managed( gtr_sprintf(_("Limit _download speed (%s):"), _(speed_K_str)), true); down_limited_check_->set_active(false); down_limited_check_tag_ = down_limited_check_->signal_toggled().connect( [this]() { torrent_set_bool(TR_KEY_downloadLimited, down_limited_check_->get_active()); }); down_limit_spin_ = Gtk::make_managed(Gtk::Adjustment::create(0, 0, INT_MAX, 5)); down_limit_spin_tag_ = down_limit_spin_->signal_value_changed().connect( [this]() { torrent_set_int(TR_KEY_downloadLimit, down_limit_spin_->get_value_as_int()); }); t->add_row_w(row, *down_limited_check_, *down_limit_spin_); up_limited_check_ = Gtk::make_managed(gtr_sprintf(_("Limit _upload speed (%s):"), _(speed_K_str)), true); up_limited_check_tag_ = up_limited_check_->signal_toggled().connect( [this]() { torrent_set_bool(TR_KEY_uploadLimited, up_limited_check_->get_active()); }); up_limit_sping_ = Gtk::make_managed(Gtk::Adjustment::create(0, 0, INT_MAX, 5)); up_limit_spin_tag_ = up_limit_sping_->signal_value_changed().connect( [this]() { torrent_set_int(TR_KEY_uploadLimit, up_limit_sping_->get_value_as_int()); }); t->add_row_w(row, *up_limited_check_, *up_limit_sping_); bandwidth_combo_ = gtr_priority_combo_new(); bandwidth_combo_tag_ = bandwidth_combo_->signal_changed().connect( [this]() { torrent_set_int(TR_KEY_bandwidthPriority, gtr_priority_combo_get_value(*bandwidth_combo_)); }); t->add_row(row, _("Torrent _priority:"), *bandwidth_combo_); t->add_section_divider(row); t->add_section_title(row, _("Seeding Limits")); auto* h1 = Gtk::make_managed(Gtk::ORIENTATION_HORIZONTAL, GUI_PAD); ratio_combo_ = gtr_combo_box_new_enum({ { _("Use global settings"), TR_RATIOLIMIT_GLOBAL }, { _("Seed regardless of ratio"), TR_RATIOLIMIT_UNLIMITED }, { _("Stop seeding at ratio:"), TR_RATIOLIMIT_SINGLE }, }); ratio_combo_tag_ = ratio_combo_->signal_changed().connect( [this]() { torrent_set_int(TR_KEY_seedRatioMode, gtr_combo_box_get_active_enum(*ratio_combo_)); refresh(); }); h1->pack_start(*ratio_combo_, true, true, 0); ratio_spin_ = Gtk::make_managed(Gtk::Adjustment::create(0, 0, 1000, .05)); ratio_spin_->set_width_chars(7); ratio_spin_tag_ = ratio_spin_->signal_value_changed().connect( [this]() { torrent_set_real(TR_KEY_seedRatioLimit, ratio_spin_->get_value()); }); h1->pack_start(*ratio_spin_, false, false, 0); t->add_row(row, _("_Ratio:"), *h1); auto* h2 = Gtk::make_managed(Gtk::ORIENTATION_HORIZONTAL, GUI_PAD); idle_combo_ = gtr_combo_box_new_enum({ { _("Use global settings"), TR_IDLELIMIT_GLOBAL }, { _("Seed regardless of activity"), TR_IDLELIMIT_UNLIMITED }, { _("Stop seeding if idle for N minutes:"), TR_IDLELIMIT_SINGLE }, }); idle_combo_tag_ = idle_combo_->signal_changed().connect( [this]() { torrent_set_int(TR_KEY_seedIdleMode, gtr_combo_box_get_active_enum(*idle_combo_)); refresh(); }); h2->pack_start(*idle_combo_, true, true, 0); idle_spin_ = Gtk::make_managed(Gtk::Adjustment::create(1, 1, 40320, 5)); idle_spin_tag_ = idle_spin_->signal_value_changed().connect( [this]() { torrent_set_int(TR_KEY_seedIdleLimit, idle_spin_->get_value_as_int()); }); h2->pack_start(*idle_spin_, false, false, 0); t->add_row(row, _("_Idle:"), *h2); t->add_section_divider(row); t->add_section_title(row, _("Peer Connections")); max_peers_spin_ = Gtk::make_managed(Gtk::Adjustment::create(1, 1, 3000, 5)); t->add_row(row, _("_Maximum peers:"), *max_peers_spin_, max_peers_spin_); max_peers_spin_tag_ = max_peers_spin_->signal_value_changed().connect( [this]() { torrent_set_int(TR_KEY_peer_limit, max_peers_spin_->get_value_as_int()); }); return t; } /**** ***** ***** INFO TAB ***** ****/ namespace { Glib::ustring activityString(int activity, bool finished) { switch (activity) { case TR_STATUS_CHECK_WAIT: return _("Queued for verification"); case TR_STATUS_CHECK: return _("Verifying local data"); case TR_STATUS_DOWNLOAD_WAIT: return _("Queued for download"); case TR_STATUS_DOWNLOAD: return C_("Verb", "Downloading"); case TR_STATUS_SEED_WAIT: return _("Queued for seeding"); case TR_STATUS_SEED: return C_("Verb", "Seeding"); case TR_STATUS_STOPPED: return finished ? _("Finished") : _("Paused"); default: g_assert_not_reached(); } return {}; } /* Only call gtk_text_buffer_set_text () if the new text differs from the old. * This way if the user has text selected, refreshing won't deselect it */ void gtr_text_buffer_set_text(Glib::RefPtr const& b, Glib::ustring const& str) { if (b->get_text() != str) { b->set_text(str); } } Glib::ustring get_short_date_string(time_t t) { char buf[64]; struct tm tm; if (t == 0) { return _("N/A"); } tr_localtime_r(&t, &tm); strftime(buf, sizeof(buf), "%d %b %Y", &tm); return Glib::locale_to_utf8(buf); } } // namespace void DetailsDialog::Impl::refreshInfo(std::vector const& torrents) { Glib::ustring str; Glib::ustring const mixed = _("Mixed"); Glib::ustring const no_torrent = _("No Torrents Selected"); Glib::ustring stateString; uint64_t sizeWhenDone = 0; std::vector stats; std::vector infos; stats.reserve(torrents.size()); infos.reserve(torrents.size()); for (auto* const torrent : torrents) { stats.push_back(tr_torrentStatCached(torrent)); infos.push_back(tr_torrentView(torrent)); } /* privacy_lb */ if (infos.empty()) { str = no_torrent; } else { bool const baseline = infos.front().is_private; bool const is_uniform = std::all_of( infos.begin(), infos.end(), [baseline](auto const& info) { return info.is_private == baseline; }); if (is_uniform) { str = baseline ? _("Private to this tracker -- DHT and PEX disabled") : _("Public torrent"); } else { str = mixed; } } privacy_lb_->set_text(str); /* origin_lb */ if (infos.empty()) { str = no_torrent; } else { auto const creator = Glib::ustring(infos.front().creator != nullptr ? infos.front().creator : ""); auto const date = infos.front().date_created; auto const datestr = get_short_date_string(date); bool const mixed_creator = std::any_of( infos.begin(), infos.end(), [&creator](auto const& info) { return creator != (info.creator != nullptr ? info.creator : ""); }); bool const mixed_date = std::any_of( infos.begin(), infos.end(), [date](auto const& info) { return date != info.date_created; }); bool const empty_creator = creator.empty(); bool const empty_date = date == 0; if (mixed_date || mixed_creator) { str = mixed; } else if (empty_date && empty_creator) { str = _("N/A"); } else { if (empty_date && !empty_creator) { str = gtr_sprintf(_("Created by %1$s"), creator); } else if (empty_creator && !empty_date) { str = gtr_sprintf(_("Created on %1$s"), datestr); } else { str = gtr_sprintf(_("Created by %1$s on %2$s"), creator, datestr); } } } origin_lb_->set_text(str); /* comment_buffer */ if (infos.empty()) { str.clear(); } else { auto const baseline = Glib::ustring(infos.front().comment != nullptr ? infos.front().comment : ""); bool const is_uniform = std::all_of( infos.begin(), infos.end(), [&baseline](auto const& info) { return baseline == (info.comment != nullptr ? info.comment : ""); }); str = is_uniform ? baseline : mixed; } gtr_text_buffer_set_text(comment_buffer_, str); /* destination_lb */ if (torrents.empty()) { str = no_torrent; } else { auto const baseline = Glib::ustring(tr_torrentGetDownloadDir(torrents.front())); bool const is_uniform = std::all_of( torrents.begin(), torrents.end(), [&baseline](auto const* torrent) { return baseline == tr_torrentGetDownloadDir(torrent); }); str = is_uniform ? baseline : mixed; } destination_lb_->set_text(str); /* state_lb */ if (stats.empty()) { str = no_torrent; } else { auto const activity = stats.front()->activity; bool const is_uniform = std::all_of( stats.begin(), stats.end(), [activity](auto const* st) { return activity == st->activity; }); bool const allFinished = std::all_of(stats.begin(), stats.end(), [](auto const* st) { return st->finished; }); str = is_uniform ? activityString(activity, allFinished) : mixed; } stateString = str; state_lb_->set_text(str); /* date started */ if (stats.empty()) { str = no_torrent; } else { time_t const baseline = stats.front()->startDate; bool const is_uniform = std::all_of( stats.begin(), stats.end(), [baseline](auto const* st) { return baseline == st->startDate; }); if (!is_uniform) { str = mixed; } else if (baseline <= 0 || stats[0]->activity == TR_STATUS_STOPPED) { str = stateString; } else { str = tr_strltime(time(nullptr) - baseline); } } date_started_lb_->set_text(str); /* eta */ if (stats.empty()) { str = no_torrent; } else { int const baseline = stats.front()->eta; bool const is_uniform = std::all_of( stats.begin(), stats.end(), [baseline](auto const* st) { return baseline == st->eta; }); if (!is_uniform) { str = mixed; } else if (baseline < 0) { str = _("Unknown"); } else { str = tr_strltime(baseline); } } eta_lb_->set_text(str); /* size_lb */ { uint64_t size = 0; int pieces = 0; int32_t pieceSize = 0; for (auto const& info : infos) { size += info.total_size; pieces += info.n_pieces; if (pieceSize == 0) { pieceSize = info.piece_size; } else if (pieceSize != (int)info.piece_size) { pieceSize = -1; } } auto const sizebuf = tr_strlsize(size); if (size == 0) { str.clear(); } else if (pieceSize >= 0) { str = gtr_sprintf( ngettext("%1$s (%2$'d piece @ %3$s)", "%1$s (%2$'d pieces @ %3$s)", pieces), sizebuf, pieces, tr_formatter_mem_B(pieceSize)); } else { str = gtr_sprintf(ngettext("%1$s (%2$'d piece)", "%1$s (%2$'d pieces)", pieces), sizebuf, pieces); } size_lb_->set_text(str); } /* have_lb */ if (stats.empty()) { str = no_torrent; } else { uint64_t leftUntilDone = 0; uint64_t haveUnchecked = 0; uint64_t haveValid = 0; uint64_t available = 0; for (auto const* const st : stats) { haveUnchecked += st->haveUnchecked; haveValid += st->haveValid; sizeWhenDone += st->sizeWhenDone; leftUntilDone += st->leftUntilDone; available += st->sizeWhenDone - st->leftUntilDone + st->haveUnchecked + st->desiredAvailable; } { double const d = sizeWhenDone != 0 ? (100.0 * available) / sizeWhenDone : 0; double const ratio = 100.0 * (sizeWhenDone != 0 ? (haveValid + haveUnchecked) / (double)sizeWhenDone : 1); auto const avail = tr_strlpercent(d); auto const buf2 = tr_strlpercent(ratio); auto const total = tr_strlsize(haveUnchecked + haveValid); auto const unver = tr_strlsize(haveUnchecked); if (haveUnchecked == 0 && leftUntilDone == 0) { str = gtr_sprintf(_("%1$s (%2$s%%)"), total, buf2); } else if (haveUnchecked == 0) { str = gtr_sprintf(_("%1$s (%2$s%% of %3$s%% Available)"), total, buf2, avail); } else { str = gtr_sprintf(_("%1$s (%2$s%% of %3$s%% Available); %4$s Unverified"), total, buf2, avail, unver); } } } have_lb_->set_text(str); /* dl_lb */ if (stats.empty()) { str = no_torrent; } else { uint64_t d = 0; uint64_t f = 0; for (auto const* const st : stats) { d += st->downloadedEver; f += st->corruptEver; } auto const dbuf = tr_strlsize(d); auto const fbuf = tr_strlsize(f); if (f != 0) { str = gtr_sprintf(_("%1$s (+%2$s corrupt)"), dbuf, fbuf); } else { str = dbuf; } } dl_lb_->set_text(str); /* ul_lb */ if (stats.empty()) { str = no_torrent; } else { uint64_t up = 0; uint64_t down = 0; for (auto const* const st : stats) { up += st->uploadedEver; down += st->downloadedEver; } str = gtr_sprintf(_("%s (Ratio: %s)"), tr_strlsize(up), tr_strlratio(tr_getRatio(up, down))); } ul_lb_->set_text(str); /* hash_lb */ if (infos.empty()) { str = no_torrent; } else if (infos.size() == 1) { str = infos.front().hash_string; } else { str = mixed; } hash_lb_->set_text(str); /* error */ if (stats.empty()) { str = no_torrent; } else { auto const baseline = Glib::ustring(stats.front()->errorString); bool const is_uniform = std::all_of( stats.begin(), stats.end(), [&baseline](auto const* st) { return baseline == st->errorString; }); str = is_uniform ? baseline : mixed; } if (str.empty()) { str = _("No errors"); } error_lb_->set_text(str); /* activity date */ if (stats.empty()) { str = no_torrent; } else { time_t const latest = (*std::max_element( stats.begin(), stats.end(), [](auto const* lhs, auto const* rhs) { return lhs->activityDate < rhs->activityDate; })) ->activityDate; if (latest <= 0) { str = _("Never"); } else { time_t const period = time(nullptr) - latest; if (period < 5) { str = _("Active now"); } else { str = gtr_sprintf(_("%1$s ago"), tr_strltime(period)); } } } last_activity_lb_->set_text(str); } Gtk::Widget* DetailsDialog::Impl::info_page_new() { guint row = 0; auto* t = Gtk::make_managed(); t->add_section_title(row, _("Activity")); /* size */ size_lb_ = Gtk::make_managed(); size_lb_->set_single_line_mode(true); t->add_row(row, _("Torrent size:"), *size_lb_); /* have */ have_lb_ = Gtk::make_managed(); have_lb_->set_single_line_mode(true); t->add_row(row, _("Have:"), *have_lb_); /* uploaded */ ul_lb_ = Gtk::make_managed(); ul_lb_->set_single_line_mode(true); t->add_row(row, _("Uploaded:"), *ul_lb_); /* downloaded */ dl_lb_ = Gtk::make_managed(); dl_lb_->set_single_line_mode(true); t->add_row(row, _("Downloaded:"), *dl_lb_); /* state */ state_lb_ = Gtk::make_managed(); state_lb_->set_single_line_mode(true); t->add_row(row, _("State:"), *state_lb_); /* running for */ date_started_lb_ = Gtk::make_managed(); date_started_lb_->set_single_line_mode(true); t->add_row(row, _("Running time:"), *date_started_lb_); /* eta */ eta_lb_ = Gtk::make_managed(); eta_lb_->set_single_line_mode(true); t->add_row(row, _("Remaining time:"), *eta_lb_); /* last activity */ last_activity_lb_ = Gtk::make_managed(); last_activity_lb_->set_single_line_mode(true); t->add_row(row, _("Last activity:"), *last_activity_lb_); /* error */ error_lb_ = Gtk::make_managed(); error_lb_->set_selectable(true); error_lb_->set_ellipsize(Pango::ELLIPSIZE_END); t->add_row(row, _("Error:"), *error_lb_); t->add_section_divider(row); t->add_section_title(row, _("Details")); /* destination */ destination_lb_ = Gtk::make_managed(); destination_lb_->set_selectable(true); destination_lb_->set_ellipsize(Pango::ELLIPSIZE_END); t->add_row(row, _("Location:"), *destination_lb_); /* hash */ hash_lb_ = Gtk::make_managed(); hash_lb_->set_selectable(true); hash_lb_->set_ellipsize(Pango::ELLIPSIZE_END); t->add_row(row, _("Hash:"), *hash_lb_); /* privacy */ privacy_lb_ = Gtk::make_managed(); privacy_lb_->set_single_line_mode(true); t->add_row(row, _("Privacy:"), *privacy_lb_); /* origins */ origin_lb_ = Gtk::make_managed(); origin_lb_->set_selectable(true); origin_lb_->set_ellipsize(Pango::ELLIPSIZE_END); t->add_row(row, _("Origin:"), *origin_lb_); /* comment */ comment_buffer_ = Gtk::TextBuffer::create(); auto* tw = Gtk::make_managed(comment_buffer_); tw->set_wrap_mode(Gtk::WRAP_WORD); tw->set_editable(false); auto* sw = Gtk::make_managed(); sw->set_size_request(350, 36); sw->set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC); sw->add(*tw); auto* fr = Gtk::make_managed(); fr->set_shadow_type(Gtk::SHADOW_IN); fr->add(*sw); auto* w = t->add_tall_row(row, _("Comment:"), *fr); w->set_halign(Gtk::ALIGN_START); w->set_valign(Gtk::ALIGN_START); t->add_section_divider(row); return t; } /**** ***** ***** PEERS TAB ***** ****/ namespace { class WebseedModelColumns : public Gtk::TreeModelColumnRecord { public: WebseedModelColumns() { add(key); add(was_updated); add(url); add(download_rate_double); add(download_rate_string); } Gtk::TreeModelColumn key; Gtk::TreeModelColumn was_updated; Gtk::TreeModelColumn url; Gtk::TreeModelColumn download_rate_double; Gtk::TreeModelColumn download_rate_string; }; WebseedModelColumns const webseed_cols; class PeerModelColumns : public Gtk::TreeModelColumnRecord { public: PeerModelColumns() { add(key); add(was_updated); add(address); add(address_collated); add(download_rate_double); add(download_rate_string); add(upload_rate_double); add(upload_rate_string); add(client); add(progress); add(upload_request_count_int); add(upload_request_count_string); add(download_request_count_int); add(download_request_count_string); add(blocks_downloaded_count_int); add(blocks_downloaded_count_string); add(blocks_uploaded_count_int); add(blocks_uploaded_count_string); add(reqs_cancelled_by_client_count_int); add(reqs_cancelled_by_client_count_string); add(reqs_cancelled_by_peer_count_int); add(reqs_cancelled_by_peer_count_string); add(encryption_stock_id); add(flags); add(torrent_name); } Gtk::TreeModelColumn key; Gtk::TreeModelColumn was_updated; Gtk::TreeModelColumn address; Gtk::TreeModelColumn address_collated; Gtk::TreeModelColumn download_rate_double; Gtk::TreeModelColumn download_rate_string; Gtk::TreeModelColumn upload_rate_double; Gtk::TreeModelColumn upload_rate_string; Gtk::TreeModelColumn client; Gtk::TreeModelColumn progress; Gtk::TreeModelColumn upload_request_count_int; Gtk::TreeModelColumn upload_request_count_string; Gtk::TreeModelColumn download_request_count_int; Gtk::TreeModelColumn download_request_count_string; Gtk::TreeModelColumn blocks_downloaded_count_int; Gtk::TreeModelColumn blocks_downloaded_count_string; Gtk::TreeModelColumn blocks_uploaded_count_int; Gtk::TreeModelColumn blocks_uploaded_count_string; Gtk::TreeModelColumn reqs_cancelled_by_client_count_int; Gtk::TreeModelColumn reqs_cancelled_by_client_count_string; Gtk::TreeModelColumn reqs_cancelled_by_peer_count_int; Gtk::TreeModelColumn reqs_cancelled_by_peer_count_string; Gtk::TreeModelColumn encryption_stock_id; Gtk::TreeModelColumn flags; Gtk::TreeModelColumn torrent_name; }; PeerModelColumns const peer_cols; void initPeerRow(Gtk::TreeIter const& iter, std::string const& key, std::string const& torrentName, tr_peer_stat const* peer) { g_return_if_fail(peer != nullptr); char const* client = peer->client; if (client == nullptr || g_strcmp0(client, "Unknown Client") == 0) { client = ""; } int q[4]; Glib::ustring collated_name; if (sscanf(peer->addr, "%d.%d.%d.%d", q, q + 1, q + 2, q + 3) != 4) { collated_name = peer->addr; } else { collated_name = gtr_sprintf("%03d.%03d.%03d.%03d", q[0], q[1], q[2], q[3]); } (*iter)[peer_cols.address] = peer->addr; (*iter)[peer_cols.address_collated] = collated_name; (*iter)[peer_cols.client] = client; (*iter)[peer_cols.encryption_stock_id] = peer->isEncrypted ? "lock" : ""; (*iter)[peer_cols.key] = key; (*iter)[peer_cols.torrent_name] = torrentName; } void refreshPeerRow(Gtk::TreeIter const& iter, tr_peer_stat const* peer) { std::string up_speed; std::string down_speed; std::string up_count; std::string down_count; std::string blocks_to_peer; std::string blocks_to_client; std::string cancelled_by_peer; std::string cancelled_by_client; g_return_if_fail(peer != nullptr); if (peer->rateToPeer_KBps > 0.01) { up_speed = tr_formatter_speed_KBps(peer->rateToPeer_KBps); } if (peer->rateToClient_KBps > 0) { down_speed = tr_formatter_speed_KBps(peer->rateToClient_KBps); } if (peer->pendingReqsToPeer > 0) { down_count = std::to_string(peer->pendingReqsToPeer); } if (peer->pendingReqsToClient > 0) { up_count = std::to_string(peer->pendingReqsToClient); } if (peer->blocksToPeer > 0) { blocks_to_peer = std::to_string(peer->blocksToPeer); } if (peer->blocksToClient > 0) { blocks_to_client = std::to_string(peer->blocksToClient); } if (peer->cancelsToPeer > 0) { cancelled_by_client = std::to_string(peer->cancelsToPeer); } if (peer->cancelsToClient > 0) { cancelled_by_peer = std::to_string(peer->cancelsToClient); } (*iter)[peer_cols.progress] = (int)(100.0 * peer->progress); (*iter)[peer_cols.upload_request_count_int] = peer->pendingReqsToClient; (*iter)[peer_cols.upload_request_count_string] = up_count; (*iter)[peer_cols.download_request_count_int] = peer->pendingReqsToPeer; (*iter)[peer_cols.download_request_count_string] = down_count; (*iter)[peer_cols.download_rate_double] = peer->rateToClient_KBps; (*iter)[peer_cols.download_rate_string] = down_speed; (*iter)[peer_cols.upload_rate_double] = peer->rateToPeer_KBps; (*iter)[peer_cols.upload_rate_string] = up_speed; (*iter)[peer_cols.flags] = peer->flagStr; (*iter)[peer_cols.was_updated] = true; (*iter)[peer_cols.blocks_downloaded_count_int] = (int)peer->blocksToClient; (*iter)[peer_cols.blocks_downloaded_count_string] = blocks_to_client; (*iter)[peer_cols.blocks_uploaded_count_int] = (int)peer->blocksToPeer; (*iter)[peer_cols.blocks_uploaded_count_string] = blocks_to_peer; (*iter)[peer_cols.reqs_cancelled_by_client_count_int] = (int)peer->cancelsToPeer; (*iter)[peer_cols.reqs_cancelled_by_client_count_string] = cancelled_by_client; (*iter)[peer_cols.reqs_cancelled_by_peer_count_int] = (int)peer->cancelsToClient; (*iter)[peer_cols.reqs_cancelled_by_peer_count_string] = cancelled_by_peer; } } // namespace void DetailsDialog::Impl::refreshPeerList(std::vector const& torrents) { auto& hash = peer_hash_; auto& store = peer_store_; /* step 1: get all the peers */ std::vector peers; std::vector peerCount; peers.reserve(torrents.size()); peerCount.reserve(torrents.size()); for (auto const* const torrent : torrents) { int count = 0; peers.push_back(tr_torrentPeers(torrent, &count)); peerCount.push_back(count); } /* step 2: mark all the peers in the list as not-updated */ for (auto const& row : store->children()) { row[peer_cols.was_updated] = false; } /* step 3: add any new peers */ for (size_t i = 0; i < torrents.size(); ++i) { auto const* tor = torrents.at(i); for (int j = 0; j < peerCount[i]; ++j) { auto const* s = &peers.at(i)[j]; auto const key = gtr_sprintf("%d.%s", tr_torrentId(tor), s->addr); if (hash.find(key) == hash.end()) { auto const iter = store->append(); initPeerRow(iter, key, tr_torrentName(tor), s); hash.try_emplace(key, Gtk::TreeRowReference(store, store->get_path(iter))); } } } /* step 4: update the peers */ for (size_t i = 0; i < torrents.size(); ++i) { auto const* tor = torrents.at(i); for (int j = 0; j < peerCount[i]; ++j) { auto const* s = &peers.at(i)[j]; auto const key = gtr_sprintf("%d.%s", tr_torrentId(tor), s->addr); refreshPeerRow(store->get_iter(hash.at(key).get_path()), s); } } /* step 5: remove peers that have disappeared */ if (auto iter = store->children().begin(); iter) { while (iter) { if (iter->get_value(peer_cols.was_updated)) { ++iter; } else { auto const key = iter->get_value(peer_cols.key); hash.erase(key); iter = store->erase(iter); } } } /* step 6: cleanup */ for (size_t i = 0; i < peers.size(); ++i) { tr_torrentPeersFree(peers[i], peerCount[i]); } } void DetailsDialog::Impl::refreshWebseedList(std::vector const& torrents) { auto has_any_webseeds = bool{ false }; auto& hash = webseed_hash_; auto& store = webseed_store_; /* step 1: mark all webseeds as not-updated */ for (auto const& row : store->children()) { row[webseed_cols.was_updated] = false; } /* step 2: add any new webseeds */ for (auto const* const tor : torrents) { for (size_t j = 0, n = tr_torrentWebseedCount(tor); j < n; ++j) { has_any_webseeds = true; auto const* const url = tr_torrentWebseed(tor, j).url; auto const key = gtr_sprintf("%d.%s", tr_torrentId(tor), url); if (hash.find(key) == hash.end()) { auto const iter = store->append(); (*iter)[webseed_cols.url] = url; (*iter)[webseed_cols.key] = key; hash.try_emplace(key, Gtk::TreeRowReference(store, store->get_path(iter))); } } } /* step 3: update the webseeds */ for (auto const* const tor : torrents) { for (size_t j = 0, n = tr_torrentWebseedCount(tor); j < n; ++j) { auto const webseed = tr_torrentWebseed(tor, j); auto const key = gtr_sprintf("%d.%s", tr_torrentId(tor), webseed.url); auto const iter = store->get_iter(hash.at(key).get_path()); auto const KBps = double(webseed.download_bytes_per_second) / speed_K; auto const buf = webseed.is_downloading ? tr_formatter_speed_KBps(KBps) : std::string(); (*iter)[webseed_cols.download_rate_double] = KBps; (*iter)[webseed_cols.download_rate_string] = buf; (*iter)[webseed_cols.was_updated] = true; } } /* step 4: remove webseeds that have disappeared */ if (auto iter = store->children().begin(); iter) { while (iter) { if (iter->get_value(webseed_cols.was_updated)) { ++iter; } else { auto const key = iter->get_value(webseed_cols.key); hash.erase(key); iter = store->erase(iter); } } } /* most of the time there are no webseeds... don't waste space showing an empty list */ webseed_view_->set_visible(has_any_webseeds); } void DetailsDialog::Impl::refreshPeers(std::vector const& torrents) { refreshPeerList(torrents); refreshWebseedList(torrents); } bool DetailsDialog::Impl::onPeerViewQueryTooltip(int x, int y, bool keyboard_tip, Glib::RefPtr const& tooltip) { Gtk::TreeModel::iterator iter; bool show_tip = false; if (peer_view_->get_tooltip_context_iter(x, y, keyboard_tip, iter)) { auto const name = iter->get_value(peer_cols.torrent_name); auto const addr = iter->get_value(peer_cols.address); auto const flagstr = iter->get_value(peer_cols.flags); std::ostringstream gstr; gstr << "" << Glib::Markup::escape_text(name) << "\n" << addr << "\n \n"; for (char const ch : flagstr) { char const* s = nullptr; switch (ch) { case 'O': s = _("Optimistic unchoke"); break; case 'D': s = _("Downloading from this peer"); break; case 'd': s = _("We would download from this peer if they would let us"); break; case 'U': s = _("Uploading to peer"); break; case 'u': s = _("We would upload to this peer if they asked"); break; case 'K': s = _("Peer has unchoked us, but we're not interested"); break; case '?': s = _("We unchoked this peer, but they're not interested"); break; case 'E': s = _("Encrypted connection"); break; case 'X': s = _("Peer was found through Peer Exchange (PEX)"); break; case 'H': s = _("Peer was found through DHT"); break; case 'I': s = _("Peer is an incoming connection"); break; case 'T': s = _("Peer is connected over µTP"); break; default: g_assert_not_reached(); } if (s != nullptr) { gstr << ch << ": " << s << '\n'; } } auto str = gstr.str(); if (!str.empty()) /* remove the last linefeed */ { str.resize(str.size() - 1); } tooltip->set_markup(str); show_tip = true; } return show_tip; } namespace { void setPeerViewColumns(Gtk::TreeView* peer_view) { std::vector view_columns; Gtk::TreeViewColumn* c; bool const more = gtr_pref_flag_get(TR_KEY_show_extra_peer_details); view_columns.push_back(&peer_cols.encryption_stock_id); view_columns.push_back(&peer_cols.upload_rate_string); if (more) { view_columns.push_back(&peer_cols.upload_request_count_string); } view_columns.push_back(&peer_cols.download_rate_string); if (more) { view_columns.push_back(&peer_cols.download_request_count_string); } if (more) { view_columns.push_back(&peer_cols.blocks_downloaded_count_string); } if (more) { view_columns.push_back(&peer_cols.blocks_uploaded_count_string); } if (more) { view_columns.push_back(&peer_cols.reqs_cancelled_by_client_count_string); } if (more) { view_columns.push_back(&peer_cols.reqs_cancelled_by_peer_count_string); } view_columns.push_back(&peer_cols.progress); view_columns.push_back(&peer_cols.flags); view_columns.push_back(&peer_cols.address); view_columns.push_back(&peer_cols.client); /* remove any existing columns */ peer_view->remove_all_columns(); for (auto const* const col : view_columns) { auto const* sort_col = col; if (*col == peer_cols.address) { auto* r = Gtk::make_managed(); c = Gtk::make_managed(_("Address"), *r); c->add_attribute(r->property_text(), *col); sort_col = &peer_cols.address_collated; } else if (*col == peer_cols.progress) { auto* r = Gtk::make_managed(); c = Gtk::make_managed(_("%"), *r); c->add_attribute(r->property_text(), *col); } else if (*col == peer_cols.encryption_stock_id) { auto* r = Gtk::make_managed(); r->property_xalign() = 0.0F; r->property_yalign() = 0.5F; c = Gtk::make_managed(Glib::ustring(), *r); c->add_attribute(r->property_icon_name(), *col); c->set_sizing(Gtk::TREE_VIEW_COLUMN_FIXED); c->set_fixed_width(20); } else if (*col == peer_cols.download_request_count_string) { auto* r = Gtk::make_managed(); c = Gtk::make_managed(_("Dn Reqs"), *r); c->add_attribute(r->property_text(), *col); sort_col = &peer_cols.download_request_count_int; } else if (*col == peer_cols.upload_request_count_string) { auto* r = Gtk::make_managed(); c = Gtk::make_managed(_("Up Reqs"), *r); c->add_attribute(r->property_text(), *col); sort_col = &peer_cols.upload_request_count_int; } else if (*col == peer_cols.blocks_downloaded_count_string) { auto* r = Gtk::make_managed(); c = Gtk::make_managed(_("Dn Blocks"), *r); c->add_attribute(r->property_text(), *col); sort_col = &peer_cols.blocks_downloaded_count_int; } else if (*col == peer_cols.blocks_uploaded_count_string) { auto* r = Gtk::make_managed(); c = Gtk::make_managed(_("Up Blocks"), *r); c->add_attribute(r->property_text(), *col); sort_col = &peer_cols.blocks_uploaded_count_int; } else if (*col == peer_cols.reqs_cancelled_by_client_count_string) { auto* r = Gtk::make_managed(); c = Gtk::make_managed(_("We Cancelled"), *r); c->add_attribute(r->property_text(), *col); sort_col = &peer_cols.reqs_cancelled_by_client_count_int; } else if (*col == peer_cols.reqs_cancelled_by_peer_count_string) { auto* r = Gtk::make_managed(); c = Gtk::make_managed(_("They Cancelled"), *r); c->add_attribute(r->property_text(), *col); sort_col = &peer_cols.reqs_cancelled_by_peer_count_int; } else if (*col == peer_cols.download_rate_string) { auto* r = Gtk::make_managed(); r->property_xalign() = 1.0F; c = Gtk::make_managed(_("Down"), *r); c->add_attribute(r->property_text(), *col); sort_col = &peer_cols.download_rate_double; } else if (*col == peer_cols.upload_rate_string) { auto* r = Gtk::make_managed(); r->property_xalign() = 1.0F; c = Gtk::make_managed(_("Up"), *r); c->add_attribute(r->property_text(), *col); sort_col = &peer_cols.upload_rate_double; } else if (*col == peer_cols.client) { auto* r = Gtk::make_managed(); c = Gtk::make_managed(_("Client"), *r); c->add_attribute(r->property_text(), *col); } else if (*col == peer_cols.flags) { auto* r = Gtk::make_managed(); c = Gtk::make_managed(_("Flags"), *r); c->add_attribute(r->property_text(), *col); } else { abort(); } c->set_resizable(false); c->set_sort_column(*sort_col); peer_view->append_column(*c); } /* the 'expander' column has a 10-pixel margin on the left that doesn't look quite correct in any of these columns... so create a non-visible column and assign it as the 'expander column. */ c = Gtk::make_managed(); c->set_visible(false); peer_view->append_column(*c); peer_view->set_expander_column(*c); } } // namespace void DetailsDialog::Impl::onMorePeerInfoToggled() { tr_quark const key = TR_KEY_show_extra_peer_details; bool const value = more_peer_details_check_->get_active(); core_->set_pref(key, value); setPeerViewColumns(peer_view_); } Gtk::Widget* DetailsDialog::Impl::peer_page_new() { /* webseeds */ webseed_store_ = Gtk::ListStore::create(webseed_cols); auto* v = Gtk::make_managed(webseed_store_); v->signal_button_release_event().connect([v](GdkEventButton* event) { return on_tree_view_button_released(v, event); }); { auto* r = Gtk::make_managed(); r->property_ellipsize() = Pango::ELLIPSIZE_END; auto* c = Gtk::make_managed(_("Web Seeds"), *r); c->add_attribute(r->property_text(), webseed_cols.url); c->set_expand(true); c->set_sort_column(webseed_cols.url); v->append_column(*c); } { auto* r = Gtk::make_managed(); auto* c = Gtk::make_managed(_("Down"), *r); c->add_attribute(r->property_text(), webseed_cols.download_rate_string); c->set_sort_column(webseed_cols.download_rate_double); v->append_column(*c); } webseed_view_ = Gtk::make_managed(); webseed_view_->set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC); webseed_view_->set_shadow_type(Gtk::SHADOW_IN); webseed_view_->add(*v); /* peers */ peer_store_ = Gtk::ListStore::create(peer_cols); auto m = Gtk::TreeModelSort::create(peer_store_); m->set_sort_column(peer_cols.progress, Gtk::SORT_DESCENDING); peer_view_ = Gtk::make_managed(m); peer_view_->set_has_tooltip(true); peer_view_->signal_query_tooltip().connect(sigc::mem_fun(*this, &Impl::onPeerViewQueryTooltip)); peer_view_->signal_button_release_event().connect([this](GdkEventButton* event) { return on_tree_view_button_released(peer_view_, event); }); setPeerViewColumns(peer_view_); auto* sw = Gtk::make_managed(); sw->set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC); sw->set_shadow_type(Gtk::SHADOW_IN); sw->add(*peer_view_); auto* vbox = Gtk::make_managed(Gtk::ORIENTATION_VERTICAL, GUI_PAD); vbox->set_border_width(GUI_PAD_BIG); auto* v2 = Gtk::make_managed(Gtk::ORIENTATION_VERTICAL); v2->add(*webseed_view_); v2->add(*sw); vbox->pack_start(*v2, true, true); more_peer_details_check_ = Gtk::make_managed(_("Show _more details"), true); more_peer_details_check_->set_active(gtr_pref_flag_get(TR_KEY_show_extra_peer_details)); more_peer_details_check_->signal_toggled().connect(sigc::mem_fun(*this, &Impl::onMorePeerInfoToggled)); vbox->pack_start(*more_peer_details_check_, false, false); return vbox; } /**** ***** ***** TRACKER ***** ****/ namespace { char const err_markup_begin[] = ""; char const err_markup_end[] = ""; char const timeout_markup_begin[] = ""; char const timeout_markup_end[] = ""; char const success_markup_begin[] = ""; char const success_markup_end[] = ""; std::array const text_dir_mark = { ""sv, "\u200E"sv, "\u200F"sv }; // if it's been longer than a minute, don't bother showing the seconds Glib::ustring tr_strltime_rounded(time_t t) { if (t > 60) { t -= (t % 60); } return tr_strltime(t); } void appendAnnounceInfo(tr_tracker_view const& tracker, time_t const now, Gtk::TextDirection direction, std::ostream& gstr) { if (tracker.hasAnnounced && tracker.announceState != TR_TRACKER_INACTIVE) { gstr << '\n'; gstr << text_dir_mark[direction]; auto const timebuf = tr_strltime_rounded(now - tracker.lastAnnounceTime); if (tracker.lastAnnounceSucceeded) { gstr << gtr_sprintf( _("Got a list of %1$s%2$'d peers%3$s %4$s ago"), success_markup_begin, tracker.lastAnnouncePeerCount, success_markup_end, timebuf); } else if (tracker.lastAnnounceTimedOut) { gstr << gtr_sprintf( _("Peer list request %1$stimed out%2$s %3$s ago; will retry"), timeout_markup_begin, timeout_markup_end, timebuf); } else { gstr << gtr_sprintf( _("Got an error %1$s\"%2$s\"%3$s %4$s ago"), err_markup_begin, tracker.lastAnnounceResult, err_markup_end, timebuf); } } switch (tracker.announceState) { case TR_TRACKER_INACTIVE: gstr << '\n'; gstr << text_dir_mark[direction]; gstr << _("No updates scheduled"); break; case TR_TRACKER_WAITING: gstr << '\n'; gstr << text_dir_mark[direction]; gstr << gtr_sprintf(_("Asking for more peers in %s"), tr_strltime_rounded(tracker.nextAnnounceTime - now)); break; case TR_TRACKER_QUEUED: gstr << '\n'; gstr << text_dir_mark[direction]; gstr << _("Queued to ask for more peers"); break; case TR_TRACKER_ACTIVE: gstr << '\n'; gstr << text_dir_mark[direction]; gstr << gtr_sprintf( _("Asking for more peers now… %s"), tr_strltime_rounded(now - tracker.lastAnnounceStartTime)); break; default: g_assert_not_reached(); } } void appendScrapeInfo(tr_tracker_view const& tracker, time_t const now, Gtk::TextDirection direction, std::ostream& gstr) { if (tracker.hasScraped) { gstr << '\n'; gstr << text_dir_mark[direction]; auto const timebuf = tr_strltime_rounded(now - tracker.lastScrapeTime); if (tracker.lastScrapeSucceeded) { gstr << gtr_sprintf( _("Tracker had %s%'d seeders and %'d leechers%s %s ago"), success_markup_begin, tracker.seederCount, tracker.leecherCount, success_markup_end, timebuf); } else { gstr << gtr_sprintf( _("Got a scrape error \"%s%s%s\" %s ago"), err_markup_begin, tracker.lastScrapeResult, err_markup_end, timebuf); } } switch (tracker.scrapeState) { case TR_TRACKER_INACTIVE: break; case TR_TRACKER_WAITING: gstr << '\n'; gstr << text_dir_mark[direction]; gstr << gtr_sprintf(_("Asking for peer counts in %s"), tr_strltime_rounded(tracker.nextScrapeTime - now)); break; case TR_TRACKER_QUEUED: gstr << '\n'; gstr << text_dir_mark[direction]; gstr << _("Queued to ask for peer counts"); break; case TR_TRACKER_ACTIVE: gstr << '\n'; gstr << text_dir_mark[direction]; gstr << gtr_sprintf( _("Asking for peer counts now… %s"), tr_strltime_rounded(now - tracker.lastScrapeStartTime)); break; default: g_assert_not_reached(); } } void buildTrackerSummary( std::ostream& gstr, std::string const& key, tr_tracker_view const& tracker, bool showScrape, Gtk::TextDirection direction) { // hostname gstr << text_dir_mark[direction]; gstr << (tracker.isBackup ? "" : ""); gstr << Glib::Markup::escape_text(!key.empty() ? gtr_sprintf("%s - %s", tracker.host, key) : tracker.host); gstr << (tracker.isBackup ? "" : ""); if (!tracker.isBackup) { time_t const now = time(nullptr); appendAnnounceInfo(tracker, now, direction, gstr); if (showScrape) { appendScrapeInfo(tracker, now, direction, gstr); } } } class TrackerModelColumns : public Gtk::TreeModelColumnRecord { public: TrackerModelColumns() { add(torrent_id); add(text); add(is_backup); add(tracker_id); add(favicon); add(was_updated); add(key); } Gtk::TreeModelColumn torrent_id; Gtk::TreeModelColumn text; Gtk::TreeModelColumn is_backup; Gtk::TreeModelColumn tracker_id; Gtk::TreeModelColumn> favicon; Gtk::TreeModelColumn was_updated; Gtk::TreeModelColumn key; }; TrackerModelColumns const tracker_cols; } // namespace bool DetailsDialog::Impl::trackerVisibleFunc(Gtk::TreeModel::const_iterator const& iter) { /* show all */ if (all_check_->get_active()) { return true; } /* don't show the backups... */ return !iter->get_value(tracker_cols.is_backup); } int DetailsDialog::Impl::tracker_list_get_current_torrent_id() const { int torrent_id = -1; /* if there's only one torrent in the dialog, always use it */ if (ids_.size() == 1) { torrent_id = ids_.front(); } /* otherwise, use the selected tracker's torrent */ if (torrent_id < 0) { auto const sel = tracker_view_->get_selection(); if (auto const iter = sel->get_selected(); iter) { torrent_id = iter->get_value(tracker_cols.torrent_id); } } return torrent_id; } tr_torrent* DetailsDialog::Impl::tracker_list_get_current_torrent() const { int const torrent_id = tracker_list_get_current_torrent_id(); return core_->find_torrent(torrent_id); } namespace { void favicon_ready_cb(Glib::RefPtr const& pixbuf, Gtk::TreeRowReference& reference) { if (pixbuf != nullptr) { auto const path = reference.get_path(); auto const model = reference.get_model(); if (auto const iter = model->get_iter(path); iter) { (*iter)[tracker_cols.favicon] = pixbuf; } } } } // namespace void DetailsDialog::Impl::refreshTracker(std::vector const& torrents) { std::ostringstream gstr; auto& hash = tracker_hash_; auto& store = tracker_store_; auto* session = core_->get_session(); bool const showScrape = scrape_check_->get_active(); /* step 1: get all the trackers */ auto trackers = std::multimap{}; for (auto const* tor : torrents) { for (size_t i = 0, n = tr_torrentTrackerCount(tor); i < n; ++i) { trackers.emplace(tor, tr_torrentTracker(tor, i)); } } /* step 2: mark all the trackers in the list as not-updated */ for (auto const& row : store->children()) { row[tracker_cols.was_updated] = false; } /* step 3: add / update trackers */ for (auto const& [tor, tracker] : trackers) { auto const torrent_id = tr_torrentId(tor); // build the key to find the row gstr.str({}); gstr << torrent_id << '\t' << tracker.tier << '\t' << tracker.announce; if (hash.find(gstr.str()) == hash.end()) { // if we didn't have that row, add it auto const iter = store->append(); (*iter)[tracker_cols.torrent_id] = torrent_id; (*iter)[tracker_cols.tracker_id] = tracker.id; (*iter)[tracker_cols.key] = gstr.str(); auto const p = store->get_path(iter); hash.try_emplace(gstr.str(), Gtk::TreeRowReference(store, p)); gtr_get_favicon_from_url( session, tracker.announce, [ref = Gtk::TreeRowReference(store, p)](auto const& pixbuf) mutable { favicon_ready_cb(pixbuf, ref); }); } } /* step 4: update the rows */ auto const summary_name = std::string(std::size(torrents) == 1 ? tr_torrentName(torrents.front()) : ""); for (auto const& [tor, tracker] : trackers) { auto const torrent_id = tr_torrentId(tor); // build the key to find the row gstr.str({}); gstr << torrent_id << '\t' << tracker.tier << '\t' << tracker.announce; auto const iter = store->get_iter(hash.at(gstr.str()).get_path()); // update the row gstr.str({}); buildTrackerSummary(gstr, summary_name, tracker, showScrape, dialog_.get_direction()); (*iter)[tracker_cols.text] = gstr.str(); (*iter)[tracker_cols.is_backup] = tracker.isBackup; (*iter)[tracker_cols.tracker_id] = tracker.id; (*iter)[tracker_cols.was_updated] = true; } /* step 5: remove trackers that have disappeared */ if (auto iter = store->children().begin(); iter) { while (iter) { if (iter->get_value(tracker_cols.was_updated)) { ++iter; } else { auto const key = iter->get_value(tracker_cols.key); hash.erase(key); iter = store->erase(iter); } } } edit_trackers_button_->set_sensitive(tracker_list_get_current_torrent_id() >= 0); } void DetailsDialog::Impl::onScrapeToggled() { tr_quark const key = TR_KEY_show_tracker_scrapes; bool const value = scrape_check_->get_active(); core_->set_pref(key, value); refresh(); } void DetailsDialog::Impl::onBackupToggled() { tr_quark const key = TR_KEY_show_backup_trackers; bool const value = all_check_->get_active(); core_->set_pref(key, value); refresh(); } void DetailsDialog::Impl::on_edit_trackers_response(int response, std::shared_ptr& dialog) { bool do_destroy = true; if (response == Gtk::RESPONSE_ACCEPT) { auto const torrent_id = GPOINTER_TO_INT(dialog->get_data(TORRENT_ID_KEY)); auto* const text_buffer = static_cast(dialog->get_data(TEXT_BUFFER_KEY)); tr_torrent* const tor = core_->find_torrent(torrent_id); if (tor != nullptr) { /* build the array of trackers */ auto const tracker_text = text_buffer->get_text(false); std::istringstream tracker_strings(tracker_text); auto announce_url_strings = std::vector{}; auto announce_urls = std::vector{}; auto tiers = std::vector{}; auto tier = tr_tracker_tier_t{ 0 }; std::string str; while (std::getline(tracker_strings, str)) { if (str.empty()) { ++tier; } else { announce_url_strings.push_back(str); tiers.push_back(tier); } } std::transform( std::begin(announce_url_strings), std::end(announce_url_strings), std::back_inserter(announce_urls), [](auto const& url) { return url.c_str(); }); if (tr_torrentSetAnnounceList(tor, std::data(announce_urls), std::data(tiers), std::size(announce_urls))) { refresh(); } else { Gtk::MessageDialog w(*dialog, _("List contains invalid URLs"), false, Gtk::MESSAGE_ERROR, Gtk::BUTTONS_CLOSE, true); w.set_secondary_text(_("Please correct the errors and try again.")); w.run(); do_destroy = false; } } } if (do_destroy) { dialog.reset(); } } namespace { std::string get_editable_tracker_list(tr_torrent const* tor) { std::ostringstream gstr; int tier = 0; for (size_t i = 0, n = tr_torrentTrackerCount(tor); i < n; ++i) { auto const tracker = tr_torrentTracker(tor, i); if (tier != tracker.tier) { tier = tracker.tier; gstr << '\n'; } gstr << tracker.announce << '\n'; } auto str = gstr.str(); if (!str.empty()) { str.resize(str.size() - 1); } return str; } } // namespace void DetailsDialog::Impl::on_edit_trackers() { tr_torrent const* tor = tracker_list_get_current_torrent(); if (tor != nullptr) { guint row; int const torrent_id = tr_torrentId(tor); auto d = std::make_shared( gtr_sprintf(_("%s - Edit Trackers"), tr_torrentName(tor)), dialog_, Gtk::DIALOG_MODAL | Gtk::DIALOG_DESTROY_WITH_PARENT); d->add_button(_("_Cancel"), Gtk::RESPONSE_CANCEL); d->add_button(_("_Save"), Gtk::RESPONSE_ACCEPT); d->signal_response().connect([this, d](int response) mutable { on_edit_trackers_response(response, d); }); row = 0; auto* t = Gtk::make_managed(); t->add_section_title(row, _("Tracker Announce URLs")); auto* l = Gtk::make_managed(); l->set_markup( _("To add a backup URL, add it on the line after the primary URL.\n" "To add another primary URL, add it after a blank line.")); l->set_justify(Gtk::JUSTIFY_LEFT); l->set_halign(Gtk::ALIGN_START); l->set_valign(Gtk::ALIGN_CENTER); t->add_wide_control(row, *l); auto* w = Gtk::make_managed(); w->get_buffer()->set_text(get_editable_tracker_list(tor)); auto* fr = Gtk::make_managed(); fr->set_shadow_type(Gtk::SHADOW_IN); auto* sw = Gtk::make_managed(); sw->set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC); sw->add(*w); fr->add(*sw); fr->set_size_request(500U, 166U); t->add_wide_tall_control(row, *fr); gtr_dialog_set_content(*d, *t); d->set_data(TORRENT_ID_KEY, GINT_TO_POINTER(torrent_id)); d->set_data(TEXT_BUFFER_KEY, gtr_get_ptr(w->get_buffer())); d->show(); } } void DetailsDialog::Impl::on_tracker_list_selection_changed() { int const n = tracker_view_->get_selection()->count_selected_rows(); auto* tor = tracker_list_get_current_torrent(); remove_tracker_button_->set_sensitive(n > 0); add_tracker_button_->set_sensitive(tor != nullptr); edit_trackers_button_->set_sensitive(tor != nullptr); } void DetailsDialog::Impl::on_add_tracker_response(int response, std::shared_ptr& dialog) { bool destroy = true; if (response == Gtk::RESPONSE_ACCEPT) { auto* e = static_cast(dialog->get_data(URL_ENTRY_KEY)); auto const torrent_id = GPOINTER_TO_INT(dialog->get_data(TORRENT_ID_KEY)); auto const url = gtr_str_strip(e->get_text()); if (!url.empty()) { if (tr_urlIsValidTracker(url.c_str())) { tr_variant top; tr_variant* args; tr_variant* trackers; tr_variantInitDict(&top, 2); tr_variantDictAddStrView(&top, TR_KEY_method, "torrent-set"sv); args = tr_variantDictAddDict(&top, TR_KEY_arguments, 2); tr_variantDictAddInt(args, TR_KEY_id, torrent_id); trackers = tr_variantDictAddList(args, TR_KEY_trackerAdd, 1); tr_variantListAddStr(trackers, url.raw()); core_->exec(&top); refresh(); tr_variantFree(&top); } else { gtr_unrecognized_url_dialog(*dialog, url); destroy = false; } } } if (destroy) { dialog.reset(); } } void DetailsDialog::Impl::on_tracker_list_add_button_clicked() { tr_torrent const* tor = tracker_list_get_current_torrent(); if (tor != nullptr) { guint row; auto w = std::make_shared( gtr_sprintf(_("%s - Add Tracker"), tr_torrentName(tor)), dialog_, Gtk::DIALOG_DESTROY_WITH_PARENT); w->add_button(_("_Cancel"), Gtk::RESPONSE_CANCEL); w->add_button(_("_Add"), Gtk::RESPONSE_ACCEPT); w->signal_response().connect([this, w](int response) mutable { on_add_tracker_response(response, w); }); row = 0; auto* t = Gtk::make_managed(); t->add_section_title(row, _("Tracker")); auto* e = Gtk::make_managed(); e->set_size_request(400, -1); gtr_paste_clipboard_url_into_entry(*e); w->set_data(URL_ENTRY_KEY, e); w->set_data(TORRENT_ID_KEY, GINT_TO_POINTER(tr_torrentId(tor))); t->add_row(row, _("_Announce URL:"), *e); gtr_dialog_set_content(*w, *t); w->show_all(); } } void DetailsDialog::Impl::on_tracker_list_remove_button_clicked() { auto* v = tracker_view_; auto sel = v->get_selection(); if (auto const iter = sel->get_selected(); iter) { auto const torrent_id = iter->get_value(tracker_cols.torrent_id); auto const tracker_id = iter->get_value(tracker_cols.tracker_id); tr_variant top; tr_variant* args; tr_variant* trackers; tr_variantInitDict(&top, 2); tr_variantDictAddStrView(&top, TR_KEY_method, "torrent-set"sv); args = tr_variantDictAddDict(&top, TR_KEY_arguments, 2); tr_variantDictAddInt(args, TR_KEY_id, torrent_id); trackers = tr_variantDictAddList(args, TR_KEY_trackerRemove, 1); tr_variantListAddInt(trackers, tracker_id); core_->exec(&top); refresh(); tr_variantFree(&top); } } Gtk::Widget* DetailsDialog::Impl::tracker_page_new() { int const pad = (GUI_PAD + GUI_PAD_BIG) / 2; auto* vbox = Gtk::make_managed(Gtk::ORIENTATION_VERTICAL, GUI_PAD); vbox->set_border_width(GUI_PAD_BIG); tracker_store_ = Gtk::ListStore::create(tracker_cols); trackers_filtered_ = Gtk::TreeModelFilter::create(tracker_store_); trackers_filtered_->set_visible_func(sigc::mem_fun(*this, &Impl::trackerVisibleFunc)); auto* hbox = Gtk::make_managed(Gtk::ORIENTATION_HORIZONTAL, GUI_PAD_BIG); tracker_view_ = Gtk::make_managed(trackers_filtered_); tracker_view_->set_headers_visible(false); tracker_view_->signal_button_press_event().connect([this](GdkEventButton* event) { return on_tree_view_button_pressed(tracker_view_, event); }); tracker_view_->signal_button_release_event().connect([this](GdkEventButton* event) { return on_tree_view_button_released(tracker_view_, event); }); auto sel = tracker_view_->get_selection(); sel->signal_changed().connect(sigc::mem_fun(*this, &Impl::on_tracker_list_selection_changed)); auto* c = Gtk::make_managed(); c->set_title(_("Trackers")); tracker_view_->append_column(*c); { auto* r = Gtk::make_managed(); r->property_width() = 20 + (GUI_PAD_SMALL * 2); r->property_xpad() = GUI_PAD_SMALL; r->property_ypad() = pad; r->property_yalign() = 0.0F; c->pack_start(*r, false); c->add_attribute(r->property_pixbuf(), tracker_cols.favicon); } { auto* r = Gtk::make_managed(); r->property_ellipsize() = Pango::ELLIPSIZE_END; r->property_xpad() = GUI_PAD_SMALL; r->property_ypad() = pad; c->pack_start(*r, true); c->add_attribute(r->property_markup(), tracker_cols.text); } auto* sw = Gtk::make_managed(); sw->set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC); sw->add(*tracker_view_); auto* w = Gtk::make_managed(); w->set_shadow_type(Gtk::SHADOW_IN); w->add(*sw); hbox->pack_start(*w, true, true); auto* v = Gtk::make_managed(Gtk::ORIENTATION_VERTICAL, GUI_PAD); add_tracker_button_ = Gtk::make_managed(_("_Add"), true); add_tracker_button_->signal_clicked().connect(sigc::mem_fun(*this, &Impl::on_tracker_list_add_button_clicked)); v->pack_start(*add_tracker_button_, false, false); edit_trackers_button_ = Gtk::make_managed(_("_Edit"), true); edit_trackers_button_->signal_clicked().connect(sigc::mem_fun(*this, &Impl::on_edit_trackers)); v->pack_start(*edit_trackers_button_, false, false); remove_tracker_button_ = Gtk::make_managed(_("_Remove"), true); remove_tracker_button_->signal_clicked().connect(sigc::mem_fun(*this, &Impl::on_tracker_list_remove_button_clicked)); v->pack_start(*remove_tracker_button_, false, false); hbox->pack_start(*v, false, false); vbox->pack_start(*hbox, true, true); scrape_check_ = Gtk::make_managed(_("Show _more details"), true); scrape_check_->set_active(gtr_pref_flag_get(TR_KEY_show_tracker_scrapes)); scrape_check_->signal_toggled().connect(sigc::mem_fun(*this, &Impl::onScrapeToggled)); vbox->pack_start(*scrape_check_, false, false); all_check_ = Gtk::make_managed(_("Show _backup trackers"), true); all_check_->set_active(gtr_pref_flag_get(TR_KEY_show_backup_trackers)); all_check_->signal_toggled().connect(sigc::mem_fun(*this, &Impl::onBackupToggled)); vbox->pack_start(*all_check_, false, false); return vbox; } /**** ***** DIALOG ****/ void DetailsDialog::Impl::refresh() { auto const torrents = getTorrents(); refreshInfo(torrents); refreshPeers(torrents); refreshTracker(torrents); refreshOptions(torrents); if (torrents.empty()) { dialog_.response(Gtk::RESPONSE_CLOSE); } } void DetailsDialog::Impl::on_details_window_size_allocated(Gtk::Allocation& /*alloc*/) { int w = 0; int h = 0; dialog_.get_size(w, h); gtr_pref_int_set(TR_KEY_details_window_width, w); gtr_pref_int_set(TR_KEY_details_window_height, h); } DetailsDialog::Impl::~Impl() { periodic_refresh_tag_.disconnect(); } std::unique_ptr DetailsDialog::create(Gtk::Window& parent, Glib::RefPtr const& core) { return std::unique_ptr(new DetailsDialog(parent, core)); } DetailsDialog::DetailsDialog(Gtk::Window& parent, Glib::RefPtr const& core) : Gtk::Dialog({}, parent) , impl_(std::make_unique(*this, core)) { } DetailsDialog::~DetailsDialog() = default; DetailsDialog::Impl::Impl(DetailsDialog& dialog, Glib::RefPtr const& core) : dialog_(dialog) , core_(core) { /* create the dialog */ dialog_.add_button(_("_Close"), Gtk::RESPONSE_CLOSE); dialog_.set_role("tr-info"); /* return saved window size */ dialog_.resize((int)gtr_pref_int_get(TR_KEY_details_window_width), (int)gtr_pref_int_get(TR_KEY_details_window_height)); dialog_.signal_size_allocate().connect(sigc::mem_fun(*this, &Impl::on_details_window_size_allocated)); dialog_.signal_response().connect(sigc::hide<0>(sigc::mem_fun(dialog_, &DetailsDialog::hide))); dialog_.set_border_width(GUI_PAD); auto* n = Gtk::make_managed(); n->set_border_width(GUI_PAD); n->append_page(*info_page_new(), _("Information")); n->append_page(*peer_page_new(), _("Peers")); n->append_page(*tracker_page_new(), _("Trackers")); auto* v = Gtk::make_managed(Gtk::ORIENTATION_VERTICAL); file_list_ = Gtk::make_managed(core, 0); file_label_ = Gtk::make_managed(_("File listing not available for combined torrent properties")); v->pack_start(*file_list_, true, true, 0); v->pack_start(*file_label_, true, true, 0); v->set_border_width(GUI_PAD_BIG); n->append_page(*v, _("Files")); n->append_page(*options_page_new(), _("Options")); gtr_dialog_set_content(dialog_, *n); periodic_refresh_tag_ = Glib::signal_timeout().connect_seconds( [this]() { return refresh(), true; }, SECONDARY_WINDOW_REFRESH_INTERVAL_SECONDS); } void DetailsDialog::set_torrents(std::vector const& ids) { impl_->set_torrents(ids); } void DetailsDialog::Impl::set_torrents(std::vector const& ids) { Glib::ustring title; int const len = ids.size(); ids_ = ids; if (len == 1) { int const id = ids.front(); auto const* tor = core_->find_torrent(id); title = gtr_sprintf(_("%s Properties"), tr_torrentName(tor)); file_list_->set_torrent(id); file_list_->show(); file_label_->hide(); } else { file_list_->clear(); file_list_->hide(); file_label_->show(); title = gtr_sprintf(_("%'d Torrent Properties"), len); } dialog_.set_title(title); refresh(); }