/* This file is part of Telegram Desktop, the official desktop application for the Telegram messaging service. For license and copyright information please follow this link: https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "settings/settings_search.h" #include "base/event_filter.h" #include "core/application.h" #include "core/click_handler_types.h" #include "lang/lang_keys.h" #include "main/main_session.h" #include "settings/settings_builder.h" #include "settings/settings_common.h" #include "settings/settings_faq_suggestions.h" #include "ui/painter.h" #include "ui/text/text_entity.h" #include "ui/search_field_controller.h" #include "ui/vertical_list.h" #include "ui/widgets/buttons.h" #include "ui/widgets/checkbox.h" #include "ui/widgets/elastic_scroll.h" #include "ui/widgets/fields/input_field.h" #include "ui/widgets/labels.h" #include "ui/widgets/scroll_area.h" #include "ui/wrap/padding_wrap.h" #include "ui/wrap/vertical_layout.h" #include "window/window_session_controller.h" #include "styles/style_info.h" #include "styles/style_layers.h" #include "styles/style_menu_icons.h" #include "styles/style_settings.h" #include "styles/style_widgets.h" namespace Settings { namespace { struct SearchResultItem { int index = 0; int matchCount = 0; }; [[nodiscard]] QStringList PrepareEntryWords(const Builder::SearchEntry &entry) { auto combined = entry.title; for (const auto &keyword : entry.keywords) { combined += ' ' + keyword; } return TextUtilities::PrepareSearchWords(combined); } [[nodiscard]] int CalculateDepth( Type sectionId, const Builder::SearchRegistry ®istry) { const auto path = registry.sectionPath(sectionId); if (path.isEmpty()) { return 0; } return path.count(u" > "_q) + 1; } void SetupCheckIcon( not_null button, Builder::SearchEntryCheckIcon checkIcon, const style::SettingsButton &st) { struct CheckWidget { CheckWidget(QWidget *parent, bool checked) : widget(parent) , view(st::defaultCheck, checked) { view.finishAnimating(); } Ui::RpWidget widget; Ui::CheckView view; }; const auto checked = (checkIcon == Builder::SearchEntryCheckIcon::Checked); const auto check = button->lifetime().make_state( button, checked); check->widget.setAttribute(Qt::WA_TransparentForMouseEvents); check->widget.resize(check->view.getSize()); check->widget.show(); button->sizeValue( ) | rpl::on_next([=, left = st.iconLeft](QSize size) { check->widget.moveToLeft( left, (size.height() - check->widget.height()) / 2, size.width()); }, check->widget.lifetime()); check->widget.paintRequest( ) | rpl::on_next([=](QRect clip) { auto p = QPainter(&check->widget); check->view.paint(p, 0, 0, check->widget.width()); p.setOpacity(0.5); p.fillRect(clip, st::boxBg); }, check->widget.lifetime()); } [[nodiscard]] not_null CreateSearchResultButtonRaw( not_null parent, const QString &title, const QString &subtitle, const style::SettingsButton &st, IconDescriptor &&icon, Builder::SearchEntryCheckIcon checkIcon) { auto buttonObj = CreateButtonWithIcon( parent, rpl::single(title), st, std::move(icon)); const auto button = buttonObj.release(); button->setPointerCursor(false); if (checkIcon != Builder::SearchEntryCheckIcon::None) { SetupCheckIcon(button, checkIcon, st); } const auto details = Ui::CreateChild( button, subtitle, st::settingsSearchResultDetails); details->show(); details->moveToLeft( st.padding.left(), st.padding.top() + st.height - details->height()); details->setAttribute(Qt::WA_TransparentForMouseEvents); return button; } } // namespace Search::Search( QWidget *parent, not_null controller) : Section(parent, controller) { setupContent(); } rpl::producer Search::title() { return tr::lng_dlg_filter(); } void Search::setInnerFocus() { if (_searchField) { _searchField->setFocus(); } } base::weak_qptr Search::createPinnedToTop( not_null parent) { _searchController = std::make_unique(""); auto rowView = _searchController->createRowView( parent, st::infoLayerMediaSearch); _searchField = rowView.field; _searchField->customUpDown(true); const auto searchContainer = Ui::CreateChild( parent.get(), st::infoLayerMediaSearch.height); const auto wrap = rowView.wrap.release(); wrap->setParent(searchContainer); wrap->show(); searchContainer->widthValue( ) | rpl::on_next([=](int width) { wrap->resizeToWidth(width); wrap->moveToLeft(0, 0); }, searchContainer->lifetime()); _searchController->queryChanges() | rpl::on_next([=](QString &&query) { rebuildResults(std::move(query)); }, searchContainer->lifetime()); _searchField->submits( ) | rpl::on_next([=](Qt::KeyboardModifiers) { const auto index = (_selected >= 0) ? _selected : 0; if (index < int(_visibleButtons.size())) { _visibleButtons[index]->clicked( Qt::NoModifier, Qt::LeftButton); } }, searchContainer->lifetime()); base::install_event_filter(_searchField, [=](not_null e) { if (e->type() != QEvent::KeyPress) { return base::EventFilterResult::Continue; } const auto key = static_cast(e.get())->key(); if (key == Qt::Key_Up || key == Qt::Key_Down || key == Qt::Key_PageUp || key == Qt::Key_PageDown) { handleKeyNavigation(key); return base::EventFilterResult::Cancel; } return base::EventFilterResult::Continue; }, lifetime()); if (!_pendingQuery.isEmpty()) { _searchField->setText(base::take(_pendingQuery)); } return base::make_weak(not_null{ searchContainer }); } void Search::setupContent() { const auto content = Ui::CreateChild(this); _list = content->add(object_ptr(content)); setupCustomizations(); buildIndex(); rebuildResults(QString()); controller()->session().faqSuggestions().loadedValue( ) | rpl::filter([](bool loaded) { return loaded; }) | rpl::take(1) | rpl::on_next([=] { for (auto i = _faqStartIndex; i < int(_entries.size()); ++i) { const auto it = _buttonCache.find(i); if (it != _buttonCache.end()) { _trackedButtons.remove(it->second); delete it->second; _buttonCache.erase(it); } } buildIndex(); const auto query = _searchController ? _searchController->query() : QString(); rebuildResults(query); }, lifetime()); Ui::ResizeFitChild(this, content); } void Search::setupCustomizations() { const auto isPaused = Window::PausedIn( controller(), Window::GifPauseReason::Layer); const auto add = [&](const QString &id, ResultCustomization value) { _customizations[id] = std::move(value); }; add(u"main/credits"_q, { .hook = [=](not_null b) { AddPremiumStar(b, true, isPaused); }, .st = &st::settingsSearchResult, }); add(u"main/premium"_q, { .hook = [=](not_null b) { AddPremiumStar(b, false, isPaused); }, .st = &st::settingsSearchResult, }); } void Search::buildIndex() { _entries.clear(); _firstLetterIndex.clear(); const auto ®istry = Builder::SearchRegistry::Instance(); const auto rawEntries = registry.collectAll(&controller()->session()); _entries.reserve(rawEntries.size()); for (const auto &entry : rawEntries) { auto indexed = IndexedEntry{ .entry = entry, .terms = PrepareEntryWords(entry), .depth = CalculateDepth(entry.section, registry), }; _entries.push_back(std::move(indexed)); } _faqStartIndex = int(_entries.size()); const auto &faq = controller()->session().faqSuggestions(); for (const auto &faqEntry : faq.entries()) { auto entry = Builder::SearchEntry{ .title = faqEntry.title, }; auto indexed = IndexedEntry{ .entry = std::move(entry), .terms = TextUtilities::PrepareSearchWords(faqEntry.title), .depth = 1000, .faqUrl = faqEntry.url, .faqSection = faqEntry.section, }; _entries.push_back(std::move(indexed)); } for (auto i = 0; i < int(_entries.size()); ++i) { for (const auto &term : _entries[i].terms) { if (!term.isEmpty()) { _firstLetterIndex[term[0]].insert(i); } } } } void Search::clearSelection() { if (_selected >= 0 && _selected < int(_visibleButtons.size())) { _visibleButtons[_selected]->setSynteticOver(false); } _selected = -1; } void Search::scrollToButton(not_null button) { const auto scrollIn = [&](auto &&scroll) { if (const auto inner = scroll->widget()) { const auto globalPos = button->mapToGlobal(QPoint(0, 0)); const auto localPos = inner->mapFromGlobal(globalPos); scroll->scrollToY( localPos.y(), localPos.y() + button->height()); } }; for (auto widget = button->parentWidget() ; widget ; widget = widget->parentWidget()) { if (const auto scroll = dynamic_cast(widget)) { scrollIn(scroll); return; } if (const auto scroll = dynamic_cast(widget)) { scrollIn(scroll); return; } } } void Search::selectByKeyboard(int newSelected) { const auto count = int(_visibleButtons.size()); if (!count) { return; } newSelected = std::clamp(newSelected, 0, count - 1); if (newSelected == _selected) { return; } const auto applySelection = [&] { for (auto i = 0; i < count; ++i) { if (i != newSelected && _visibleButtons[i]->isOver()) { _visibleButtons[i]->setSynteticOver(false); } } _selected = newSelected; _visibleButtons[_selected]->setSynteticOver(true); }; applySelection(); scrollToButton(_visibleButtons[_selected]); applySelection(); } void Search::setupButtonMouseTracking( not_null button) { if (!_trackedButtons.emplace(button).second) { return; } button->events( ) | rpl::filter([](not_null e) { return e->type() == QEvent::Enter; }) | rpl::on_next([=] { if (_selected >= 0) { clearSelection(); } }, button->lifetime()); } void Search::handleKeyNavigation(int key) { constexpr auto kPageSkip = 5; const auto startIndex = [&] { if (_selected >= 0) { return _selected; } for (auto i = 0; i < int(_visibleButtons.size()); ++i) { if (_visibleButtons[i]->isOver()) { return i; } } return -1; }(); if (key == Qt::Key_Down) { selectByKeyboard((startIndex < 0) ? 0 : (startIndex + 1)); } else if (key == Qt::Key_Up) { if (startIndex > 0) { selectByKeyboard(startIndex - 1); } else if (startIndex == 0) { clearSelection(); } } else if (key == Qt::Key_PageDown) { selectByKeyboard((startIndex < 0) ? 0 : (startIndex + kPageSkip)); } else if (key == Qt::Key_PageUp) { if (startIndex > 0) { selectByKeyboard(startIndex - kPageSkip); } else if (startIndex == 0) { clearSelection(); } } } void Search::rebuildResults(const QString &query) { for (auto i = 0, count = _list->count(); i != count; ++i) { _list->widgetAt(i)->hide(); } _list->clear(); clearSelection(); _visibleButtons.clear(); const auto queryWords = TextUtilities::PrepareSearchWords(query); if (queryWords.isEmpty()) { rebuildFaqResults(); return; } auto results = std::vector(); { auto toFilter = (const base::flat_set*)nullptr; for (const auto &word : queryWords) { if (word.isEmpty()) { continue; } const auto it = _firstLetterIndex.find(word[0]); if (it == _firstLetterIndex.end() || it->second.empty()) { toFilter = nullptr; break; } else if (!toFilter || it->second.size() < toFilter->size()) { toFilter = &it->second; } } if (toFilter) { for (const auto entryIndex : *toFilter) { const auto &indexed = _entries[entryIndex]; auto matched = 0; for (const auto &queryWord : queryWords) { for (const auto &term : indexed.terms) { if (term.startsWith(queryWord)) { ++matched; break; } } } if (matched > 0) { results.push_back({ .index = entryIndex, .matchCount = matched, }); } } ranges::sort(results, [&](const auto &a, const auto &b) { if (a.matchCount != b.matchCount) { return a.matchCount > b.matchCount; } const auto &entryA = _entries[a.index]; const auto &entryB = _entries[b.index]; if (entryA.depth != entryB.depth) { return entryA.depth < entryB.depth; } return entryA.entry.title < entryB.entry.title; }); } } if (results.empty() && !queryWords.isEmpty()) { _list->add( object_ptr( _list, tr::lng_search_tab_no_results(), st::settingsSearchNoResults), st::settingsSearchNoResultsPadding); } else { const auto showOther = showOtherMethod(); const auto ®istry = Builder::SearchRegistry::Instance(); const auto faqSubtitle = tr::lng_settings_faq_subtitle(tr::now); const auto weak = base::make_weak(controller()); for (const auto &result : results) { const auto entryIndex = result.index; const auto &indexed = _entries[entryIndex]; const auto &entry = indexed.entry; const auto isFaq = !indexed.faqUrl.isEmpty(); const auto cached = _buttonCache.find(entryIndex); if (cached != _buttonCache.end()) { addButton(cached->second); continue; } auto subtitle = QString(); if (isFaq) { subtitle = faqSubtitle + u" > "_q + indexed.faqSection; } else { const auto parentsOnly = entry.id.isEmpty(); subtitle = registry.sectionPath(entry.section, parentsOnly); } const auto hasIcon = entry.icon.icon != nullptr; const auto hasCheckIcon = !hasIcon && (entry.checkIcon != Builder::SearchEntryCheckIcon::None); const auto it = _customizations.find(entry.id); const auto custom = (it != _customizations.end()) ? &it->second : nullptr; const auto &st = custom && custom->st ? *custom->st : (hasIcon || hasCheckIcon) ? st::settingsSearchResult : st::settingsSearchResultNoIcon; const auto button = CreateSearchResultButtonRaw( this, entry.title, subtitle, st, IconDescriptor{ entry.icon.icon }, (hasCheckIcon ? entry.checkIcon : Builder::SearchEntryCheckIcon::None)); if (custom && custom->hook) { custom->hook(button); } if (isFaq) { const auto url = indexed.faqUrl; button->addClickHandler([=] { UrlClickHandler::Open( url, QVariant::fromValue(ClickHandlerContext{ .sessionWindow = weak, })); }); } else { const auto targetSection = entry.section; const auto controlId = entry.id; const auto deeplink = entry.deeplink; button->addClickHandler([=] { if (!deeplink.isEmpty()) { Core::App().openLocalUrl( deeplink, QVariant::fromValue(ClickHandlerContext{ .sessionWindow = base::make_weak(controller()), })); } else { controller()->setHighlightControlId(controlId); showOther(targetSection); } }); } _buttonCache.emplace(entryIndex, button); addButton(button); } } _list->resizeToWidth(_list->width()); } void Search::sectionSaveState(std::any &state) { const auto query = _searchController ? _searchController->query() : _pendingQuery; if (!query.isEmpty()) { state = SearchSectionState{ query }; } } void Search::sectionRestoreState(const std::any &state) { const auto saved = std::any_cast(&state); if (saved && !saved->query.isEmpty()) { if (_searchField) { _searchField->setText(saved->query); } else { _pendingQuery = saved->query; } } } void Search::rebuildFaqResults() { if (_faqStartIndex >= int(_entries.size())) { return; } const auto faqSubtitle = tr::lng_settings_faq_subtitle(tr::now); const auto weak = base::make_weak(controller()); for (auto i = _faqStartIndex; i < int(_entries.size()); ++i) { const auto &indexed = _entries[i]; const auto cached = _buttonCache.find(i); if (cached != _buttonCache.end()) { addButton(cached->second); continue; } const auto subtitle = faqSubtitle + u" > "_q + indexed.faqSection; const auto button = CreateSearchResultButtonRaw( this, indexed.entry.title, subtitle, st::settingsSearchResultNoIcon, IconDescriptor{}, Builder::SearchEntryCheckIcon::None); const auto url = indexed.faqUrl; button->addClickHandler([=] { UrlClickHandler::Open( url, QVariant::fromValue(ClickHandlerContext{ .sessionWindow = weak, })); }); _buttonCache.emplace(i, button); addButton(button); } _list->resizeToWidth(_list->width()); } void Search::addButton(not_null button) { button->show(); _list->add(object_ptr::fromRaw(button)); _visibleButtons.push_back(button); setupButtonMouseTracking(button); } } // namespace Settings