mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
352 lines
10 KiB
Python
Executable File
352 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Build settings search index from QML source files.
|
|
|
|
Parses settings tab QML files to extract searchable metadata
|
|
(i18n keys, widget types, tab/sub-tab locations).
|
|
|
|
Output: Assets/settings-search-index.json
|
|
|
|
Usage:
|
|
python Scripts/dev/build-settings-search-index.py
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
|
SETTINGS_DIR = ROOT / "Modules" / "Panels" / "Settings"
|
|
TABS_DIR = SETTINGS_DIR / "Tabs"
|
|
OUTPUT = ROOT / "Assets" / "settings-search-index.json"
|
|
|
|
# Widget types that have searchable label/description
|
|
WIDGET_TYPES = (
|
|
"NToggle",
|
|
"NComboBox",
|
|
"NValueSlider",
|
|
"NSpinBox",
|
|
"NSearchableComboBox",
|
|
"NTextInputButton",
|
|
"NTextInput",
|
|
"NCheckbox",
|
|
"NLabel",
|
|
"HookRow",
|
|
)
|
|
|
|
# Regex patterns
|
|
RE_WIDGET_OPEN = re.compile(
|
|
r"^\s*(" + "|".join(WIDGET_TYPES) + r")\s*\{", re.MULTILINE
|
|
)
|
|
RE_LABEL = re.compile(r'label:\s*I18n\.tr\("([^"]+)"')
|
|
RE_DESCRIPTION = re.compile(r'description:\s*I18n\.tr\("([^"]+)"')
|
|
|
|
|
|
def parse_component_declarations(content: str) -> dict[str, str]:
|
|
"""
|
|
Parse Component declarations from SettingsContent.qml.
|
|
|
|
Returns: component_id -> QML type name (e.g. "generalTab" -> "GeneralTab")
|
|
"""
|
|
components = {}
|
|
# Match patterns like:
|
|
# Component {
|
|
# id: generalTab
|
|
# GeneralTab {}
|
|
# }
|
|
pattern = re.compile(
|
|
r"Component\s*\{\s*\n\s*id:\s*(\w+)\s*\n\s*(\w+)\s*\{",
|
|
re.MULTILINE,
|
|
)
|
|
for m in pattern.finditer(content):
|
|
comp_id = m.group(1)
|
|
type_name = m.group(2)
|
|
components[comp_id] = type_name
|
|
|
|
return components
|
|
|
|
|
|
def parse_tabs_model_order(content: str) -> list[tuple[str, str]]:
|
|
"""
|
|
Parse updateTabsModel() to get the ordered list of (source_id, label_key) pairs.
|
|
|
|
Returns: list of (component_id, i18n_label_key) in display order.
|
|
"""
|
|
# Find the updateTabsModel function body
|
|
match = re.search(r"function updateTabsModel\(\)\s*\{", content)
|
|
if not match:
|
|
return []
|
|
|
|
func_body = content[match.end():]
|
|
|
|
# Extract tab entries: each has "label" and "source" fields
|
|
entries = []
|
|
for m in re.finditer(
|
|
r'"label":\s*"([^"]+)"[^}]*?"source":\s*(\w+)',
|
|
func_body,
|
|
re.DOTALL,
|
|
):
|
|
entries.append((m.group(2), m.group(1)))
|
|
|
|
return entries
|
|
|
|
|
|
def build_tab_mappings(content: str) -> tuple[dict[str, int], dict[str, str]]:
|
|
"""
|
|
Build mappings from QML type name to tabsModel index and label key.
|
|
|
|
Parses Component declarations and updateTabsModel() order.
|
|
Returns: (type_to_index, type_to_label)
|
|
- type_to_index: e.g. {"GeneralTab": 0, ...}
|
|
- type_to_label: e.g. {"GeneralTab": "common.general", ...}
|
|
"""
|
|
components = parse_component_declarations(content)
|
|
entries = parse_tabs_model_order(content)
|
|
|
|
type_to_index = {}
|
|
type_to_label = {}
|
|
for idx, (source_id, label_key) in enumerate(entries):
|
|
type_name = components.get(source_id)
|
|
if type_name:
|
|
type_to_index[type_name] = idx
|
|
type_to_label[type_name] = label_key
|
|
|
|
return type_to_index, type_to_label
|
|
|
|
|
|
def get_subtab_info(parent_tab_file: Path) -> tuple[list[str], list[str | None]]:
|
|
"""
|
|
Parse a parent tab file to get subtab order and labels.
|
|
|
|
Returns: (subtab_type_names, subtab_label_keys)
|
|
- subtab_type_names: list of component names like ["VolumesSubTab", ...]
|
|
- subtab_label_keys: list of i18n keys like ["common.volumes", ...] (same order)
|
|
"""
|
|
content = parent_tab_file.read_text()
|
|
|
|
# Extract NTabButton labels in order from NTabBar
|
|
labels = []
|
|
in_tabbar = False
|
|
tabbar_depth = 0
|
|
|
|
for line in content.splitlines():
|
|
stripped = line.strip()
|
|
|
|
if not in_tabbar:
|
|
if re.match(r"NTabBar\s*\{", stripped):
|
|
in_tabbar = True
|
|
tabbar_depth = 1
|
|
continue
|
|
continue
|
|
|
|
tabbar_depth += stripped.count("{") - stripped.count("}")
|
|
|
|
if tabbar_depth <= 0:
|
|
break
|
|
|
|
# Match text: I18n.tr("...") inside NTabButton
|
|
m = re.search(r'text:\s*I18n\.tr\("([^"]+)"', stripped)
|
|
if m:
|
|
labels.append(m.group(1))
|
|
|
|
# Extract subtab component names from NTabView
|
|
subtabs = []
|
|
in_tabview = False
|
|
tabview_depth = 0
|
|
|
|
for line in content.splitlines():
|
|
stripped = line.strip()
|
|
|
|
if not in_tabview:
|
|
if re.match(r"NTabView\s*\{", stripped):
|
|
in_tabview = True
|
|
tabview_depth = 1
|
|
continue
|
|
continue
|
|
|
|
tabview_depth += stripped.count("{") - stripped.count("}")
|
|
|
|
if tabview_depth <= 0:
|
|
break
|
|
|
|
# Match component instantiations like "VolumesSubTab {}" or "VolumesSubTab {"
|
|
m = re.match(r"(\w+SubTab)\s*\{", stripped)
|
|
if m:
|
|
subtabs.append(m.group(1))
|
|
|
|
# Pad labels list if shorter than subtabs (shouldn't happen, but safety)
|
|
while len(labels) < len(subtabs):
|
|
labels.append(None)
|
|
|
|
return subtabs, labels[:len(subtabs)]
|
|
|
|
|
|
def resolve_tab_info(
|
|
qml_file: Path,
|
|
type_to_index: dict[str, int],
|
|
type_to_label: dict[str, str],
|
|
) -> tuple[int | None, str | None, int | None, str | None]:
|
|
"""
|
|
Determine the tab index, tab label, sub-tab index, and sub-tab label for a QML file.
|
|
|
|
Returns (tab_index, tab_label_key, sub_tab_index, sub_tab_label_key)
|
|
"""
|
|
parent = qml_file.parent
|
|
stem = qml_file.stem
|
|
|
|
# Top-level tab files (directly in Tabs/)
|
|
if parent == TABS_DIR:
|
|
tab_index = type_to_index.get(stem)
|
|
tab_label = type_to_label.get(stem)
|
|
return tab_index, tab_label, None, None
|
|
|
|
# Sub-directory files
|
|
dir_name = parent.name # e.g. "Audio", "Bar"
|
|
parent_type = f"{dir_name}Tab" # e.g. "AudioTab"
|
|
tab_index = type_to_index.get(parent_type)
|
|
tab_label = type_to_label.get(parent_type)
|
|
|
|
if tab_index is None:
|
|
return None, None, None, None
|
|
|
|
# Skip the parent tab file itself (e.g. AudioTab.qml) — still scan for widgets
|
|
if stem.endswith("Tab") and not stem.endswith("SubTab"):
|
|
return tab_index, tab_label, None, None
|
|
|
|
# Determine sub-tab index and label from parent tab's NTabBar/NTabView
|
|
parent_tab_file = parent / f"{dir_name}Tab.qml"
|
|
if not parent_tab_file.exists():
|
|
return tab_index, tab_label, None, None
|
|
|
|
subtab_names, subtab_labels = get_subtab_info(parent_tab_file)
|
|
try:
|
|
idx = subtab_names.index(stem)
|
|
sub_label = subtab_labels[idx] if idx < len(subtab_labels) else None
|
|
return tab_index, tab_label, idx, sub_label
|
|
except ValueError:
|
|
# File doesn't map to any subtab (e.g. a dialog). If the parent tab
|
|
# has subtabs, the focus ring can't reach widgets inside dialogs, so
|
|
# exclude them from the index.
|
|
if subtab_names:
|
|
return None, None, None, None
|
|
return tab_index, tab_label, None, None
|
|
|
|
|
|
def extract_widget_blocks(content: str) -> list[tuple[str, str]]:
|
|
"""
|
|
Extract (widget_type, block_text) pairs from QML content.
|
|
|
|
Uses brace-depth tracking to capture the full widget block.
|
|
"""
|
|
results = []
|
|
lines = content.splitlines()
|
|
i = 0
|
|
|
|
while i < len(lines):
|
|
m = RE_WIDGET_OPEN.match(lines[i])
|
|
if m:
|
|
widget_type = m.group(1)
|
|
depth = 0
|
|
block_lines = []
|
|
j = i
|
|
|
|
while j < len(lines):
|
|
line = lines[j]
|
|
block_lines.append(line)
|
|
depth += line.count("{") - line.count("}")
|
|
if depth <= 0:
|
|
break
|
|
j += 1
|
|
|
|
block_text = "\n".join(block_lines)
|
|
results.append((widget_type, block_text))
|
|
i = j + 1
|
|
else:
|
|
i += 1
|
|
|
|
return results
|
|
|
|
|
|
def extract_entries(
|
|
qml_file: Path,
|
|
type_to_index: dict[str, int],
|
|
type_to_label: dict[str, str],
|
|
) -> list[dict]:
|
|
"""Extract all searchable settings entries from a QML file."""
|
|
tab_index, tab_label, sub_tab, sub_tab_label = resolve_tab_info(
|
|
qml_file, type_to_index, type_to_label
|
|
)
|
|
if tab_index is None:
|
|
return []
|
|
|
|
content = qml_file.read_text()
|
|
entries = []
|
|
|
|
for widget_type, block in extract_widget_blocks(content):
|
|
label_match = RE_LABEL.search(block)
|
|
if not label_match:
|
|
continue
|
|
|
|
label_key = label_match.group(1)
|
|
desc_match = RE_DESCRIPTION.search(block)
|
|
desc_key = desc_match.group(1) if desc_match else None
|
|
|
|
entry = {
|
|
"labelKey": label_key,
|
|
"descriptionKey": desc_key,
|
|
"widget": widget_type,
|
|
"tab": tab_index,
|
|
"tabLabel": tab_label,
|
|
"subTab": sub_tab,
|
|
}
|
|
if sub_tab_label is not None:
|
|
entry["subTabLabel"] = sub_tab_label
|
|
|
|
entries.append(entry)
|
|
|
|
return entries
|
|
|
|
|
|
def main():
|
|
if not TABS_DIR.exists():
|
|
print(f"Error: Tabs directory not found: {TABS_DIR}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
settings_content = SETTINGS_DIR / "SettingsContent.qml"
|
|
if not settings_content.exists():
|
|
print(f"Error: SettingsContent.qml not found: {settings_content}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
# Build type -> tabsModel index/label mappings from SettingsContent.qml
|
|
content = settings_content.read_text()
|
|
type_to_index, type_to_label = build_tab_mappings(content)
|
|
|
|
if not type_to_index:
|
|
print("Error: Could not parse tab model from SettingsContent.qml", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(f"Parsed {len(type_to_index)} tab types from SettingsContent.qml")
|
|
|
|
all_entries = []
|
|
seen_labels = set()
|
|
|
|
# Scan all QML files in Tabs/ (recursive)
|
|
for qml_file in sorted(TABS_DIR.rglob("*.qml")):
|
|
entries = extract_entries(qml_file, type_to_index, type_to_label)
|
|
for entry in entries:
|
|
if entry["labelKey"] not in seen_labels:
|
|
seen_labels.add(entry["labelKey"])
|
|
all_entries.append(entry)
|
|
|
|
# Write output
|
|
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(OUTPUT, "w") as f:
|
|
json.dump(all_entries, f, indent=2)
|
|
|
|
print(f"Generated {len(all_entries)} entries -> {OUTPUT.relative_to(ROOT)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|