diff --git a/Modules/Settings/Common/PageBase.qml b/Modules/Settings/Common/PageBase.qml index 0a474ea..4bf164f 100644 --- a/Modules/Settings/Common/PageBase.qml +++ b/Modules/Settings/Common/PageBase.qml @@ -9,8 +9,6 @@ import qs.Config ColumnLayout { id: root - // Enables a smooth scroll animation only for search jumps, so normal - // flicking stays instant. property bool animateScroll: false readonly property int cappedWidth: Math.min(800, width) default property Item contentChild @@ -31,7 +29,7 @@ ColumnLayout { function findAnchor(item: Item, anchor: string): Item { if (!item) return null; - if (item.settingAnchor !== undefined && item.settingAnchor === anchor) // qmllint disable missing-property + if (item.settingAnchor !== undefined && item.settingAnchor === anchor) return item; const kids = item.children; for (let i = 0; i < kids.length; i++) { @@ -42,14 +40,12 @@ ColumnLayout { return null; } - // Flash a row without scrolling (used when re-selecting the current setting). function highlightAnchor(anchor: string): void { const row = findAnchor(contentChild, anchor); - if (row && row.flashHighlight !== undefined) // qmllint disable missing-property - row.flashHighlight(); // qmllint disable missing-property + if (row && row.flashHighlight !== undefined) + row.flashHighlight(); } - // When the settings search jumps to this page, scroll to the matching row. function scrollToAnchor(anchor: string): bool { if (!anchor || !contentChild) return false; @@ -57,8 +53,6 @@ ColumnLayout { if (!row) return false; const pos = row.mapToItem(flickable.contentItem, 0, 0); - // Land the row below the top fade so it isn't dimmed by the edge effect, - // clamped to the flickable's real scroll range (which includes margins). const inset = flickable.height * flickable.fadeAmount + Appearance.padding.large; const minY = -flickable.topMargin; const maxY = Math.max(minY, flickable.contentHeight + flickable.bottomMargin - flickable.height); @@ -66,8 +60,8 @@ ColumnLayout { root.animateScroll = true; flickable.contentY = target; Qt.callLater(() => root.animateScroll = false); - if (row.flashHighlight !== undefined) // qmllint disable missing-property - row.flashHighlight(); // qmllint disable missing-property + if (row.flashHighlight !== undefined) + row.flashHighlight(); return true; } @@ -86,10 +80,6 @@ ColumnLayout { repeat: true onTriggered: { - // Pages like the ethernet detail load their content asynchronously - // (device info, IP config), so the layout keeps growing for a while. - // Wait until contentHeight has held steady for a few frames (or we've - // waited long enough) before scrolling, so the target doesn't drift. const h = flickable.contentHeight; if (h === lastHeight && h > flickable.height) stableFrames++; @@ -119,9 +109,8 @@ ColumnLayout { target: root.sState } - MouseArea { // Prevent clicks from reaching flickable - Layout.bottomMargin: -flickable.topMargin // Extra height to block clicks on flickable top margin - + MouseArea { + Layout.bottomMargin: -flickable.topMargin implicitHeight: header.implicitHeight - Layout.bottomMargin implicitWidth: header.implicitWidth z: 1 diff --git a/Modules/Settings/NavPane/NavLocations.qml b/Modules/Settings/NavPane/NavLocations.qml index 18326d9..a65fd36 100644 --- a/Modules/Settings/NavPane/NavLocations.qml +++ b/Modules/Settings/NavPane/NavLocations.qml @@ -10,9 +10,6 @@ import qs.Modules.Settings VerticalFadeFlickable { id: root - // Results grouped by their top-level page, so the list can show one heading - // per page with the matching settings joined underneath it (like the - // Android settings search). Each group: { page, entries: [...] }. readonly property var groups: { const out = []; const byPage = ({}); @@ -154,12 +151,6 @@ VerticalFadeFlickable { ListView { id: resultList - // Grouped results: the model is one entry per top-level page, and - // each delegate renders that page's heading plus the matching - // settings joined into a single rounded card (first/last rounded, - // middles square, thin dividers between them), like the Android - // settings search. A ScriptModel diffs the groups so only changed - // ones animate. Scrolling is delegated to the outer flickable. Layout.fillWidth: true cacheBuffer: 10000 implicitHeight: contentHeight @@ -175,7 +166,6 @@ VerticalFadeFlickable { spacing: Appearance.spacing.small width: resultList.width - // Group heading: the top-level page name, shown once. RowLayout { Layout.fillWidth: true Layout.leftMargin: Appearance.padding.small @@ -196,7 +186,6 @@ VerticalFadeFlickable { } } - // The matching settings, joined into one card. ColumnLayout { Layout.fillWidth: true spacing: 0 @@ -220,9 +209,6 @@ VerticalFadeFlickable { const h = resultLayout.implicitHeight + resultLayout.anchors.margins * 2; return h % 2 === 0 ? h : h + 1; } - // Joined card: round only the outer corners so the - // rows read as one block (square where they meet), - // matching the page tabs' corner radius. topLeftRadius: isFirst ? Appearance.rounding.large : 0 topRightRadius: isFirst ? Appearance.rounding.large : 0 @@ -242,11 +228,9 @@ VerticalFadeFlickable { anchors.fill: parent anchors.margins: Appearance.padding.large - // Leave room on the right for the toggle switch. anchors.rightMargin: result.modelData.togglePath ? toggle.width + Appearance.padding.large * 2 : Appearance.padding.large spacing: Appearance.spacing.small / 2 - // Location line: deepest icon + "Section > sub", faint. CustomText { Layout.fillWidth: true color: DynamicColors.palette.m3onSurfaceVariant @@ -261,7 +245,6 @@ VerticalFadeFlickable { visible: text.length > 0 } - // The setting itself, most prominent. CustomText { Layout.fillWidth: true color: DynamicColors.palette.m3onSurface @@ -271,7 +254,6 @@ VerticalFadeFlickable { textFormat: Text.StyledText } - // Optional description, faintest and smallest. CustomText { Layout.fillWidth: true color: DynamicColors.palette.m3outline @@ -312,15 +294,7 @@ VerticalFadeFlickable { } } } - - // The list's implicitHeight tracks contentHeight; while items animate - // their position the reported height fluctuates, which left gaps in - // the surrounding layout on fast typing. So additions, removals and - // reordering are all instant - no transitions - keeping the height - // correct at every frame. model: ScriptModel { - // Match groups by their page so content updates in place rather - // than rebuilding the delegate when ranking shifts the order. objectProp: "pageIdx" values: root.groups } diff --git a/Modules/Settings/SettingsSearcher.qml b/Modules/Settings/SettingsSearcher.qml index cbb108d..eb61042 100644 --- a/Modules/Settings/SettingsSearcher.qml +++ b/Modules/Settings/SettingsSearcher.qml @@ -6,37 +6,13 @@ import Quickshell import ZShell import qs.Config -// Search service over the settings index. The index is generated at build time -// from the page QML files by scripts/build-settings-index.py and baked into the -// plugin binary (read via CUtils.settingsIndex), so it stays in sync with the UI -// without any hand-maintained entries and without a user-editable data file. -// -// Unlike the launcher's fuzzy searcher, this uses the real inverted index + -// ranking baked into the JSON: a query is tokenised, each token is looked up in -// the inverted index (exact token or prefix), the matching entry ids are scored -// with the precomputed per-token ranking, and the best entries are returned. -// SettingEntry QObjects are produced via Variants so the result objects expose -// the same properties the result list expects. Singleton { id: root - // fzf finder over the entries (title + keywords), used as a fuzzy fallback - // when the exact/prefix index lookup comes up short. fzf is the same matcher - // the launcher uses, so typo and mid-word matching behave consistently. property var fzfFinder: null - - // entries: forward index (one record per setting) - // inverted: token -> [entry id...] - // ranking: token -> { entry id (string): weight } property var inverted: ({}) property var ranking: ({}) - // Wrap the parts of `text` that match the search in the given colour, for use - // with a StyledText in Text.StyledText format. Matches each query token as a - // prefix at a word boundary (mirroring how lookup matches), so "wall" - // highlights the start of "wallpaper". StyledText supports but - // not CSS . HTML-significant characters are escaped first so the - // rich-text parser doesn't choke on names with & < or >. function highlight(text: string, search: string, colour: color): string { const escaped = text.replace(/&/g, "&").replace(//g, ">"); const tokens = tokenize(search); @@ -47,9 +23,6 @@ Singleton { return escaped.replace(pattern, `$1`); } - // Look up a query token in the inverted index: exact match first, then any - // indexed token that starts with it (prefix search, so "wif" finds "wifi"). - // Returns a map of entry id -> best ranking weight for that id. function lookup(token: string): var { const result = ({}); const exact = root.inverted[token] !== undefined; @@ -71,31 +44,21 @@ Singleton { if (tokens.length === 0) return []; - // Accumulate a score per entry id across all query tokens. An entry must - // match every query token (AND), and its score is the sum of the ranking - // weights of the index tokens it matched, so results stay relevant. const scores = ({}); const hitCounts = ({}); for (const token of tokens) { - const matches = root.lookup(token); // { id: weight } + const matches = root.lookup(token); for (const id in matches) { scores[id] = (scores[id] ?? 0) + matches[id]; hitCounts[id] = (hitCounts[id] ?? 0) + 1; } } - // Sort by score, breaking ties by id so the order is stable (otherwise - // entries with equal scores can be dropped arbitrarily by the limit). const ranked = Object.keys(scores).filter(id => hitCounts[id] === tokens.length).sort((a, b) => scores[b] - scores[a] || (parseInt(a) - parseInt(b))).slice(0, 25); const all = entries.instances; const out = ranked.map(id => all[parseInt(id)]).filter(e => e !== undefined); - // The inverted index only does exact/prefix matches. When it finds little - // or nothing - a typo ("trasparency") or a mid-word query ("paper") - fall - // back to fzf over the same entries. fzf hits that the index already - // returned are skipped, and the rest are appended after the (stronger) - // index results, so precise matches always lead. if (out.length < 5 && root.fzfFinder) { const seen = ({}); for (const id of ranked) @@ -127,9 +90,6 @@ Singleton { entries.model = data.entries; root.inverted = data.inverted ?? {}; root.ranking = data.ranking ?? {}; - // One searchable string per entry: the title. fzf provides typo and - // mid-word matching over titles as a fallback when the exact/prefix - // index lookup comes up short. const docs = data.entries.map((e, i) => ({ idx: i, text: e.title @@ -164,12 +124,7 @@ Singleton { readonly property var subPath: modelData.subPath readonly property string subtext: modelData.subtext ?? "" readonly property string title: modelData.title - - // A non-empty togglePath means this is a plain on/off setting that can be - // flipped straight from the results (e.g. "background.wallpaperEnabled"). readonly property string togglePath: modelData.togglePath ?? "" - // Live value of the config property, read by walking the path on - // GlobalConfig. Re-evaluates when that property changes. readonly property bool toggleValue: { if (!isToggle) return false; @@ -183,7 +138,6 @@ Singleton { return obj ?? false; } - // Write `value` back to the config property the path points at. function setToggle(value: bool): void { if (!isToggle) return; diff --git a/Modules/Settings/SettingsState.qml b/Modules/Settings/SettingsState.qml index e7f8f55..79e80d8 100644 --- a/Modules/Settings/SettingsState.qml +++ b/Modules/Settings/SettingsState.qml @@ -29,35 +29,24 @@ QtObject { subPageIdxStack.pop(); } - // Jump straight to a setting from search: open the page, then any sub-pages - // along subPath, then let the page scroll to the anchor. subPageIdxStack is - // filled directly so a freshly loaded StackPage opens the whole chain at - // once (see StackPage.Component.onCompleted), which avoids the half-open - // state that firing openSubPage signals one by one would cause. function jumpToSetting(pageIdx: int, subPath: var, anchor: string): void { const samePage = currentPageIdx === pageIdx; const sameSub = subPageIdxStack.length === subPath.length && subPath.every((v, i) => subPageIdxStack[i] === v); if (samePage && sameSub && anchor === lastAnchor) { - // Re-clicking the exact same setting: flash it again, don't scroll. highlightSetting(anchor); return; } lastAnchor = anchor; if (samePage && sameSub) { - // Same page, different setting: just scroll to it. searchAnchor = ""; searchAnchor = anchor; return; } - // Different page, or same page but different sub-page: point at the - // target sub-page chain and load the destination page, which scrolls to - // the anchor once it's ready. searchAnchor = anchor; if (!samePage) { pendingSubPath = subPath.slice(); currentPageIdx = pageIdx; } else { - // Same page: close back to the page root, then open the chain. while (subPageIdxStack.length > 0) closeSubPage(); for (let i = 0; i < subPath.length; i++) diff --git a/scripts/build-settings-index.py b/scripts/build-settings-index.py index d8a0419..a5315e1 100644 --- a/scripts/build-settings-index.py +++ b/scripts/build-settings-index.py @@ -1,22 +1,3 @@ -#!/usr/bin/env python3 -"""Build-time settings index extractor for the settings settings search. - -Parses the settings page QML files, PageRegistry.qml (page icons/labels) and -PageCompRegistry.qml (page ordering and sub-page nesting) to produce a search -index as JSON. Run at build time (see CMakeLists.txt); the shell loads the -result at runtime via SettingsSearcher.qml. - -The output contains three parts: - - entries: forward index, one record per setting (title, anchor, nav path) - - inverted: token -> list of entry indices (classic inverted index) - - ranking: token -> {entry index: weight} precomputed match weights - -Nothing here is hand-maintained per page: page metadata comes from -PageRegistry, the page tree from PageCompRegistry, and the directory layout is -discovered by walking the pages folder. - -Usage: build-settings-index.py -""" from __future__ import annotations import json @@ -29,7 +10,6 @@ from pathlib import Path @lru_cache(maxsize=None) def read_lines(path: Path) -> tuple[str, ...]: - """Read a file's lines, cached so each page file is only read once.""" return tuple(path.read_text().splitlines()) @@ -37,18 +17,11 @@ ROW_RE = re.compile( r'^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|InfoRow|PopupRow|OverlayRow|DefaultRow)\s*\{') LABEL_RE = re.compile(r'^\s*(?:label|text):\s*qsTr\("([^"]+)"\)') ANCHOR_RE = re.compile(r'^\s*settingAnchor:\s*"([^"]+)"') -# A ToggleRow whose value is a plain config property can be flipped straight from -# the search results. We capture the property path from `checked:` and require -# `onToggled:` to write the same path back (a symmetric binding), so reading and -# writing go through one path. Toggles bound to functions or multi-line handlers -# are left without a path and just deep-link as usual. CHECKED_RE = re.compile(r'^\s*checked:\s*(?:Config)\.([\w.]+)\s*$') ONTOGGLED_RE = re.compile( r'^\s*onToggled:\s*(?:Config)\.([\w.]+)\s*=\s*checked\s*$') ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"') SKIP_LABELS = {"Muted", "None"} -# Field weights for ranking: a token matching the title counts more than one -# matching the keywords blob. FIELD_WEIGHT = {"title": 1.0, "keywords": 0.4} STOPWORDS = {"the", "a", "an", "of", "and", "or", "to", "on", "in", "for"} @@ -58,7 +31,6 @@ def find_pages_dir(settings: Path) -> Path: def discover_files(settings: Path) -> dict[str, Path]: - """component name -> file path, discovered by walking pages/.""" files: dict[str, Path] = {} for p in find_pages_dir(settings).rglob("*.qml"): files[p.stem] = p @@ -159,12 +131,10 @@ def parse_block(lines: list[str], i: int) -> tuple[str, list[tuple[str, list]], def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]: name, children = block - # Outer array entry: Component { ... } if name != "Component": return [name] for child_name, child_children in children: - # StackPage { Component { FooPage { } } ... } if child_name == "StackPage": out: list[str] = [] for grand_name, grand_children in child_children: @@ -172,7 +142,6 @@ def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]: out.extend(collect_page_names((grand_name, grand_children))) return out - # Component { PlaceholderComp { } } if child_name != "Component": return [child_name] @@ -212,8 +181,6 @@ def parse_page_comps(settings: Path) -> list[list[str]]: def dedup_crumbs(labels: list[str], icons: list[str]) -> tuple[list[str], list[str]]: - """Drop consecutive duplicate labels (e.g. a section header that repeats the - page name), keeping icons aligned.""" out_labels: list[str] = [] out_icons: list[str] = [] for lbl, ico in zip(labels, icons): @@ -228,13 +195,10 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]: comps = parse_page_comps(settings) registry = parse_page_registry(settings) - # Top-level index -> (icon, label) from PageRegistry (same order as pageComps). top_meta: dict[int, tuple[str, str]] = {} for i, (icon, label) in enumerate(registry): top_meta[i] = (icon, label) - # parentName -> {childPos: (icon, label, section)} from openSubPage() + - # nearby NavRow, remembering the section header the NavRow sits under. nav_children: dict[str, dict[int, tuple[str, str, str]]] = {} for names in comps: for name in names: @@ -242,8 +206,8 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]: if not pf: continue pending_icon = pending_label = None - section = "" # text of the most recent SectionHeader - expect_section = False # next label line is that header's text + section = "" + expect_section = False for ln in read_lines(pf): if SECTION_RE.match(ln): expect_section = True @@ -275,24 +239,14 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]: nav[main] = {"pageIdx": top_idx, "subPath": [], "crumbIcons": [main_icon], "crumbLabels": [main_label]} children = dict(nav_children.get(main, {})) - # Components that some other page opens via openSubPage. Those are reached - # through that page (e.g. the bar pages are opened from inside Taskbar's - # "Components" section), so they must not be linked directly here, which - # would give them a wrong, shorter breadcrumb and navigation path. opened_via_subpage = set() for owner, kids in nav_children.items(): - # Find the group this owner component belongs to. owner_group = next((ns for ns in comps if owner in ns), None) if not owner_group: continue for kpos in kids: if kpos < len(owner_group): opened_via_subpage.add(owner_group[kpos]) - # Fallback: a StackPage may list sub-pages (pos > 0) whose openSubPage() - # call lives in a separate component file we don't scan (e.g. the - # Ethernet detail page is opened from EthernetSection.qml). Link any such - # sub-page by its position, deriving a label from its component name - - # but skip ones already reached through another page. for pos in range(1, len(names)): if pos not in children and names[pos] not in opened_via_subpage: label = re.sub(r"(Detail)?Page$", "", names[pos]) @@ -302,8 +256,6 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]: if pos >= len(names): continue child = names[pos] - # Insert the section header (e.g. "Components") as a breadcrumb step - # between the parent page and the sub-page, when present. labels = [main_label] + ([section] if section else []) + [label] icons = [main_icon] + ([icon] if section else []) + [icon] labels, icons = dedup_crumbs(labels, icons) @@ -325,8 +277,6 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]: def tokenize(text: str) -> list[str]: toks: list[str] = [] - # Process word by word (split on whitespace) so we only collapse separators - # inside a single word like "Wi-Fi" -> "wifi", not across a whole phrase. for word in text.lower().split(): parts = [p for p in re.split(r"[^a-z0-9]+", word) if p] for p in parts: @@ -350,10 +300,9 @@ def extract_settings(files: dict[str, Path], nav: dict[str, dict]) -> list[dict] if not pf: continue lines = read_lines(pf) - section = "" # text of the most recent SectionHeader + section = "" i = 0 while i < len(lines): - # Track the current section header so its words are searchable too. if SECTION_RE.match(lines[i]): for j in range(i + 1, min(i + 4, len(lines))): m = LABEL_RE.match(lines[j]) @@ -386,15 +335,12 @@ def extract_settings(files: dict[str, Path], nav: dict[str, dict]) -> list[dict] tg = ONTOGGLED_RE.match(lines[j]) if tg: toggled_path = tg.group(1) - # Only expose a toggle path when read and write target the same - # property (symmetric), so flipping from search is safe. toggle_path = ( checked_path if row_type == "ToggleRow" and checked_path and checked_path == toggled_path else "" ) if label and label not in SKIP_LABELS and anchor: - # keyword sources: breadcrumb path, section header, subtext. extra = " ".join(meta["crumbLabels"]) + \ " " + section + " " + (subtext or "") entries.append({ @@ -412,7 +358,6 @@ def extract_settings(files: dict[str, Path], nav: dict[str, dict]) -> list[dict] def build_inverted_and_ranking(entries: list[dict]): - """Classic inverted index + precomputed per-token ranking weights.""" inverted: dict[str, list[int]] = defaultdict(list) ranking: dict[str, dict[int, float]] = defaultdict(dict) for idx, e in enumerate(entries): @@ -423,10 +368,8 @@ def build_inverted_and_ranking(entries: list[dict]): for tok in tokenize(text): if idx not in inverted[tok]: inverted[tok].append(idx) - # accumulate the strongest field weight for this token/entry ranking[tok][idx] = max(ranking[tok].get(idx, 0.0), weight) seen.add(tok) - # sort each posting list by descending rank so runtime can stop early for tok, ids in inverted.items(): ids.sort(key=lambda i: ranking[tok][i], reverse=True) return inverted, {t: {str(k): v for k, v in d.items()} for t, d in ranking.items()} @@ -442,8 +385,6 @@ def main() -> int: nav = build_nav_map(settings, files) entries = extract_settings(files, nav) inverted, ranking = build_inverted_and_ranking(entries) - # keywords were only needed to build the inverted index; the runtime reads - # the index, not the per-entry keyword blob, so drop it to shrink the JSON. for e in entries: e.pop("keywords", None) out.write_text(json.dumps({