chore: cleanup comments
This commit is contained in:
@@ -9,8 +9,6 @@ import qs.Config
|
|||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
// Enables a smooth scroll animation only for search jumps, so normal
|
|
||||||
// flicking stays instant.
|
|
||||||
property bool animateScroll: false
|
property bool animateScroll: false
|
||||||
readonly property int cappedWidth: Math.min(800, width)
|
readonly property int cappedWidth: Math.min(800, width)
|
||||||
default property Item contentChild
|
default property Item contentChild
|
||||||
@@ -31,7 +29,7 @@ ColumnLayout {
|
|||||||
function findAnchor(item: Item, anchor: string): Item {
|
function findAnchor(item: Item, anchor: string): Item {
|
||||||
if (!item)
|
if (!item)
|
||||||
return null;
|
return null;
|
||||||
if (item.settingAnchor !== undefined && item.settingAnchor === anchor) // qmllint disable missing-property
|
if (item.settingAnchor !== undefined && item.settingAnchor === anchor)
|
||||||
return item;
|
return item;
|
||||||
const kids = item.children;
|
const kids = item.children;
|
||||||
for (let i = 0; i < kids.length; i++) {
|
for (let i = 0; i < kids.length; i++) {
|
||||||
@@ -42,14 +40,12 @@ ColumnLayout {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flash a row without scrolling (used when re-selecting the current setting).
|
|
||||||
function highlightAnchor(anchor: string): void {
|
function highlightAnchor(anchor: string): void {
|
||||||
const row = findAnchor(contentChild, anchor);
|
const row = findAnchor(contentChild, anchor);
|
||||||
if (row && row.flashHighlight !== undefined) // qmllint disable missing-property
|
if (row && row.flashHighlight !== undefined)
|
||||||
row.flashHighlight(); // qmllint disable missing-property
|
row.flashHighlight();
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the settings search jumps to this page, scroll to the matching row.
|
|
||||||
function scrollToAnchor(anchor: string): bool {
|
function scrollToAnchor(anchor: string): bool {
|
||||||
if (!anchor || !contentChild)
|
if (!anchor || !contentChild)
|
||||||
return false;
|
return false;
|
||||||
@@ -57,8 +53,6 @@ ColumnLayout {
|
|||||||
if (!row)
|
if (!row)
|
||||||
return false;
|
return false;
|
||||||
const pos = row.mapToItem(flickable.contentItem, 0, 0);
|
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 inset = flickable.height * flickable.fadeAmount + Appearance.padding.large;
|
||||||
const minY = -flickable.topMargin;
|
const minY = -flickable.topMargin;
|
||||||
const maxY = Math.max(minY, flickable.contentHeight + flickable.bottomMargin - flickable.height);
|
const maxY = Math.max(minY, flickable.contentHeight + flickable.bottomMargin - flickable.height);
|
||||||
@@ -66,8 +60,8 @@ ColumnLayout {
|
|||||||
root.animateScroll = true;
|
root.animateScroll = true;
|
||||||
flickable.contentY = target;
|
flickable.contentY = target;
|
||||||
Qt.callLater(() => root.animateScroll = false);
|
Qt.callLater(() => root.animateScroll = false);
|
||||||
if (row.flashHighlight !== undefined) // qmllint disable missing-property
|
if (row.flashHighlight !== undefined)
|
||||||
row.flashHighlight(); // qmllint disable missing-property
|
row.flashHighlight();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,10 +80,6 @@ ColumnLayout {
|
|||||||
repeat: true
|
repeat: true
|
||||||
|
|
||||||
onTriggered: {
|
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;
|
const h = flickable.contentHeight;
|
||||||
if (h === lastHeight && h > flickable.height)
|
if (h === lastHeight && h > flickable.height)
|
||||||
stableFrames++;
|
stableFrames++;
|
||||||
@@ -119,9 +109,8 @@ ColumnLayout {
|
|||||||
target: root.sState
|
target: root.sState
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea { // Prevent clicks from reaching flickable
|
MouseArea {
|
||||||
Layout.bottomMargin: -flickable.topMargin // Extra height to block clicks on flickable top margin
|
Layout.bottomMargin: -flickable.topMargin
|
||||||
|
|
||||||
implicitHeight: header.implicitHeight - Layout.bottomMargin
|
implicitHeight: header.implicitHeight - Layout.bottomMargin
|
||||||
implicitWidth: header.implicitWidth
|
implicitWidth: header.implicitWidth
|
||||||
z: 1
|
z: 1
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ import qs.Modules.Settings
|
|||||||
VerticalFadeFlickable {
|
VerticalFadeFlickable {
|
||||||
id: root
|
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: {
|
readonly property var groups: {
|
||||||
const out = [];
|
const out = [];
|
||||||
const byPage = ({});
|
const byPage = ({});
|
||||||
@@ -154,12 +151,6 @@ VerticalFadeFlickable {
|
|||||||
ListView {
|
ListView {
|
||||||
id: resultList
|
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
|
Layout.fillWidth: true
|
||||||
cacheBuffer: 10000
|
cacheBuffer: 10000
|
||||||
implicitHeight: contentHeight
|
implicitHeight: contentHeight
|
||||||
@@ -175,7 +166,6 @@ VerticalFadeFlickable {
|
|||||||
spacing: Appearance.spacing.small
|
spacing: Appearance.spacing.small
|
||||||
width: resultList.width
|
width: resultList.width
|
||||||
|
|
||||||
// Group heading: the top-level page name, shown once.
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.leftMargin: Appearance.padding.small
|
Layout.leftMargin: Appearance.padding.small
|
||||||
@@ -196,7 +186,6 @@ VerticalFadeFlickable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The matching settings, joined into one card.
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 0
|
spacing: 0
|
||||||
@@ -220,9 +209,6 @@ VerticalFadeFlickable {
|
|||||||
const h = resultLayout.implicitHeight + resultLayout.anchors.margins * 2;
|
const h = resultLayout.implicitHeight + resultLayout.anchors.margins * 2;
|
||||||
return h % 2 === 0 ? h : h + 1;
|
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
|
topLeftRadius: isFirst ? Appearance.rounding.large : 0
|
||||||
topRightRadius: isFirst ? Appearance.rounding.large : 0
|
topRightRadius: isFirst ? Appearance.rounding.large : 0
|
||||||
|
|
||||||
@@ -242,11 +228,9 @@ VerticalFadeFlickable {
|
|||||||
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: Appearance.padding.large
|
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
|
anchors.rightMargin: result.modelData.togglePath ? toggle.width + Appearance.padding.large * 2 : Appearance.padding.large
|
||||||
spacing: Appearance.spacing.small / 2
|
spacing: Appearance.spacing.small / 2
|
||||||
|
|
||||||
// Location line: deepest icon + "Section > sub", faint.
|
|
||||||
CustomText {
|
CustomText {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
color: DynamicColors.palette.m3onSurfaceVariant
|
color: DynamicColors.palette.m3onSurfaceVariant
|
||||||
@@ -261,7 +245,6 @@ VerticalFadeFlickable {
|
|||||||
visible: text.length > 0
|
visible: text.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// The setting itself, most prominent.
|
|
||||||
CustomText {
|
CustomText {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
color: DynamicColors.palette.m3onSurface
|
color: DynamicColors.palette.m3onSurface
|
||||||
@@ -271,7 +254,6 @@ VerticalFadeFlickable {
|
|||||||
textFormat: Text.StyledText
|
textFormat: Text.StyledText
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional description, faintest and smallest.
|
|
||||||
CustomText {
|
CustomText {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
color: DynamicColors.palette.m3outline
|
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 {
|
model: ScriptModel {
|
||||||
// Match groups by their page so content updates in place rather
|
|
||||||
// than rebuilding the delegate when ranking shifts the order.
|
|
||||||
objectProp: "pageIdx"
|
objectProp: "pageIdx"
|
||||||
values: root.groups
|
values: root.groups
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,37 +6,13 @@ import Quickshell
|
|||||||
import ZShell
|
import ZShell
|
||||||
import qs.Config
|
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 {
|
Singleton {
|
||||||
id: root
|
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
|
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 inverted: ({})
|
||||||
property var ranking: ({})
|
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 <font color> but
|
|
||||||
// not CSS <span style>. 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 {
|
function highlight(text: string, search: string, colour: color): string {
|
||||||
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
const tokens = tokenize(search);
|
const tokens = tokenize(search);
|
||||||
@@ -47,9 +23,6 @@ Singleton {
|
|||||||
return escaped.replace(pattern, `<font color="${colour}">$1</font>`);
|
return escaped.replace(pattern, `<font color="${colour}">$1</font>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
function lookup(token: string): var {
|
||||||
const result = ({});
|
const result = ({});
|
||||||
const exact = root.inverted[token] !== undefined;
|
const exact = root.inverted[token] !== undefined;
|
||||||
@@ -71,31 +44,21 @@ Singleton {
|
|||||||
if (tokens.length === 0)
|
if (tokens.length === 0)
|
||||||
return [];
|
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 scores = ({});
|
||||||
const hitCounts = ({});
|
const hitCounts = ({});
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
const matches = root.lookup(token); // { id: weight }
|
const matches = root.lookup(token);
|
||||||
for (const id in matches) {
|
for (const id in matches) {
|
||||||
scores[id] = (scores[id] ?? 0) + matches[id];
|
scores[id] = (scores[id] ?? 0) + matches[id];
|
||||||
hitCounts[id] = (hitCounts[id] ?? 0) + 1;
|
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 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 all = entries.instances;
|
||||||
const out = ranked.map(id => all[parseInt(id)]).filter(e => e !== undefined);
|
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) {
|
if (out.length < 5 && root.fzfFinder) {
|
||||||
const seen = ({});
|
const seen = ({});
|
||||||
for (const id of ranked)
|
for (const id of ranked)
|
||||||
@@ -127,9 +90,6 @@ Singleton {
|
|||||||
entries.model = data.entries;
|
entries.model = data.entries;
|
||||||
root.inverted = data.inverted ?? {};
|
root.inverted = data.inverted ?? {};
|
||||||
root.ranking = data.ranking ?? {};
|
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) => ({
|
const docs = data.entries.map((e, i) => ({
|
||||||
idx: i,
|
idx: i,
|
||||||
text: e.title
|
text: e.title
|
||||||
@@ -164,12 +124,7 @@ Singleton {
|
|||||||
readonly property var subPath: modelData.subPath
|
readonly property var subPath: modelData.subPath
|
||||||
readonly property string subtext: modelData.subtext ?? ""
|
readonly property string subtext: modelData.subtext ?? ""
|
||||||
readonly property string title: modelData.title
|
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 ?? ""
|
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: {
|
readonly property bool toggleValue: {
|
||||||
if (!isToggle)
|
if (!isToggle)
|
||||||
return false;
|
return false;
|
||||||
@@ -183,7 +138,6 @@ Singleton {
|
|||||||
return obj ?? false;
|
return obj ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write `value` back to the config property the path points at.
|
|
||||||
function setToggle(value: bool): void {
|
function setToggle(value: bool): void {
|
||||||
if (!isToggle)
|
if (!isToggle)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -29,35 +29,24 @@ QtObject {
|
|||||||
subPageIdxStack.pop();
|
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 {
|
function jumpToSetting(pageIdx: int, subPath: var, anchor: string): void {
|
||||||
const samePage = currentPageIdx === pageIdx;
|
const samePage = currentPageIdx === pageIdx;
|
||||||
const sameSub = subPageIdxStack.length === subPath.length && subPath.every((v, i) => subPageIdxStack[i] === v);
|
const sameSub = subPageIdxStack.length === subPath.length && subPath.every((v, i) => subPageIdxStack[i] === v);
|
||||||
if (samePage && sameSub && anchor === lastAnchor) {
|
if (samePage && sameSub && anchor === lastAnchor) {
|
||||||
// Re-clicking the exact same setting: flash it again, don't scroll.
|
|
||||||
highlightSetting(anchor);
|
highlightSetting(anchor);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastAnchor = anchor;
|
lastAnchor = anchor;
|
||||||
if (samePage && sameSub) {
|
if (samePage && sameSub) {
|
||||||
// Same page, different setting: just scroll to it.
|
|
||||||
searchAnchor = "";
|
searchAnchor = "";
|
||||||
searchAnchor = anchor;
|
searchAnchor = anchor;
|
||||||
return;
|
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;
|
searchAnchor = anchor;
|
||||||
if (!samePage) {
|
if (!samePage) {
|
||||||
pendingSubPath = subPath.slice();
|
pendingSubPath = subPath.slice();
|
||||||
currentPageIdx = pageIdx;
|
currentPageIdx = pageIdx;
|
||||||
} else {
|
} else {
|
||||||
// Same page: close back to the page root, then open the chain.
|
|
||||||
while (subPageIdxStack.length > 0)
|
while (subPageIdxStack.length > 0)
|
||||||
closeSubPage();
|
closeSubPage();
|
||||||
for (let i = 0; i < subPath.length; i++)
|
for (let i = 0; i < subPath.length; i++)
|
||||||
|
|||||||
@@ -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 <settings-dir> <output-json>
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -29,7 +10,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
@lru_cache(maxsize=None)
|
@lru_cache(maxsize=None)
|
||||||
def read_lines(path: Path) -> tuple[str, ...]:
|
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())
|
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*\{')
|
r'^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|InfoRow|PopupRow|OverlayRow|DefaultRow)\s*\{')
|
||||||
LABEL_RE = re.compile(r'^\s*(?:label|text):\s*qsTr\("([^"]+)"\)')
|
LABEL_RE = re.compile(r'^\s*(?:label|text):\s*qsTr\("([^"]+)"\)')
|
||||||
ANCHOR_RE = re.compile(r'^\s*settingAnchor:\s*"([^"]+)"')
|
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*$')
|
CHECKED_RE = re.compile(r'^\s*checked:\s*(?:Config)\.([\w.]+)\s*$')
|
||||||
ONTOGGLED_RE = re.compile(
|
ONTOGGLED_RE = re.compile(
|
||||||
r'^\s*onToggled:\s*(?:Config)\.([\w.]+)\s*=\s*checked\s*$')
|
r'^\s*onToggled:\s*(?:Config)\.([\w.]+)\s*=\s*checked\s*$')
|
||||||
ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"')
|
ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"')
|
||||||
SKIP_LABELS = {"Muted", "None"}
|
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}
|
FIELD_WEIGHT = {"title": 1.0, "keywords": 0.4}
|
||||||
STOPWORDS = {"the", "a", "an", "of", "and", "or", "to", "on", "in", "for"}
|
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]:
|
def discover_files(settings: Path) -> dict[str, Path]:
|
||||||
"""component name -> file path, discovered by walking pages/."""
|
|
||||||
files: dict[str, Path] = {}
|
files: dict[str, Path] = {}
|
||||||
for p in find_pages_dir(settings).rglob("*.qml"):
|
for p in find_pages_dir(settings).rglob("*.qml"):
|
||||||
files[p.stem] = p
|
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]:
|
def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]:
|
||||||
name, children = block
|
name, children = block
|
||||||
|
|
||||||
# Outer array entry: Component { ... }
|
|
||||||
if name != "Component":
|
if name != "Component":
|
||||||
return [name]
|
return [name]
|
||||||
|
|
||||||
for child_name, child_children in children:
|
for child_name, child_children in children:
|
||||||
# StackPage { Component { FooPage { } } ... }
|
|
||||||
if child_name == "StackPage":
|
if child_name == "StackPage":
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
for grand_name, grand_children in child_children:
|
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)))
|
out.extend(collect_page_names((grand_name, grand_children)))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# Component { PlaceholderComp { } }
|
|
||||||
if child_name != "Component":
|
if child_name != "Component":
|
||||||
return [child_name]
|
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]]:
|
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_labels: list[str] = []
|
||||||
out_icons: list[str] = []
|
out_icons: list[str] = []
|
||||||
for lbl, ico in zip(labels, icons):
|
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)
|
comps = parse_page_comps(settings)
|
||||||
registry = parse_page_registry(settings)
|
registry = parse_page_registry(settings)
|
||||||
|
|
||||||
# Top-level index -> (icon, label) from PageRegistry (same order as pageComps).
|
|
||||||
top_meta: dict[int, tuple[str, str]] = {}
|
top_meta: dict[int, tuple[str, str]] = {}
|
||||||
for i, (icon, label) in enumerate(registry):
|
for i, (icon, label) in enumerate(registry):
|
||||||
top_meta[i] = (icon, label)
|
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]]] = {}
|
nav_children: dict[str, dict[int, tuple[str, str, str]]] = {}
|
||||||
for names in comps:
|
for names in comps:
|
||||||
for name in names:
|
for name in names:
|
||||||
@@ -242,8 +206,8 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]:
|
|||||||
if not pf:
|
if not pf:
|
||||||
continue
|
continue
|
||||||
pending_icon = pending_label = None
|
pending_icon = pending_label = None
|
||||||
section = "" # text of the most recent SectionHeader
|
section = ""
|
||||||
expect_section = False # next label line is that header's text
|
expect_section = False
|
||||||
for ln in read_lines(pf):
|
for ln in read_lines(pf):
|
||||||
if SECTION_RE.match(ln):
|
if SECTION_RE.match(ln):
|
||||||
expect_section = True
|
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": [],
|
nav[main] = {"pageIdx": top_idx, "subPath": [],
|
||||||
"crumbIcons": [main_icon], "crumbLabels": [main_label]}
|
"crumbIcons": [main_icon], "crumbLabels": [main_label]}
|
||||||
children = dict(nav_children.get(main, {}))
|
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()
|
opened_via_subpage = set()
|
||||||
for owner, kids in nav_children.items():
|
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)
|
owner_group = next((ns for ns in comps if owner in ns), None)
|
||||||
if not owner_group:
|
if not owner_group:
|
||||||
continue
|
continue
|
||||||
for kpos in kids:
|
for kpos in kids:
|
||||||
if kpos < len(owner_group):
|
if kpos < len(owner_group):
|
||||||
opened_via_subpage.add(owner_group[kpos])
|
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)):
|
for pos in range(1, len(names)):
|
||||||
if pos not in children and names[pos] not in opened_via_subpage:
|
if pos not in children and names[pos] not in opened_via_subpage:
|
||||||
label = re.sub(r"(Detail)?Page$", "", names[pos])
|
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):
|
if pos >= len(names):
|
||||||
continue
|
continue
|
||||||
child = names[pos]
|
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]
|
labels = [main_label] + ([section] if section else []) + [label]
|
||||||
icons = [main_icon] + ([icon] if section else []) + [icon]
|
icons = [main_icon] + ([icon] if section else []) + [icon]
|
||||||
labels, icons = dedup_crumbs(labels, icons)
|
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]:
|
def tokenize(text: str) -> list[str]:
|
||||||
toks: 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():
|
for word in text.lower().split():
|
||||||
parts = [p for p in re.split(r"[^a-z0-9]+", word) if p]
|
parts = [p for p in re.split(r"[^a-z0-9]+", word) if p]
|
||||||
for p in parts:
|
for p in parts:
|
||||||
@@ -350,10 +300,9 @@ def extract_settings(files: dict[str, Path], nav: dict[str, dict]) -> list[dict]
|
|||||||
if not pf:
|
if not pf:
|
||||||
continue
|
continue
|
||||||
lines = read_lines(pf)
|
lines = read_lines(pf)
|
||||||
section = "" # text of the most recent SectionHeader
|
section = ""
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(lines):
|
while i < len(lines):
|
||||||
# Track the current section header so its words are searchable too.
|
|
||||||
if SECTION_RE.match(lines[i]):
|
if SECTION_RE.match(lines[i]):
|
||||||
for j in range(i + 1, min(i + 4, len(lines))):
|
for j in range(i + 1, min(i + 4, len(lines))):
|
||||||
m = LABEL_RE.match(lines[j])
|
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])
|
tg = ONTOGGLED_RE.match(lines[j])
|
||||||
if tg:
|
if tg:
|
||||||
toggled_path = tg.group(1)
|
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 = (
|
toggle_path = (
|
||||||
checked_path
|
checked_path
|
||||||
if row_type == "ToggleRow" and checked_path and checked_path == toggled_path
|
if row_type == "ToggleRow" and checked_path and checked_path == toggled_path
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
if label and label not in SKIP_LABELS and anchor:
|
if label and label not in SKIP_LABELS and anchor:
|
||||||
# keyword sources: breadcrumb path, section header, subtext.
|
|
||||||
extra = " ".join(meta["crumbLabels"]) + \
|
extra = " ".join(meta["crumbLabels"]) + \
|
||||||
" " + section + " " + (subtext or "")
|
" " + section + " " + (subtext or "")
|
||||||
entries.append({
|
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]):
|
def build_inverted_and_ranking(entries: list[dict]):
|
||||||
"""Classic inverted index + precomputed per-token ranking weights."""
|
|
||||||
inverted: dict[str, list[int]] = defaultdict(list)
|
inverted: dict[str, list[int]] = defaultdict(list)
|
||||||
ranking: dict[str, dict[int, float]] = defaultdict(dict)
|
ranking: dict[str, dict[int, float]] = defaultdict(dict)
|
||||||
for idx, e in enumerate(entries):
|
for idx, e in enumerate(entries):
|
||||||
@@ -423,10 +368,8 @@ def build_inverted_and_ranking(entries: list[dict]):
|
|||||||
for tok in tokenize(text):
|
for tok in tokenize(text):
|
||||||
if idx not in inverted[tok]:
|
if idx not in inverted[tok]:
|
||||||
inverted[tok].append(idx)
|
inverted[tok].append(idx)
|
||||||
# accumulate the strongest field weight for this token/entry
|
|
||||||
ranking[tok][idx] = max(ranking[tok].get(idx, 0.0), weight)
|
ranking[tok][idx] = max(ranking[tok].get(idx, 0.0), weight)
|
||||||
seen.add(tok)
|
seen.add(tok)
|
||||||
# sort each posting list by descending rank so runtime can stop early
|
|
||||||
for tok, ids in inverted.items():
|
for tok, ids in inverted.items():
|
||||||
ids.sort(key=lambda i: ranking[tok][i], reverse=True)
|
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()}
|
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)
|
nav = build_nav_map(settings, files)
|
||||||
entries = extract_settings(files, nav)
|
entries = extract_settings(files, nav)
|
||||||
inverted, ranking = build_inverted_and_ranking(entries)
|
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:
|
for e in entries:
|
||||||
e.pop("keywords", None)
|
e.pop("keywords", None)
|
||||||
out.write_text(json.dumps({
|
out.write_text(json.dumps({
|
||||||
|
|||||||
Reference in New Issue
Block a user