/* * This file Copyright (C) 2007-2021 Mnemosyne LLC * * It may be used under the GNU GPL versions 2 or 3 * or any future license endorsed by Mnemosyne LLC. * */ #include #include /* INT_MAX */ #include #include #include /* sscanf() */ #include /* abort() */ #include #include #include #include #include /* tr_free */ #include "actions.h" #include "conf.h" #include "details.h" #include "favicon.h" /* gtr_get_favicon() */ #include "file-list.h" #include "hig.h" #include "tr-core.h" #include "tr-prefs.h" #include "util.h" using namespace std::literals; namespace { auto const TORRENT_ID_KEY = Glib::Quark("tr-torrent-id-key"); auto const TEXT_BUFFER_KEY = Glib::Quark("tr-text-buffer-key"); auto const URL_ENTRY_KEY = Glib::Quark("tr-url-entry-key"); } // namespace class DetailsDialog::Impl { public: Impl(DetailsDialog& dialog, Glib::RefPtr const& core); ~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, Gtk::Dialog* dialog); void on_edit_trackers_response(int response, Gtk::Dialog* 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_; }; 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_variantDictAddStr(&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_variantDictAddStr(&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_variantDictAddStr(&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"); } 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_torrentInfo(torrent)); } /* privacy_lb */ if (infos.empty()) { str = no_torrent; } else { bool const baseline = infos.front()->isPrivate; bool const is_uniform = std::all_of( infos.begin(), infos.end(), [baseline](auto const* info) { return info->isPrivate == 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 : ""); time_t const date = infos.front()->dateCreated; 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->dateCreated; }); 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* const info : infos) { size += info->totalSize; pieces += info->pieceCount; if (pieceSize == 0) { pieceSize = info->pieceSize; } else if (pieceSize != (int)info->pieceSize) { pieceSize = -1; } } auto const sizebuf = tr_strlsize(size); if (size == 0) { str.clear(); } else if (pieceSize >= 0) { char piecebuf[128]; tr_formatter_mem_B(piecebuf, pieceSize, sizeof(piecebuf)); str = gtr_sprintf( ngettext("%1$s (%2$'d piece @ %3$s)", "%1$s (%2$'d pieces @ %3$s)", pieces), sizebuf, pieces, piecebuf); } 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()->hashString; } 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 ? "transmission-lock" : ""; (*iter)[peer_cols.key] = key; (*iter)[peer_cols.torrent_name] = torrentName; } void refreshPeerRow(Gtk::TreeIter const& iter, tr_peer_stat const* peer) { char up_speed[64] = { '\0' }; char down_speed[64] = { '\0' }; 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) { tr_formatter_speed_KBps(up_speed, peer->rateToPeer_KBps, sizeof(up_speed)); } if (peer->rateToClient_KBps > 0) { tr_formatter_speed_KBps(down_speed, peer->rateToClient_KBps, sizeof(down_speed)); } 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.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) { int total = 0; 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) { auto const* inf = tr_torrentInfo(tor); total += inf->webseedCount; for (unsigned int j = 0; j < inf->webseedCount; ++j) { char const* url = inf->webseeds[j]; 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.emplace(key, Gtk::TreeRowReference(store, store->get_path(iter))); } } } /* step 3: update the webseeds */ for (auto const* const tor : torrents) { auto const* inf = tr_torrentInfo(tor); double* speeds_KBps = tr_torrentWebSpeeds_KBps(tor); for (unsigned int j = 0; j < inf->webseedCount; ++j) { char const* const url = inf->webseeds[j]; auto const key = gtr_sprintf("%d.%s", tr_torrentId(tor), url); auto const iter = store->get_iter(hash.at(key).get_path()); char buf[128] = { 0 }; if (speeds_KBps[j] > 0) { tr_formatter_speed_KBps(buf, speeds_KBps[j], sizeof(buf)); } (*iter)[webseed_cols.download_rate_double] = speeds_KBps[j]; (*iter)[webseed_cols.download_rate_string] = buf; (*iter)[webseed_cols.was_updated] = true; } tr_free(speeds_KBps); } /* 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(total > 0); } 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 (auto 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; } 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_stock_id(), *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_rules_hint(true); 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[] = ""; // 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_stat const* const st, time_t const now, std::ostream& gstr) { if (st->hasAnnounced && st->announceState != TR_TRACKER_INACTIVE) { gstr << '\n'; auto const timebuf = tr_strltime_rounded(now - st->lastAnnounceTime); if (st->lastAnnounceSucceeded) { gstr << gtr_sprintf( _("Got a list of %1$s%2$'d peers%3$s %4$s ago"), success_markup_begin, st->lastAnnouncePeerCount, success_markup_end, timebuf); } else if (st->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, st->lastAnnounceResult, err_markup_end, timebuf); } } switch (st->announceState) { case TR_TRACKER_INACTIVE: gstr << '\n'; gstr << _("No updates scheduled"); break; case TR_TRACKER_WAITING: gstr << '\n'; gstr << gtr_sprintf(_("Asking for more peers in %s"), tr_strltime_rounded(st->nextAnnounceTime - now)); break; case TR_TRACKER_QUEUED: gstr << '\n'; gstr << _("Queued to ask for more peers"); break; case TR_TRACKER_ACTIVE: gstr << '\n'; gstr << gtr_sprintf( _("Asking for more peers now… %s"), tr_strltime_rounded(now - st->lastAnnounceStartTime)); break; } } void appendScrapeInfo(tr_tracker_stat const* const st, time_t const now, std::ostream& gstr) { if (st->hasScraped) { gstr << '\n'; auto const timebuf = tr_strltime_rounded(now - st->lastScrapeTime); if (st->lastScrapeSucceeded) { gstr << gtr_sprintf( _("Tracker had %s%'d seeders and %'d leechers%s %s ago"), success_markup_begin, st->seederCount, st->leecherCount, success_markup_end, timebuf); } else { gstr << gtr_sprintf( _("Got a scrape error \"%s%s%s\" %s ago"), err_markup_begin, st->lastScrapeResult, err_markup_end, timebuf); } } switch (st->scrapeState) { case TR_TRACKER_INACTIVE: break; case TR_TRACKER_WAITING: gstr << '\n'; gstr << gtr_sprintf(_("Asking for peer counts in %s"), tr_strltime_rounded(st->nextScrapeTime - now)); break; case TR_TRACKER_QUEUED: gstr << '\n'; gstr << _("Queued to ask for peer counts"); break; case TR_TRACKER_ACTIVE: gstr << '\n'; gstr << gtr_sprintf( _("Asking for peer counts now… %s"), tr_strltime_rounded(now - st->lastScrapeStartTime)); break; } } void buildTrackerSummary(std::ostream& gstr, std::string const& key, tr_tracker_stat const* st, bool showScrape) { // hostname gstr << (st->isBackup ? "" : ""); gstr << Glib::Markup::escape_text(!key.empty() ? gtr_sprintf("%s - %s", st->host, key) : st->host); gstr << (st->isBackup ? "" : ""); if (!st->isBackup) { time_t const now = time(nullptr); appendAnnounceInfo(st, now, gstr); if (showScrape) { appendScrapeInfo(st, now, 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 */ std::vector statCount; std::vector stats; statCount.reserve(torrents.size()); stats.reserve(torrents.size()); for (auto const* torrent : torrents) { int count = 0; stats.push_back(tr_torrentTrackers(torrent, &count)); statCount.push_back(count); } /* 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 any new trackers */ for (size_t i = 0; i < statCount.size(); ++i) { int const jn = statCount.at(i); for (int j = 0; j < jn; ++j) { tr_torrent const* tor = torrents.at(i); tr_tracker_stat const* st = &stats.at(i)[j]; int const torrent_id = tr_torrentId(tor); /* build the key to find the row */ gstr.str({}); gstr << torrent_id << '\t' << st->tier << '\t' << st->announce; if (hash.find(gstr.str()) == hash.end()) { auto const iter = store->append(); (*iter)[tracker_cols.torrent_id] = torrent_id; (*iter)[tracker_cols.tracker_id] = st->id; (*iter)[tracker_cols.key] = gstr.str(); auto const p = store->get_path(iter); hash.emplace(gstr.str(), Gtk::TreeRowReference(store, p)); gtr_get_favicon_from_url( session, st->announce, [ref = Gtk::TreeRowReference(store, p)](auto const& pixbuf) mutable { favicon_ready_cb(pixbuf, ref); }); } } } /* step 4: update the peers */ for (size_t i = 0; i < torrents.size(); ++i) { tr_torrent const* tor = torrents.at(i); auto const summary_name = std::string(torrents.size() > 1 ? tr_torrentName(tor) : ""); for (int j = 0; j < statCount.at(i); ++j) { tr_tracker_stat const* st = &stats.at(i)[j]; /* build the key to find the row */ gstr.str({}); gstr << tr_torrentId(tor) << '\t' << st->tier << '\t' << st->announce; auto const iter = store->get_iter(hash.at(gstr.str()).get_path()); /* update the row */ gstr.str({}); buildTrackerSummary(gstr, summary_name, st, showScrape); (*iter)[tracker_cols.text] = gstr.str(); (*iter)[tracker_cols.is_backup] = st->isBackup; (*iter)[tracker_cols.tracker_id] = st->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); /* cleanup */ for (size_t i = 0; i < stats.size(); ++i) { tr_torrentTrackersFree(stats[i], statCount[i]); } } 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, Gtk::Dialog* dialog) { bool do_destroy = true; if (response == Gtk::RESPONSE_ACCEPT) { int 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); std::vector trackers; std::list announce_urls; int tier = 0; std::string str; while (std::getline(tracker_strings, str)) { if (str.empty()) { ++tier; } else { announce_urls.push_front(str); trackers.push_back(tr_tracker_info{ tier, announce_urls.front().data(), nullptr, 0 }); } } /* update the torrent */ if (tr_torrentSetAnnounceList(tor, trackers.data(), trackers.size())) { 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) { delete dialog; } } namespace { std::string get_editable_tracker_list(tr_torrent const* tor) { std::ostringstream gstr; int tier = 0; tr_info const* inf = tr_torrentInfo(tor); for (unsigned int i = 0; i < inf->trackerCount; ++i) { tr_tracker_info const* t = &inf->trackers[i]; if (tier != t->tier) { tier = t->tier; gstr << '\n'; } gstr << t->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 = new Gtk::Dialog( 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) { 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, Gtk::Dialog* dialog) { bool destroy = true; if (response == Gtk::RESPONSE_ACCEPT) { auto* e = static_cast(dialog->get_data(URL_ENTRY_KEY)); int 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_variantDictAddStr(&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) { delete dialog; } } 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 = new Gtk::Dialog( 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) { 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_variantDictAddStr(&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); auto const* inf = tr_torrentInfo(tor); title = gtr_sprintf(_("%s Properties"), inf->name); 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(); }