// This file Copyright © Mnemosyne LLC. // It may be used under GPLv2 (SPDX: GPL-2.0-only), GPLv3 (SPDX: GPL-3.0-only), // or any future license endorsed by Mnemosyne LLC. // License text can be found in the licenses/ folder. #include "DetailsDialog.h" #include "Actions.h" #include "FileList.h" #include "GtkCompat.h" #include "HigWorkarea.h" // GUI_PAD, GUI_PAD_BIG, GUI_PAD_SMALL #include "Prefs.h" #include "PrefsDialog.h" #include "Session.h" #include "Utils.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // abort() #include #include #include #include #include #include #include #ifdef _WIN32 #include #include #else #include #include #endif using namespace std::literals; class DetailsDialog::Impl { public: Impl(DetailsDialog& dialog, Glib::RefPtr const& builder, Glib::RefPtr const& core); ~Impl(); TR_DISABLE_COPY_MOVE(Impl) void set_torrents(std::vector const& torrent_ids); void refresh(); private: void info_page_init(Glib::RefPtr const& builder); void peer_page_init(Glib::RefPtr const& builder); void tracker_page_init(Glib::RefPtr const& builder); void options_page_init(Glib::RefPtr const& builder); void on_details_window_size_allocated(); 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 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 refreshInfo(std::vector const& torrents); void refreshPeers(std::vector const& torrents); void refreshTracker(std::vector const& torrents); void refreshFiles(std::vector const& torrents); void refreshOptions(std::vector const& torrents); void refreshPeerList(std::vector const& torrents); void refreshWebseedList(std::vector const& torrents); tr_torrent_id_t tracker_list_get_current_torrent_id() const; tr_torrent* tracker_list_get_current_torrent() const; std::vector getTorrents() const; private: DetailsDialog& dialog_; Glib::RefPtr const core_; 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* added_lb_ = nullptr; 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_; 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"); static guint last_page_; }; guint DetailsDialog::Impl::last_page_ = 0; 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::CheckButton* 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_combo_box_set_active_enum(*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); } 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); } 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); } void DetailsDialog::Impl::options_page_init(Glib::RefPtr const& /*builder*/) { 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_->set_label(fmt::format(down_limited_check_->get_label().raw(), fmt::arg("speed_units", speed_K_str))); 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_->set_adjustment(Gtk::Adjustment::create(0, 0, std::numeric_limits::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()); }); up_limited_check_->set_label(fmt::format(up_limited_check_->get_label().raw(), fmt::arg("speed_units", speed_K_str))); 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_->set_adjustment(Gtk::Adjustment::create(0, 0, std::numeric_limits::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()); }); gtr_priority_combo_init(*bandwidth_combo_); bandwidth_combo_tag_ = bandwidth_combo_->signal_changed().connect( [this]() { torrent_set_int(TR_KEY_bandwidthPriority, gtr_combo_box_get_active_enum(*bandwidth_combo_)); }); gtr_combo_box_set_enum( *ratio_combo_, { { _("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(); }); ratio_spin_->set_adjustment(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()); }); gtr_combo_box_set_enum( *idle_combo_, { { _("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(); }); idle_spin_->set_adjustment(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()); }); max_peers_spin_->set_adjustment(Gtk::Adjustment::create(1, 1, 3000, 5)); 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()); }); } /**** ***** ***** 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); } } [[nodiscard]] std::string get_date_string(time_t t) { return t == 0 ? _("N/A") : fmt::format(FMT_STRING("{:%x}"), fmt::localtime(t)); } [[nodiscard]] std::string get_date_time_string(time_t t) { return t == 0 ? _("N/A") : fmt::format(FMT_STRING("{:%c}"), fmt::localtime(t)); } } // namespace void DetailsDialog::Impl::refreshInfo(std::vector const& torrents) { auto const now = time(nullptr); 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_torrentStat(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); /* added_lb */ if (stats.empty()) { str = no_torrent; } else { auto const baseline = stats.front()->addedDate; bool const is_uniform = std::all_of( stats.begin(), stats.end(), [baseline](auto const* stat) { return stat->addedDate == baseline; }); if (is_uniform) { str = get_date_time_string(baseline); } else { str = mixed; } } added_lb_->set_text(str); /* origin_lb */ if (infos.empty()) { str = no_torrent; } else { auto const creator = tr_strv_strip(infos.front().creator != nullptr ? infos.front().creator : ""); auto const date = infos.front().date_created; auto const datestr = get_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 = std::empty(creator); bool const empty_date = date == 0; if (mixed_creator || mixed_date) { str = mixed; } else if (!empty_creator && !empty_date) { str = fmt::format(_("Created by {creator} on {date}"), fmt::arg("creator", creator), fmt::arg("date", datestr)); } else if (!empty_creator) { str = fmt::format(_("Created by {creator}"), fmt::arg("creator", creator)); } else if (!empty_date) { str = fmt::format(_("Created on {date}"), fmt::arg("date", datestr)); } else { str = _("N/A"); } } 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_format_time(now - baseline); } } date_started_lb_->set_text(str); /* eta */ if (stats.empty()) { str = no_torrent; } else { auto const baseline = stats.front()->eta; auto 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_format_time_left(baseline); } } eta_lb_->set_text(str); /* size_lb */ { auto const piece_count = std::accumulate( std::begin(infos), std::end(infos), uint64_t{}, [](auto sum, auto const& info) { return sum + info.n_pieces; }); if (piece_count == 0) { str.clear(); } else { auto const total_size = std::accumulate( std::begin(infos), std::end(infos), uint64_t{}, [](auto sum, auto const& info) { return sum + info.total_size; }); auto const file_count = std::accumulate( std::begin(torrents), std::end(torrents), std::size_t{}, [](auto sum, auto const* tor) { return sum + tr_torrentFileCount(tor); }); str = fmt::format( ngettext("{total_size} in {file_count:L} file", "{total_size} in {file_count:L} files", file_count), fmt::arg("total_size", tr_strlsize(total_size)), fmt::arg("file_count", file_count)); auto const piece_size = std::empty(infos) ? uint32_t{} : infos.front().piece_size; auto const piece_size_is_uniform = std::all_of( std::begin(infos), std::end(infos), [piece_size](auto const& info) { return info.piece_size == piece_size; }); if (piece_size_is_uniform) { str += ' '; str += fmt::format( ngettext( "({piece_count} BitTorrent piece @ {piece_size})", "({piece_count} BitTorrent pieces @ {piece_size})", piece_count), fmt::arg("piece_count", piece_count), fmt::arg("piece_size", tr_formatter_mem_B(piece_size))); } } 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_strpercent(d); auto const buf2 = tr_strpercent(ratio); auto const total = tr_strlsize(haveUnchecked + haveValid); auto const unver = tr_strlsize(haveUnchecked); if (haveUnchecked == 0 && leftUntilDone == 0) { str = fmt::format( _("{current_size} ({percent_done}%)"), fmt::arg("current_size", total), fmt::arg("percent_done", buf2)); } else if (haveUnchecked == 0) { str = fmt::format( // xgettext:no-c-format _("{current_size} ({percent_done}% of {percent_available}% available)"), fmt::arg("current_size", total), fmt::arg("percent_done", buf2), fmt::arg("percent_available", avail)); } else { str = fmt::format( // xgettext:no-c-format _("{current_size} ({percent_done}% of {percent_available}% available; {unverified_size} unverified)"), fmt::arg("current_size", total), fmt::arg("percent_done", buf2), fmt::arg("percent_available", avail), fmt::arg("unverified_size", unver)); } } } have_lb_->set_text(str); // dl_lb if (stats.empty()) { str = no_torrent; } else { auto const downloaded_str = tr_strlsize(std::accumulate( std::begin(stats), std::end(stats), uint64_t{ 0 }, [](auto sum, auto const* st) { return sum + st->downloadedEver; })); auto const failed = std::accumulate( std::begin(stats), std::end(stats), uint64_t{ 0 }, [](auto sum, auto const* st) { return sum + st->corruptEver; }); if (failed != 0) { str = fmt::format( _("{downloaded_size} (+{discarded_size} discarded after failed checksum)"), fmt::arg("downloaded_size", downloaded_str), fmt::arg("discarded_size", tr_strlsize(failed))); } else { str = downloaded_str; } } dl_lb_->set_text(str); /* ul_lb */ if (stats.empty()) { str = no_torrent; } else { auto const uploaded = std::accumulate( std::begin(stats), std::end(stats), uint64_t{}, [](auto sum, auto const* st) { return sum + st->uploadedEver; }); auto const denominator = std::accumulate( std::begin(stats), std::end(stats), uint64_t{}, [](auto sum, auto const* st) { return sum + st->sizeWhenDone; }); str = fmt::format( _("{uploaded_size} (Ratio: {ratio})"), fmt::arg("uploaded_size", tr_strlsize(uploaded)), fmt::arg("ratio", tr_strlratio(tr_getRatio(uploaded, denominator)))); } 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 if ((now - latest) < 5) { str = _("Active now"); } else { str = tr_format_time_relative(now, latest); } } last_activity_lb_->set_text(str); } void DetailsDialog::Impl::info_page_init(Glib::RefPtr const& builder) { comment_buffer_ = Gtk::TextBuffer::create(); auto* tw = gtr_get_widget(builder, "comment_value_view"); tw->set_buffer(comment_buffer_); } /**** ***** ***** PEERS TAB ***** ****/ namespace { class WebseedModelColumns : public Gtk::TreeModelColumnRecord { public: WebseedModelColumns() noexcept { 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() noexcept { 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_number); add(upload_request_count_string); add(download_request_count_number); add(download_request_count_string); add(blocks_downloaded_count_number); add(blocks_downloaded_count_string); add(blocks_uploaded_count_number); add(blocks_uploaded_count_string); add(reqs_cancelled_by_client_count_number); add(reqs_cancelled_by_client_count_string); add(reqs_cancelled_by_peer_count_number); 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_number; Gtk::TreeModelColumn upload_request_count_string; Gtk::TreeModelColumn download_request_count_number; Gtk::TreeModelColumn download_request_count_string; Gtk::TreeModelColumn blocks_downloaded_count_number; Gtk::TreeModelColumn blocks_downloaded_count_string; Gtk::TreeModelColumn blocks_uploaded_count_number; Gtk::TreeModelColumn blocks_uploaded_count_string; Gtk::TreeModelColumn reqs_cancelled_by_client_count_number; Gtk::TreeModelColumn reqs_cancelled_by_client_count_string; Gtk::TreeModelColumn reqs_cancelled_by_peer_count_number; 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::TreeModel::iterator const& iter, std::string_view key, std::string_view torrent_name, 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 = ""; } auto peer_addr4 = in_addr(); // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) auto const* const peer_addr4_octets = reinterpret_cast(&peer_addr4.s_addr); auto const collated_name = inet_pton(AF_INET, std::data(peer->addr), &peer_addr4) != 1 ? std::data(peer->addr) : fmt::format( "{:03}", fmt::join( peer_addr4_octets, // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) peer_addr4_octets + sizeof(peer_addr4.s_addr), // TODO(C++20): Use std::span ".")); (*iter)[peer_cols.address] = std::data(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] = std::string(key); (*iter)[peer_cols.torrent_name] = std::string(torrent_name); } void refreshPeerRow(Gtk::TreeModel::iterator 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->activeReqsToPeer > 0) { down_count = std::to_string(peer->activeReqsToPeer); } if (peer->activeReqsToClient > 0) { up_count = std::to_string(peer->activeReqsToClient); } 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] = static_cast(100.0 * peer->progress); (*iter)[peer_cols.upload_request_count_number] = peer->activeReqsToClient; (*iter)[peer_cols.upload_request_count_string] = up_count; (*iter)[peer_cols.download_request_count_number] = peer->activeReqsToPeer; (*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] = std::data(peer->flagStr); (*iter)[peer_cols.was_updated] = true; (*iter)[peer_cols.blocks_downloaded_count_number] = peer->blocksToClient; (*iter)[peer_cols.blocks_downloaded_count_string] = blocks_to_client; (*iter)[peer_cols.blocks_uploaded_count_number] = peer->blocksToPeer; (*iter)[peer_cols.blocks_uploaded_count_string] = blocks_to_peer; (*iter)[peer_cols.reqs_cancelled_by_client_count_number] = peer->cancelsToPeer; (*iter)[peer_cols.reqs_cancelled_by_client_count_string] = cancelled_by_client; (*iter)[peer_cols.reqs_cancelled_by_peer_count_number] = 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 const& 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) { size_t 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& row : store->children()) { row[peer_cols.was_updated] = false; } auto make_key = [](tr_torrent const* tor, tr_peer_stat const* ps) { return fmt::format(FMT_STRING("{:d}.{:s}"), tr_torrentId(tor), ps->addr); }; /* step 3: add any new peers */ for (size_t i = 0; i < torrents.size(); ++i) { auto const* tor = torrents.at(i); for (size_t j = 0; j < peerCount[i]; ++j) { auto const* s = &peers.at(i)[j]; auto const key = make_key(tor, s); 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 (size_t j = 0; j < peerCount[i]; ++j) { auto const* s = &peers.at(i)[j]; auto const key = make_key(tor, s); 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 const& store = webseed_store_; auto make_key = [](tr_torrent const* tor, char const* url) { return fmt::format(FMT_STRING("{:d}.{:s}"), tr_torrentId(tor), url); }; /* step 1: mark all webseeds as not-updated */ for (auto& 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 = make_key(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 = make_key(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 = nullptr; 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(); // % is percent done 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(TR_GTK_TREE_VIEW_COLUMN_SIZING(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_number; } 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_number; } 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_number; } 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_number; } 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_number; } 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_number; } 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 { std::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_); } void DetailsDialog::Impl::peer_page_init(Glib::RefPtr const& builder) { /* webseeds */ webseed_store_ = Gtk::ListStore::create(webseed_cols); auto* v = gtr_get_widget(builder, "webseeds_view"); v->set_model(webseed_store_); setup_item_view_button_event_handling( *v, {}, [v](double view_x, double view_y) { return on_item_view_button_released(*v, view_x, view_y); }); { auto* r = Gtk::make_managed(); r->property_ellipsize() = TR_PANGO_ELLIPSIZE_MODE(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); } /* peers */ peer_store_ = Gtk::ListStore::create(peer_cols); auto m = Gtk::TreeModelSort::create(peer_store_); m->set_sort_column(peer_cols.progress, TR_GTK_SORT_TYPE(DESCENDING)); peer_view_->set_model(m); peer_view_->set_has_tooltip(true); peer_view_->signal_query_tooltip().connect(sigc::mem_fun(*this, &Impl::onPeerViewQueryTooltip), false); setup_item_view_button_event_handling( *peer_view_, {}, [this](double view_x, double view_y) { return on_item_view_button_released(*peer_view_, view_x, view_y); }); setPeerViewColumns(peer_view_); 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)); } /**** ***** ***** TRACKER ***** ****/ namespace { auto constexpr ErrMarkupBegin = ""sv; auto constexpr ErrMarkupEnd = ""sv; auto constexpr TimeoutMarkupBegin = ""sv; auto constexpr TimeoutMarkupEnd = ""sv; auto constexpr SuccessMarkupBegin = ""sv; auto constexpr SuccessMarkupEnd = ""sv; std::array const text_dir_mark = { ""sv, "\u200E"sv, "\u200F"sv }; void appendAnnounceInfo(tr_tracker_view const& tracker, time_t const now, Gtk::TextDirection direction, std::ostream& gstr) { auto const dir_mark = text_dir_mark.at(static_cast(direction)); if (tracker.hasAnnounced && tracker.announceState != TR_TRACKER_INACTIVE) { gstr << '\n'; gstr << dir_mark; auto const time_span_ago = tr_format_time_relative(now, tracker.lastAnnounceTime); if (tracker.lastAnnounceSucceeded) { gstr << fmt::format( // {markup_begin} and {markup_end} should surround the peer text ngettext( "Got a list of {markup_begin}{peer_count} peer{markup_end} {time_span_ago}", "Got a list of {markup_begin}{peer_count} peers{markup_end} {time_span_ago}", tracker.lastAnnouncePeerCount), fmt::arg("markup_begin", SuccessMarkupBegin), fmt::arg("peer_count", tracker.lastAnnouncePeerCount), fmt::arg("markup_end", SuccessMarkupEnd), fmt::arg("time_span_ago", time_span_ago)); } else if (tracker.lastAnnounceTimedOut) { gstr << fmt::format( // {markup_begin} and {markup_end} should surround the time_span _("Peer list request {markup_begin}timed out {time_span_ago}{markup_end}; will retry"), fmt::arg("markup_begin", TimeoutMarkupBegin), fmt::arg("time_span_ago", time_span_ago), fmt::arg("markup_end", TimeoutMarkupEnd)); } else { gstr << fmt::format( // {markup_begin} and {markup_end} should surround the error _("Got an error '{markup_begin}{error}{markup_end}' {time_span_ago}"), fmt::arg("markup_begin", ErrMarkupBegin), fmt::arg("error", Glib::Markup::escape_text(std::data(tracker.lastAnnounceResult))), fmt::arg("markup_end", ErrMarkupEnd), fmt::arg("time_span_ago", time_span_ago)); } } switch (tracker.announceState) { case TR_TRACKER_INACTIVE: gstr << '\n'; gstr << dir_mark; gstr << _("No updates scheduled"); break; case TR_TRACKER_WAITING: gstr << '\n'; gstr << dir_mark; gstr << fmt::format( _("Asking for more peers {time_span_from_now}"), fmt::arg("time_span_from_now", tr_format_time_relative(now, tracker.nextAnnounceTime))); break; case TR_TRACKER_QUEUED: gstr << '\n'; gstr << dir_mark; gstr << _("Queued to ask for more peers"); break; case TR_TRACKER_ACTIVE: gstr << '\n'; gstr << dir_mark; gstr << fmt::format( // {markup_begin} and {markup_end} should surround time_span_ago _("Asked for more peers {markup_begin}{time_span_ago}{markup_end}"), fmt::arg("markup_begin", ""), fmt::arg("time_span_ago", tr_format_time_relative(now, tracker.lastAnnounceStartTime)), fmt::arg("markup_end", "")); break; default: g_assert_not_reached(); } } void appendScrapeInfo(tr_tracker_view const& tracker, time_t const now, Gtk::TextDirection direction, std::ostream& gstr) { auto const dir_mark = text_dir_mark.at(static_cast(direction)); if (tracker.hasScraped) { gstr << '\n'; gstr << dir_mark; auto const time_span_ago = tr_format_time_relative(now, tracker.lastScrapeTime); if (tracker.lastScrapeSucceeded) { gstr << fmt::format( // {markup_begin} and {markup_end} should surround the seeder/leecher text _("Tracker had {markup_begin}{seeder_count} {seeder_or_seeders} and {leecher_count} {leecher_or_leechers}{markup_end} {time_span_ago}"), fmt::arg("seeder_count", tracker.seederCount), fmt::arg("seeder_or_seeders", ngettext("seeder", "seeders", tracker.seederCount)), fmt::arg("leecher_count", tracker.leecherCount), fmt::arg("leecher_or_leechers", ngettext("leecher", "leechers", tracker.leecherCount)), fmt::arg("time_span_ago", time_span_ago), fmt::arg("markup_begin", SuccessMarkupBegin), fmt::arg("markup_end", SuccessMarkupEnd)); } else { gstr << fmt::format( // {markup_begin} and {markup_end} should surround the error text _("Got a scrape error '{markup_begin}{error}{markup_end}' {time_span_ago}"), fmt::arg("error", Glib::Markup::escape_text(std::data(tracker.lastScrapeResult))), fmt::arg("time_span_ago", time_span_ago), fmt::arg("markup_begin", ErrMarkupBegin), fmt::arg("markup_end", ErrMarkupEnd)); } } switch (tracker.scrapeState) { case TR_TRACKER_INACTIVE: break; case TR_TRACKER_WAITING: gstr << '\n'; gstr << dir_mark; gstr << fmt::format( _("Asking for peer counts in {time_span_from_now}"), fmt::arg("time_span_from_now", tr_format_time_relative(now, tracker.nextScrapeTime))); break; case TR_TRACKER_QUEUED: gstr << '\n'; gstr << dir_mark; gstr << _("Queued to ask for peer counts"); break; case TR_TRACKER_ACTIVE: gstr << '\n'; gstr << dir_mark; gstr << fmt::format( _("Asked for peer counts {markup_begin}{time_span_ago}{markup_end}"), fmt::arg("markup_begin", ""), fmt::arg("time_span_ago", tr_format_time_relative(now, tracker.lastScrapeStartTime)), fmt::arg("markup_end", "")); 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.at(static_cast(direction)); gstr << (tracker.isBackup ? "" : ""); gstr << Glib::Markup::escape_text( !key.empty() ? fmt::format(FMT_STRING("{:s} - {:s}"), tracker.host_and_port, key) : tracker.host_and_port); 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() noexcept { 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); } tr_torrent_id_t DetailsDialog::Impl::tracker_list_get_current_torrent_id() const { // if there's only one torrent in the dialog, always use it if (ids_.size() == 1) { return ids_.front(); } // otherwise, use the selected tracker's torrent auto const sel = tracker_view_->get_selection(); if (auto const iter = sel->get_selected(); iter) { return iter->get_value(tracker_cols.torrent_id); } return -1; } tr_torrent* DetailsDialog::Impl::tracker_list_get_current_torrent() const { return core_->find_torrent(tracker_list_get_current_torrent_id()); } namespace { void favicon_ready_cb(Glib::RefPtr const* pixbuf, Gtk::TreeRowReference& reference) { if (pixbuf != nullptr && *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 const& store = tracker_store_; 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& 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)); core_->favicon_cache().load( tracker.announce, [ref = Gtk::TreeRowReference(store, p)](auto const* pixbuf_refptr) mutable { favicon_ready_cb(pixbuf_refptr, 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::refreshFiles(std::vector const& torrents) { if (torrents.size() == 1) { file_list_->set_torrent(tr_torrentId(torrents.front())); file_list_->show(); file_label_->hide(); } else { file_list_->clear(); file_list_->hide(); file_label_->show(); } } 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(); } namespace { class EditTrackersDialog : public Gtk::Dialog { public: EditTrackersDialog( BaseObjectType* cast_item, Glib::RefPtr const& builder, DetailsDialog& parent, Glib::RefPtr const& core, tr_torrent const* torrent); ~EditTrackersDialog() override = default; TR_DISABLE_COPY_MOVE(EditTrackersDialog) static std::unique_ptr create( DetailsDialog& parent, Glib::RefPtr const& core, tr_torrent const* tor); private: void on_response(int response) override; private: DetailsDialog& parent_; Glib::RefPtr const core_; tr_torrent_id_t const torrent_id_; Gtk::TextView* const urls_view_; }; EditTrackersDialog::EditTrackersDialog( BaseObjectType* cast_item, Glib::RefPtr const& builder, DetailsDialog& parent, Glib::RefPtr const& core, tr_torrent const* torrent) : Gtk::Dialog(cast_item) , parent_(parent) , core_(core) , torrent_id_(tr_torrentId(torrent)) , urls_view_(gtr_get_widget(builder, "urls_view")) { set_title(fmt::format(_("{torrent_name} - Edit Trackers"), fmt::arg("torrent_name", tr_torrentName(torrent)))); set_transient_for(parent); urls_view_->get_buffer()->set_text(tr_torrentGetTrackerList(torrent)); } std::unique_ptr EditTrackersDialog::create( DetailsDialog& parent, Glib::RefPtr const& core, tr_torrent const* torrent) { auto const builder = Gtk::Builder::create_from_resource(gtr_get_full_resource_path("EditTrackersDialog.ui")); return std::unique_ptr( gtr_get_widget_derived(builder, "EditTrackersDialog", parent, core, torrent)); } void EditTrackersDialog::on_response(int response) { bool do_destroy = true; if (response == TR_GTK_RESPONSE_TYPE(ACCEPT)) { auto const text_buffer = urls_view_->get_buffer(); if (auto* const tor = core_->find_torrent(torrent_id_); tor != nullptr) { if (tr_torrentSetTrackerList(tor, text_buffer->get_text(false).c_str())) { parent_.refresh(); } else { auto w = std::make_shared( *this, _("List contains invalid URLs"), false, TR_GTK_MESSAGE_TYPE(ERROR), TR_GTK_BUTTONS_TYPE(CLOSE), true); w->set_secondary_text(_("Please correct the errors and try again.")); w->signal_response().connect([w](int /*response*/) mutable { w.reset(); }); w->show(); do_destroy = false; } } } if (do_destroy) { close(); } } } // namespace void DetailsDialog::Impl::on_edit_trackers() { if (auto const* const tor = tracker_list_get_current_torrent(); tor != nullptr) { auto d = std::shared_ptr(EditTrackersDialog::create(dialog_, core_, tor)); gtr_window_on_close(*d, [d]() mutable { d.reset(); }); d->show(); } } void DetailsDialog::Impl::on_tracker_list_selection_changed() { int const n = tracker_view_->get_selection()->count_selected_rows(); auto const* const 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); } namespace { class AddTrackerDialog : public Gtk::Dialog { public: AddTrackerDialog( BaseObjectType* cast_item, Glib::RefPtr const& builder, DetailsDialog& parent, Glib::RefPtr const& core, tr_torrent const* torrent); ~AddTrackerDialog() override = default; TR_DISABLE_COPY_MOVE(AddTrackerDialog) static std::unique_ptr create( DetailsDialog& parent, Glib::RefPtr const& core, tr_torrent const* tor); private: void on_response(int response) override; private: DetailsDialog& parent_; Glib::RefPtr const core_; tr_torrent_id_t const torrent_id_; Gtk::Entry* const url_entry_; }; AddTrackerDialog::AddTrackerDialog( BaseObjectType* cast_item, Glib::RefPtr const& builder, DetailsDialog& parent, Glib::RefPtr const& core, tr_torrent const* torrent) : Gtk::Dialog(cast_item) , parent_(parent) , core_(core) , torrent_id_(tr_torrentId(torrent)) , url_entry_(gtr_get_widget(builder, "url_entry")) { set_title(fmt::format(_("{torrent_name} - Add Tracker"), fmt::arg("torrent_name", tr_torrentName(torrent)))); set_transient_for(parent); gtr_paste_clipboard_url_into_entry(*url_entry_); } std::unique_ptr AddTrackerDialog::create( DetailsDialog& parent, Glib::RefPtr const& core, tr_torrent const* torrent) { auto const builder = Gtk::Builder::create_from_resource(gtr_get_full_resource_path("AddTrackerDialog.ui")); return std::unique_ptr( gtr_get_widget_derived(builder, "AddTrackerDialog", parent, core, torrent)); } void AddTrackerDialog::on_response(int response) { bool destroy = true; if (response == TR_GTK_RESPONSE_TYPE(ACCEPT)) { auto const url = gtr_str_strip(url_entry_->get_text()); if (!url.empty()) { if (tr_urlIsValidTracker(url.c_str())) { tr_variant top; tr_variantInitDict(&top, 2); tr_variantDictAddStrView(&top, TR_KEY_method, "torrent-set"sv); auto* const args = tr_variantDictAddDict(&top, TR_KEY_arguments, 2); tr_variantDictAddInt(args, TR_KEY_id, torrent_id_); auto* const trackers = tr_variantDictAddList(args, TR_KEY_trackerAdd, 1); tr_variantListAddStr(trackers, url.raw()); core_->exec(&top); parent_.refresh(); } else { gtr_unrecognized_url_dialog(*this, url); destroy = false; } } } if (destroy) { close(); } } } // namespace void DetailsDialog::Impl::on_tracker_list_add_button_clicked() { if (auto const* const tor = tracker_list_get_current_torrent(); tor != nullptr) { auto d = std::shared_ptr(AddTrackerDialog::create(dialog_, core_, tor)); gtr_window_on_close(*d, [d]() mutable { d.reset(); }); d->show(); } } 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_variantInitDict(&top, 2); tr_variantDictAddStrView(&top, TR_KEY_method, "torrent-set"sv); auto* const args = tr_variantDictAddDict(&top, TR_KEY_arguments, 2); tr_variantDictAddInt(args, TR_KEY_id, torrent_id); auto* const trackers = tr_variantDictAddList(args, TR_KEY_trackerRemove, 1); tr_variantListAddInt(trackers, tracker_id); core_->exec(&top); refresh(); } } void DetailsDialog::Impl::tracker_page_init(Glib::RefPtr const& /*builder*/) { int const pad = (GUI_PAD + GUI_PAD_BIG) / 2; 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)); tracker_view_->set_model(trackers_filtered_); setup_item_view_button_event_handling( *tracker_view_, [this](guint /*button*/, TrGdkModifierType /*state*/, double view_x, double view_y, bool context_menu_requested) { return on_item_view_button_pressed(*tracker_view_, view_x, view_y, context_menu_requested); }, [this](double view_x, double view_y) { return on_item_view_button_released(*tracker_view_, view_x, view_y); }); 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() = TR_PANGO_ELLIPSIZE_MODE(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); } add_tracker_button_->signal_clicked().connect(sigc::mem_fun(*this, &Impl::on_tracker_list_add_button_clicked)); edit_trackers_button_->signal_clicked().connect(sigc::mem_fun(*this, &Impl::on_edit_trackers)); remove_tracker_button_->signal_clicked().connect(sigc::mem_fun(*this, &Impl::on_tracker_list_remove_button_clicked)); scrape_check_->set_active(gtr_pref_flag_get(TR_KEY_show_tracker_scrapes)); scrape_check_->signal_toggled().connect(sigc::mem_fun(*this, &Impl::onScrapeToggled)); all_check_->set_active(gtr_pref_flag_get(TR_KEY_show_backup_trackers)); all_check_->signal_toggled().connect(sigc::mem_fun(*this, &Impl::onBackupToggled)); } /**** ***** DIALOG ****/ void DetailsDialog::Impl::refresh() { auto const torrents = getTorrents(); refreshInfo(torrents); refreshPeers(torrents); refreshTracker(torrents); refreshFiles(torrents); refreshOptions(torrents); if (torrents.empty()) { dialog_.response(TR_GTK_RESPONSE_TYPE(CLOSE)); } } void DetailsDialog::Impl::on_details_window_size_allocated() { int w = 0; int h = 0; #if GTKMM_CHECK_VERSION(4, 0, 0) dialog_.get_default_size(w, h); #else dialog_.get_size(w, h); #endif 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) { auto const builder = Gtk::Builder::create_from_resource(gtr_get_full_resource_path("DetailsDialog.ui")); return std::unique_ptr(gtr_get_widget_derived(builder, "DetailsDialog", parent, core)); } DetailsDialog::DetailsDialog( BaseObjectType* cast_item, Glib::RefPtr const& builder, Gtk::Window& parent, Glib::RefPtr const& core) : Gtk::Dialog(cast_item) , impl_(std::make_unique(*this, builder, core)) { set_transient_for(parent); } DetailsDialog::~DetailsDialog() = default; DetailsDialog::Impl::Impl(DetailsDialog& dialog, Glib::RefPtr const& builder, Glib::RefPtr const& core) : dialog_(dialog) , core_(core) , honor_limits_check_(gtr_get_widget(builder, "honor_limits_check")) , up_limited_check_(gtr_get_widget(builder, "upload_limit_check")) , up_limit_sping_(gtr_get_widget(builder, "upload_limit_spin")) , down_limited_check_(gtr_get_widget(builder, "download_limit_check")) , down_limit_spin_(gtr_get_widget(builder, "download_limit_spin")) , bandwidth_combo_(gtr_get_widget(builder, "priority_combo")) , ratio_combo_(gtr_get_widget(builder, "ratio_limit_combo")) , ratio_spin_(gtr_get_widget(builder, "ratio_limit_spin")) , idle_combo_(gtr_get_widget(builder, "idle_limit_combo")) , idle_spin_(gtr_get_widget(builder, "idle_limit_spin")) , max_peers_spin_(gtr_get_widget(builder, "max_peers_spin")) , added_lb_(gtr_get_widget(builder, "added_value_label")) , size_lb_(gtr_get_widget(builder, "torrent_size_value_label")) , state_lb_(gtr_get_widget(builder, "state_value_label")) , have_lb_(gtr_get_widget(builder, "have_value_label")) , dl_lb_(gtr_get_widget(builder, "downloaded_value_label")) , ul_lb_(gtr_get_widget(builder, "uploaded_value_label")) , error_lb_(gtr_get_widget(builder, "error_value_label")) , date_started_lb_(gtr_get_widget(builder, "running_time_value_label")) , eta_lb_(gtr_get_widget(builder, "remaining_time_value_label")) , last_activity_lb_(gtr_get_widget(builder, "last_activity_value_label")) , hash_lb_(gtr_get_widget(builder, "hash_value_label")) , privacy_lb_(gtr_get_widget(builder, "privacy_value_label")) , origin_lb_(gtr_get_widget(builder, "origin_value_label")) , destination_lb_(gtr_get_widget(builder, "location_value_label")) , webseed_view_(gtr_get_widget(builder, "webseeds_view_scroll")) , peer_view_(gtr_get_widget(builder, "peers_view")) , more_peer_details_check_(gtr_get_widget(builder, "more_peer_details_check")) , add_tracker_button_(gtr_get_widget(builder, "add_tracker_button")) , edit_trackers_button_(gtr_get_widget(builder, "edit_tracker_button")) , remove_tracker_button_(gtr_get_widget(builder, "remove_tracker_button")) , tracker_view_(gtr_get_widget(builder, "trackers_view")) , scrape_check_(gtr_get_widget(builder, "more_tracker_details_check")) , all_check_(gtr_get_widget(builder, "backup_trackers_check")) , file_list_(gtr_get_widget_derived(builder, "files_view_scroll", "files_view", core, 0)) , file_label_(gtr_get_widget(builder, "files_label")) { /* return saved window size */ auto const width = (int)gtr_pref_int_get(TR_KEY_details_window_width); auto const height = (int)gtr_pref_int_get(TR_KEY_details_window_height); #if GTKMM_CHECK_VERSION(4, 0, 0) dialog_.set_default_size(width, height); dialog_.property_default_width().signal_changed().connect(sigc::mem_fun(*this, &Impl::on_details_window_size_allocated)); dialog_.property_default_height().signal_changed().connect(sigc::mem_fun(*this, &Impl::on_details_window_size_allocated)); #else dialog_.resize(width, height); dialog_.signal_size_allocate().connect(sigc::hide<0>(sigc::mem_fun(*this, &Impl::on_details_window_size_allocated))); #endif dialog_.signal_response().connect(sigc::hide<0>(sigc::mem_fun(dialog_, &DetailsDialog::close))); info_page_init(builder); peer_page_init(builder); tracker_page_init(builder); options_page_init(builder); periodic_refresh_tag_ = Glib::signal_timeout().connect_seconds( [this]() { return refresh(), true; }, SECONDARY_WINDOW_REFRESH_INTERVAL_SECONDS); auto* const n = gtr_get_widget(builder, "dialog_pages"); n->set_current_page(last_page_); n->signal_switch_page().connect([](Gtk::Widget* /*page*/, guint page_number) { last_page_ = page_number; }); } void DetailsDialog::set_torrents(std::vector const& ids) { impl_->set_torrents(ids); } void DetailsDialog::refresh() { impl_->refresh(); } void DetailsDialog::Impl::set_torrents(std::vector const& ids) { Glib::ustring title; auto const len = ids.size(); ids_ = ids; if (len == 1) { int const id = ids.front(); auto const* tor = core_->find_torrent(id); title = fmt::format(_("{torrent_name} Properties"), fmt::arg("torrent_name", tr_torrentName(tor))); } else { title = fmt::format( ngettext("Properties - {torrent_count:L} Torrent", "Properties - {torrent_count:L} Torrents", len), fmt::arg("torrent_count", len)); } dialog_.set_title(title); refresh(); }