diff --git a/CMakeLists.txt b/CMakeLists.txt
index 95c3714..6d58d8e 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -26,6 +26,18 @@ if(NOT DEFINED VERSION)
endif()
endif()
+if(NOT DEFINED GIT_REVISION)
+ execute_process(COMMAND git rev-parse HEAD
+ WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
+ OUTPUT_VARIABLE GIT_REVISION
+ OUTPUT_STRIP_TRAILING_WHITESPACE
+ )
+
+ if("${GIT_REVISION}" STREQUAL "")
+ message(FATAL_ERROR "GIT_REVISION is not set and failed to get from git")
+ endif()
+endif()
+
set(VERSION "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}")
project(ZShell VERSION ${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH})
@@ -36,7 +48,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
-set(ENABLE_MODULES "plugin;shell;m3shapes" CACHE STRING "Modules to build/install")
+set(ENABLE_MODULES "plugin;shell;cli;m3shapes" CACHE STRING "Modules to build/install")
set(INSTALL_LIBDIR "usr/lib/ZShell" CACHE STRING "Library install dir")
set(INSTALL_QMLDIR "usr/lib/qt6/qml" CACHE STRING "QML install dir")
@@ -54,21 +66,7 @@ add_compile_options(
)
-if("shell" IN_LIST ENABLE_MODULES)
- # Build settings index
- find_package(Python3 COMPONENTS Interpreter REQUIRED)
- set(SETTINGS_INDEX_JSON "${CMAKE_BINARY_DIR}/settings-index.json")
- execute_process(
- COMMAND ${Python3_EXECUTABLE}
- "${CMAKE_SOURCE_DIR}/scripts/build-settings-index.py"
- "${CMAKE_SOURCE_DIR}/Modules/Settings"
- "${SETTINGS_INDEX_JSON}"
- RESULT_VARIABLE SETTINGS_INDEX_RESULT
- )
- if(NOT SETTINGS_INDEX_RESULT EQUAL 0)
- message(FATAL_ERROR "Failed to build settings search index")
- endif()
-
+if("cli" IN_LIST ENABLE_MODULES)
# Nuitka compilation
set(ZSHELL_CLI_BUILD_DIR "${CMAKE_BINARY_DIR}/zshell-cli")
set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist")
diff --git a/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml b/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml
index e335cf5..e4cd5f8 100644
--- a/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml
+++ b/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml
@@ -9,7 +9,6 @@ TextEditBase {
color: Colors.palette.m3onSurface
readOnly: true
- anchors.margins: Tokens.padding.medium
textFormat: Text.MarkdownText
font.pointSize: Tokens.font.size.smaller
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml
index 4b76e12..7261555 100644
--- a/Modules/Notifications/Sidebar/Content.qml
+++ b/Modules/Notifications/Sidebar/Content.qml
@@ -1,7 +1,6 @@
pragma ComponentBehavior: Bound
import ZShell.Config
-import ZShell.Llm
import QtQuick
import QtQuick.Layouts
import qs.Components
@@ -31,7 +30,7 @@ Item {
Tabs {
Layout.fillWidth: true
- Layout.preferredHeight: ChatState.isWindow ? 0 : implicitHeight
+ Layout.preferredHeight: ChatState.isWindow || !Config.llm.enabled ? 0 : implicitHeight
dashState: root.props
nonAnimWidth: layout.width
visible: height > 0
@@ -53,12 +52,10 @@ Item {
x: root.props.currentTab === 0 ? 0 : -root.width
Behavior on opacity {
- Anim {
- }
+ Anim {}
}
Behavior on x {
- Anim {
- }
+ Anim {}
}
CustomRect {
@@ -73,33 +70,38 @@ Item {
}
}
- Item {
- id: chatPage
+ Loader {
+ id: chatLoader
anchors.bottom: parent.bottom
anchors.top: parent.top
- implicitWidth: parent.width
- opacity: root.props.currentTab === 1 ? 1 : 0
- visible: opacity > 0
- x: root.props.currentTab === 0 ? root.width : 0
- z: 1
+ active: Config.llm.enabled
+ width: parent.width
- Behavior on opacity {
- Anim {
+ sourceComponent: Item {
+ id: chatPage
+
+ opacity: root.props.currentTab === 1 ? 1 : 0
+ visible: opacity > 0
+ x: root.props.currentTab === 0 ? root.width : 0
+ z: 1
+
+ Behavior on opacity {
+ Anim {}
}
- }
- Behavior on x {
- Anim {
+
+ Behavior on x {
+ Anim {}
}
- }
- CustomRect {
- anchors.fill: parent
- color: Colors.tPalette.m3surfaceContainerLow
- radius: Tokens.rounding.small
-
- ChatPanel {
+ CustomRect {
anchors.fill: parent
+ color: Colors.tPalette.m3surfaceContainerLow
+ radius: Tokens.rounding.small
+
+ ChatPanel {
+ anchors.fill: parent
+ }
}
}
}
diff --git a/Modules/Settings/Common/PageBase.qml b/Modules/Settings/Common/PageBase.qml
index 5b4200c..6fec5f8 100644
--- a/Modules/Settings/Common/PageBase.qml
+++ b/Modules/Settings/Common/PageBase.qml
@@ -30,7 +30,7 @@ ColumnLayout {
function findAnchor(item: Item, anchor: string): Item {
if (!item)
return null;
- if (item.settingAnchor !== undefined && item.settingAnchor === anchor)
+ if (item.settingAnchor !== undefined && item.settingAnchor === anchor) // qmllint disable missing-property
return item;
const kids = item.children;
for (let i = 0; i < kids.length; i++) {
@@ -43,8 +43,8 @@ ColumnLayout {
function highlightAnchor(anchor: string): void {
const row = findAnchor(contentChild, anchor);
- if (row && row.flashHighlight !== undefined)
- row.flashHighlight();
+ if (row && row.flashHighlight !== undefined) // qmllint disable missing-property
+ row.flashHighlight(); // qmllint disable missing-property
}
function scrollToAnchor(anchor: string): bool {
@@ -61,8 +61,8 @@ ColumnLayout {
root.animateScroll = true;
flickable.contentY = target;
Qt.callLater(() => root.animateScroll = false);
- if (row.flashHighlight !== undefined)
- row.flashHighlight();
+ if (row.flashHighlight !== undefined) // qmllint disable missing-property
+ row.flashHighlight(); // qmllint disable missing-property
return true;
}
@@ -154,11 +154,20 @@ ColumnLayout {
Layout.fillHeight: true
Layout.fillWidth: true
Layout.topMargin: -topMargin
+
bottomMargin: Tokens.padding.extraLarge
+ topMargin: Tokens.padding.large
+ fadeAmount: 0.1
+
contentHeight: root.contentChild?.implicitHeight ?? 0
contentItem.children: [root.contentChild]
- fadeAmount: 0.1
- topMargin: Tokens.padding.large
+
+ rebound: Transition {
+ Anim {
+ properties: "x,y"
+ type: Anim.DefaultEffects
+ }
+ }
Behavior on contentY {
enabled: root.animateScroll
diff --git a/Modules/Settings/NavPane/NavLocations.qml b/Modules/Settings/NavPane/NavLocations.qml
index 5ccf726..ed50d33 100644
--- a/Modules/Settings/NavPane/NavLocations.qml
+++ b/Modules/Settings/NavPane/NavLocations.qml
@@ -81,14 +81,10 @@ VerticalFadeFlickable {
topLeftRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
topRightRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
- RadiusBehavior on bottomLeftRadius {
- }
- RadiusBehavior on bottomRightRadius {
- }
- RadiusBehavior on topLeftRadius {
- }
- RadiusBehavior on topRightRadius {
- }
+ RadiusBehavior on bottomLeftRadius {}
+ RadiusBehavior on bottomRightRadius {}
+ RadiusBehavior on topLeftRadius {}
+ RadiusBehavior on topRightRadius {}
StateLayer {
id: stateLayer
@@ -262,14 +258,10 @@ VerticalFadeFlickable {
topRightRadius: layer.pressed ? Tokens.rounding.largeIncreased : isFirst ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
width: cardList.width
- RadiusBehavior on bottomLeftRadius {
- }
- RadiusBehavior on bottomRightRadius {
- }
- RadiusBehavior on topLeftRadius {
- }
- RadiusBehavior on topRightRadius {
- }
+ RadiusBehavior on bottomLeftRadius {}
+ RadiusBehavior on bottomRightRadius {}
+ RadiusBehavior on topLeftRadius {}
+ RadiusBehavior on topRightRadius {}
ColumnLayout {
id: resultLayout
@@ -318,9 +310,10 @@ VerticalFadeFlickable {
z: 1
- onClicked: {
- root.sState.jumpToSetting(result.modelData.pageIdx, result.modelData.subPath, result.modelData.anchor);
- }
+ onClicked: {
+ const target = result.modelData.targetSubPath;
+ root.sState.jumpToSetting(result.modelData.pageIdx, target.length > 0 ? target : result.modelData.subPath, target.length > 0 ? "" : result.modelData.anchor);
+ }
}
CustomSwitch {
diff --git a/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml
index ce750e5..2538aac 100644
--- a/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml
+++ b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml
@@ -67,6 +67,20 @@ PageBase {
SectionHeader {
first: true
+ text: qsTr("General")
+ }
+
+ ToggleRow {
+ settingAnchor: "panels-sidebar-llm-enabled"
+ checked: Config.llm.enabled
+ text: qsTr("Enabled")
+ first: true
+ last: true
+
+ onToggled: Config.llm.enabled = checked
+ }
+
+ SectionHeader {
text: qsTr("Appearance")
}
diff --git a/Modules/Settings/Pages/Panels/SidebarPanel.qml b/Modules/Settings/Pages/Panels/SidebarPanel.qml
index 6c8abd3..8de209b 100644
--- a/Modules/Settings/Pages/Panels/SidebarPanel.qml
+++ b/Modules/Settings/Pages/Panels/SidebarPanel.qml
@@ -120,6 +120,7 @@ PageBase {
NavRow {
text: qsTr("AI chat")
+ settingAnchor: "panels-sidebar-llm"
icon: "robot_2"
first: true
last: true
diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml
index 8c4b524..58bc912 100644
--- a/Modules/Settings/Pages/WallpaperPage.qml
+++ b/Modules/Settings/Pages/WallpaperPage.qml
@@ -38,6 +38,9 @@ PageBase {
spacing: Tokens.spacing.small
IconTextButton {
+
+ property string settingAnchor: "style-wallpapers"
+
enabled: Config.background.enabled
horizontalPadding: Tokens.padding.extraLarge
icon: "wallpaper"
@@ -51,6 +54,9 @@ PageBase {
}
IconTextButton {
+
+ property string settingAnchor: "style-colors-fonts"
+
enabled: Config.background.enabled
horizontalPadding: Tokens.padding.extraLarge
icon: "palette"
diff --git a/Modules/Settings/SettingsSearcher.qml b/Modules/Settings/SettingsSearcher.qml
index a180086..7894570 100644
--- a/Modules/Settings/SettingsSearcher.qml
+++ b/Modules/Settings/SettingsSearcher.qml
@@ -1,66 +1,24 @@
pragma Singleton
import "../../scripts/fzf.js" as Fzf
+import "../../scripts/settings-indexer.js" as SettingsIndexer
import QtQuick
import Quickshell
import ZShell
import ZShell.Config
+import qs.Paths
Singleton {
id: root
- property var fzfFinder: null
+ property var inverted: ({})
+ property var ranking: ({})
readonly property var highlightCache: ({
"search": "",
"pattern": null
})
- property var inverted: ({})
- property var ranking: ({})
-
- function highlight(text: string, search: string, colour: color): string {
- const escaped = text.replace(/&/g, "&").replace(//g, ">");
- if (search.length === 0)
- return escaped;
-
- const cache = root.highlightCache;
- if (search !== cache.search) {
- const tokens = tokenize(search);
- cache.search = search;
- if (tokens.length === 0)
- cache.pattern = null;
- else {
- const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
- cache.pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi");
- }
- }
-
- const pattern = cache.pattern;
- if (!pattern)
- return escaped;
-
- pattern.lastIndex = 0;
- if (!pattern.test(escaped))
- return escaped;
-
- pattern.lastIndex = 0;
- return escaped.replace(pattern, `$1`);
- }
-
- function lookup(token: string): var {
- const result = ({});
- const exact = root.inverted[token] !== undefined;
- const keys = exact ? [token] : Object.keys(root.inverted).filter(k => k.startsWith(token));
- for (const key of keys) {
- const rank = root.ranking[key] ?? ({});
- for (const id of root.inverted[key]) {
- const w = rank[id] ?? 0.1;
- if (result[id] === undefined || w > result[id])
- result[id] = w;
- }
- }
-
- return result;
- }
+ property var fzfFinder: null
+ readonly property string cachePath: Paths.cache + "/settings-index.json"
function query(search: string): list {
const tokens = root.tokenize(search);
@@ -70,7 +28,7 @@ Singleton {
const scores = ({});
const hitCounts = ({});
for (const token of tokens) {
- const matches = root.lookup(token);
+ const matches = root.lookup(token); // { id: weight }
for (const id in matches) {
scores[id] = (scores[id] ?? 0) + matches[id];
hitCounts[id] = (hitCounts[id] ?? 0) + 1;
@@ -103,13 +61,76 @@ Singleton {
return out;
}
+ function lookup(token: string): var {
+ const result = ({});
+ const exact = root.inverted[token] !== undefined;
+ const keys = exact ? [token] : Object.keys(root.inverted).filter(k => k.startsWith(token));
+ for (const key of keys) {
+ const rank = root.ranking[key] ?? ({});
+ for (const id of root.inverted[key]) {
+ const w = rank[id] ?? 0.1;
+ if (result[id] === undefined || w > result[id])
+ result[id] = w;
+ }
+ }
+
+ return result;
+ }
+
function tokenize(text: string): var {
return text.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length > 0);
}
+ function highlight(text: string, search: string, colour: color): string {
+ const escaped = text.replace(/&/g, "&").replace(//g, ">");
+ if (search.length === 0)
+ return escaped;
+
+ const cache = root.highlightCache;
+ if (search !== cache.search) {
+ const tokens = root.tokenize(search);
+ cache.search = search;
+ if (tokens.length === 0) {
+ cache.pattern = null;
+ } else {
+ const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
+ cache.pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi");
+ }
+ }
+
+ const pattern = cache.pattern;
+ if (!pattern)
+ return escaped;
+
+ pattern.lastIndex = 0;
+ if (!pattern.test(escaped))
+ return escaped;
+
+ pattern.lastIndex = 0;
+ return escaped.replace(pattern, `$1`);
+ }
+
+ function loadIndex(): var {
+ const revision = ZUtils.gitRevision();
+ // const cached = ZUtils.readTextFile(cachePath);
+ // if (cached) {
+ // try {
+ // const parsed = JSON.parse(cached);
+ // if (parsed.version === 3 && revision && parsed.revision === revision && parsed.locale === Qt.locale().name)
+ // return parsed;
+ // } catch (e) {}
+ // }
+ const data = SettingsIndexer.buildIndex(`${Quickshell.shellDir}/Modules/Settings`, p => ZUtils.readTextFile(p), (d, s) => ZUtils.listFiles(d, s), (ctx, text) => qsTranslate(ctx, text));
+ data.revision = revision;
+ data.locale = Qt.locale().name;
+ ZUtils.writeTextFile(cachePath, JSON.stringify(data));
+ console.log(`SettingsSearcher: indexed ${data.entries.length} settings (revision ${revision || "unknown"})`);
+ return data;
+ }
+
Component.onCompleted: {
try {
- const data = JSON.parse(ZUtils.settingsIndex());
+ const data = root.loadIndex();
entries.model = data.entries;
root.inverted = data.inverted ?? {};
root.ranking = data.ranking ?? {};
@@ -122,6 +143,7 @@ Singleton {
limit: 25
});
} catch (e) {
+ console.warn("SettingsSearcher: failed to build settings index:", e);
entries.model = [];
root.inverted = {};
root.ranking = {};
@@ -132,22 +154,25 @@ Singleton {
Variants {
id: entries
- SettingEntry {
- }
+ SettingEntry {}
}
component SettingEntry: QtObject {
- readonly property string anchor: modelData.anchor ?? ""
+ required property var modelData
+
+ readonly property int pageIdx: modelData.pageIdx
+ readonly property var subPath: modelData.subPath
+ readonly property var targetSubPath: modelData.targetSubPath ?? []
readonly property var crumbIcons: modelData.crumbIcons
readonly property var crumbLabels: modelData.crumbLabels
- readonly property bool isToggle: togglePath.length > 0
- required property var modelData
- readonly property int pageIdx: modelData.pageIdx
- readonly property string section: modelData.section ?? ""
- readonly property var subPath: modelData.subPath
- readonly property string subtext: modelData.subtext ?? ""
readonly property string title: modelData.title
+ readonly property string section: modelData.section ?? ""
+ readonly property string subtext: modelData.subtext ?? ""
+ readonly property string anchor: modelData.anchor ?? ""
+ readonly property string icon: modelData.icon ?? ""
+
readonly property string togglePath: modelData.togglePath ?? ""
+ readonly property bool isToggle: togglePath.length > 0
readonly property bool toggleValue: {
if (!isToggle)
return false;
diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt
index 4a2ff2f..422b784 100644
--- a/Plugins/CMakeLists.txt
+++ b/Plugins/CMakeLists.txt
@@ -1 +1,24 @@
+find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus CanvasPainter)
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
+pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
+pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED)
+pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET)
+pkg_check_modules(GLIB REQUIRED glib-2.0 gobject-2.0 gio-2.0)
+
+if(NOT Cava_FOUND)
+ pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED)
+endif()
+
+include(cmake/sensorslib.cmake)
+
+set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
+qt_standard_project_setup(REQUIRES 6.9)
+
+include(cmake/pch.cmake)
+include(cmake/qml-module.cmake)
+
+add_library(zshell-util INTERFACE)
+target_include_directories(zshell-util INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include")
+
add_subdirectory(ZShell)
diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt
index 9e7303f..485caea 100644
--- a/Plugins/ZShell/CMakeLists.txt
+++ b/Plugins/ZShell/CMakeLists.txt
@@ -1,55 +1,3 @@
-find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus CanvasPainter)
-find_package(PkgConfig REQUIRED)
-find_library(SENSORS_LIBRARY NAMES sensors REQUIRED)
-find_path(SENSORS_INCLUDE_DIR NAMES sensors/sensors.h REQUIRED)
-pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
-pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
-pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED)
-pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET)
-pkg_check_modules(GLIB REQUIRED glib-2.0 gobject-2.0 gio-2.0)
-
-if(NOT Cava_FOUND)
- pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED)
-endif()
-
-if(NOT TARGET Sensors::Sensors)
- add_library(Sensors::Sensors UNKNOWN IMPORTED)
- set_target_properties(Sensors::Sensors PROPERTIES
- IMPORTED_LOCATION "${SENSORS_LIBRARY}"
- INTERFACE_INCLUDE_DIRECTORIES "${SENSORS_INCLUDE_DIR}"
- )
-endif()
-
-set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
-qt_standard_project_setup(REQUIRES 6.9)
-
-function(qml_module arg_TARGET)
- cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;LIBRARIES;RESOURCES")
- qt_add_qml_module(${arg_TARGET}
- URI ${arg_URI}
- VERSION 1.0
- SOURCES ${arg_SOURCES}
- RESOURCES ${arg_RESOURCES}
- )
-
- qt_query_qml_module(${arg_TARGET}
- URI module_uri
- VERSION module_version
- PLUGIN_TARGET module_plugin_target
- TARGET_PATH module_target_path
- QMLDIR module_qmldir
- TYPEINFO module_typeinfo
- )
- set(module_dir "${INSTALL_QMLDIR}/${module_target_path}")
- install(TARGETS ${arg_TARGET} LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}")
- install(TARGETS "${module_plugin_target}" LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}")
- install(FILES "${module_qmldir}" DESTINATION "${module_dir}")
- install(FILES "${module_typeinfo}" DESTINATION "${module_dir}")
- target_link_libraries(${arg_TARGET} PRIVATE Qt::Core Qt::Qml ${arg_LIBRARIES})
-endfunction()
-
-set_source_files_properties("${SETTINGS_INDEX_JSON}" PROPERTIES QT_RESOURCE_ALIAS "settings-index.json")
-
qml_module(ZShell
URI ZShell
SOURCES
@@ -60,8 +8,6 @@ qml_module(ZShell
toaster.hpp toaster.cpp
qalculator.hpp qalculator.cpp
zutils.hpp zutils.cpp
- RESOURCES
- "${SETTINGS_INDEX_JSON}"
LIBRARIES
Qt::Gui
Qt::Quick
@@ -69,9 +15,13 @@ qml_module(ZShell
Qt::Sql
Qt::DBus
PkgConfig::Qalculate
+ zshell-util
)
-target_compile_definitions(ZShell PRIVATE ZSHELL_VERSION="${VERSION}")
+target_compile_definitions(ZShell PRIVATE
+ ZSHELL_VERSION="${VERSION}"
+ GIT_REVISION="${GIT_REVISION}"
+)
add_subdirectory(Models)
add_subdirectory(Internal)
diff --git a/Plugins/ZShell/Config/llm.hpp b/Plugins/ZShell/Config/llm.hpp
index b6a1353..4b5e68b 100644
--- a/Plugins/ZShell/Config/llm.hpp
+++ b/Plugins/ZShell/Config/llm.hpp
@@ -24,6 +24,7 @@ class Llm : public ConfigObject {
CFG_PROPERTY(QString, model, "")
CFG_PROPERTY(double, temperature, 0.7)
CFG_PROPERTY(bool, tools, true)
+ CFG_PROPERTY(bool, enabled, true)
CONFIG_SUBOBJECT(LlmAppearance, appearance)
public:
diff --git a/Plugins/ZShell/zutils.cpp b/Plugins/ZShell/zutils.cpp
index 4aef1fe..b6de6e0 100644
--- a/Plugins/ZShell/zutils.cpp
+++ b/Plugins/ZShell/zutils.cpp
@@ -5,6 +5,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -12,6 +13,8 @@
#include
#include
+#include "util/metaenum.hpp"
+
Q_LOGGING_CATEGORY(lcZUtils, "ZShell.cutils", QtInfoMsg)
namespace ZShell {
@@ -171,13 +174,44 @@ qreal ZUtils::clamp(qreal value, qreal min, qreal max) {
return qBound(min, value, max);
}
-QString ZUtils::settingsIndex() {
- QFile file(QStringLiteral(":/qt/qml/ZShell/settings-index.json"));
- if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
- qCWarning(lcZUtils) << "Failed to open embedded settings index";
- return QString();
+QString ZUtils::enumToString(
+ QObject* target, const QString& property, const QVariant& value) {
+ if (!target) {
+ qCWarning(lcZUtils) << "enumToString: a target is required";
+ return {};
}
- return QString::fromUtf8(file.readAll());
+
+ const auto* meta = target->metaObject();
+ const auto index = meta->indexOfProperty(property.toUtf8().constData());
+ if (index < 0) {
+ qCWarning(lcZUtils)
+ << "enumToString:" << target << "has no property" << property;
+ return {};
+ }
+
+ const auto prop = meta->property(index);
+ const auto metaEnum = prop.isEnumType()
+ ? prop.enumerator()
+ : util::metaEnumFor(prop.metaType());
+ if (!metaEnum.isValid() || metaEnum.is64Bit()) {
+ qCWarning(lcZUtils) << "enumToString: property" << property << "of"
+ << target << "is not a supported enum";
+ return {};
+ }
+
+ const auto val = value.isValid() ? value : prop.read(target);
+ const auto* key = util::enumKeyFor(metaEnum, val);
+ if (!key) {
+ qCWarning(
+ lcZUtils,
+ "enumToString: no enumerator of %s::%s has the value %lld",
+ metaEnum.scope(),
+ metaEnum.name(),
+ val.toLongLong());
+ return {};
+ }
+
+ return QString::fromUtf8(key);
}
#ifndef ZSHELL_VERSION
@@ -192,4 +226,46 @@ QString ZUtils::qtVersion() const {
return QStringLiteral(QT_VERSION_STR);
}
+QString ZUtils::gitRevision() {
+#ifdef GIT_REVISION
+ return QStringLiteral(GIT_REVISION);
+#else
+ return QString();
+#endif
+}
+
+QString ZUtils::readTextFile(const QString& path) {
+ QFile file(path);
+ if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ return QString();
+ }
+ return QString::fromUtf8(file.readAll());
+}
+
+bool ZUtils::writeTextFile(const QString& path, const QString& text) {
+ const QFileInfo info(path);
+ if (!QDir().mkpath(info.absolutePath())) {
+ qCWarning(lcZUtils) << "Failed to create directory for" << path;
+ return false;
+ }
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
+ qCWarning(lcZUtils) << "Failed to open" << path << "for writing";
+ return false;
+ }
+ return file.write(text.toUtf8()) >= 0;
+}
+
+QStringList ZUtils::listFiles(const QString& dir, const QString& suffix) {
+ QStringList out;
+ QDirIterator it(dir, QDirIterator::Subdirectories);
+ while (it.hasNext()) {
+ const QString path = it.next();
+ if (it.fileInfo().isFile() && path.endsWith(suffix)) {
+ out.append(path);
+ }
+ }
+ return out;
+}
+
} // namespace ZShell
diff --git a/Plugins/ZShell/zutils.hpp b/Plugins/ZShell/zutils.hpp
index 1e9b3f8..3900cc0 100644
--- a/Plugins/ZShell/zutils.hpp
+++ b/Plugins/ZShell/zutils.hpp
@@ -4,6 +4,8 @@
#include
#include
#include
+#include
+#include
namespace ZShell {
@@ -32,7 +34,24 @@ class ZUtils : public QObject {
Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max);
- Q_INVOKABLE static QString settingsIndex();
+ Q_INVOKABLE static QString enumToString(
+ QObject* target,
+ const QString& property,
+ const QVariant& value = QVariant());
+
+ Q_INVOKABLE static QString gitRevision();
+ Q_INVOKABLE static QString readTextFile(const QString& path);
+ Q_INVOKABLE static bool writeTextFile(
+ const QString& path, const QString& text);
+ Q_INVOKABLE static QStringList listFiles(
+ const QString& dir, const QString& suffix);
+
+ Q_INVOKABLE static QQuickItem* findChild(
+ QQuickItem* root, const QString& name);
+ Q_INVOKABLE static QList findChildren(
+ QQuickItem* root, const QString& name);
+ Q_INVOKABLE static QList findChildrenMatching(
+ QQuickItem* root, const QString& pattern);
[[nodiscard]] QString version() const;
[[nodiscard]] QString qtVersion() const;
diff --git a/Plugins/cmake/pch.cmake b/Plugins/cmake/pch.cmake
new file mode 100644
index 0000000..c5685fb
--- /dev/null
+++ b/Plugins/cmake/pch.cmake
@@ -0,0 +1,14 @@
+add_library(zshell-pch INTERFACE)
+target_precompile_headers(zshell-pch INTERFACE
+
+
+
+
+
+
+
+
+
+
+
+)
diff --git a/Plugins/cmake/qml-module.cmake b/Plugins/cmake/qml-module.cmake
new file mode 100644
index 0000000..f45a6a7
--- /dev/null
+++ b/Plugins/cmake/qml-module.cmake
@@ -0,0 +1,45 @@
+message(STATUS "QML install dir: ${CMAKE_INSTALL_PREFIX}/${INSTALL_QMLDIR}")
+
+function(qml_module arg_TARGET)
+ cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;QML_FILES;QML_SINGLETONS;DEPENDENCIES;IMPORTS;OPTIONAL_IMPORTS;DEFAULT_IMPORTS;LIBRARIES")
+ qt_add_qml_module(${arg_TARGET}
+ URI ${arg_URI}
+ SOURCES ${arg_SOURCES}
+ QML_FILES ${arg_QML_FILES} ${arg_QML_SINGLETONS}
+ DEPENDENCIES ${arg_DEPENDENCIES}
+ IMPORTS ${arg_IMPORTS}
+ OPTIONAL_IMPORTS ${arg_OPTIONAL_IMPORTS}
+ DEFAULT_IMPORTS ${arg_DEFAULT_IMPORTS}
+ )
+
+ qt_query_qml_module(${arg_TARGET}
+ URI module_uri
+ PLUGIN_TARGET module_plugin_target
+ TARGET_PATH module_target_path
+ QMLDIR module_qmldir
+ TYPEINFO module_typeinfo
+ )
+
+ message(STATUS "Created QML module: ${module_uri}")
+
+ string(REPLACE "/" ";" uri_parts "${module_target_path}")
+ list(GET uri_parts 0 top_level)
+ set(backing_lib_dir "${INSTALL_QMLDIR}/${top_level}/lib")
+ set(module_dir "${INSTALL_QMLDIR}/${module_target_path}")
+
+ install(TARGETS ${arg_TARGET}
+ LIBRARY DESTINATION "${backing_lib_dir}"
+ RUNTIME DESTINATION "${backing_lib_dir}"
+ )
+ install(TARGETS "${module_plugin_target}"
+ LIBRARY DESTINATION "${module_dir}"
+ RUNTIME DESTINATION "${module_dir}"
+ )
+ install(FILES "${module_qmldir}" DESTINATION "${module_dir}")
+ install(FILES "${module_typeinfo}" DESTINATION "${module_dir}")
+
+ target_link_libraries(${arg_TARGET} PRIVATE Qt::Core Qt::Qml ${arg_LIBRARIES})
+
+ file(RELATIVE_PATH plugin_to_lib "/${module_target_path}" "/${top_level}/lib")
+ set_property(TARGET ${module_plugin_target} APPEND PROPERTY INSTALL_RPATH "$ORIGIN/${plugin_to_lib}")
+endfunction()
diff --git a/Plugins/cmake/sensorslib.cmake b/Plugins/cmake/sensorslib.cmake
new file mode 100644
index 0000000..b7333a2
--- /dev/null
+++ b/Plugins/cmake/sensorslib.cmake
@@ -0,0 +1,10 @@
+find_library(SENSORS_LIBRARY NAMES sensors REQUIRED)
+find_path(SENSORS_INCLUDE_DIR NAMES sensors/sensors.h REQUIRED)
+if(NOT TARGET Sensors::Sensors)
+ add_library(Sensors::Sensors UNKNOWN IMPORTED)
+ set_target_properties(Sensors::Sensors PROPERTIES
+ IMPORTED_LOCATION "${SENSORS_LIBRARY}"
+ INTERFACE_INCLUDE_DIRECTORIES "${SENSORS_INCLUDE_DIR}"
+ )
+endif()
+
diff --git a/Plugins/include/util/metaenum.hpp b/Plugins/include/util/metaenum.hpp
new file mode 100644
index 0000000..25f6786
--- /dev/null
+++ b/Plugins/include/util/metaenum.hpp
@@ -0,0 +1,31 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace util {
+
+inline QMetaEnum metaEnumFor(const QMetaType& type) {
+ const auto* meta = type.metaObject();
+ if (!meta) return {};
+
+ auto name = QByteArray(type.name());
+ if (const auto scope = name.lastIndexOf("::"); scope >= 0)
+ name = name.mid(scope + 2);
+
+ return meta->enumerator(meta->indexOfEnumerator(name.constData()));
+}
+
+inline bool isSupportedEnum(const QMetaType& type) {
+ if (!type.flags().testFlag(QMetaType::IsEnumeration)) return false;
+
+ const auto metaEnum = metaEnumFor(type);
+ return metaEnum.isValid() && !metaEnum.is64Bit();
+}
+
+inline const char* enumKeyFor(const QMetaEnum& metaEnum, const QVariant& value) {
+ return metaEnum.valueToKey(static_cast(value.toLongLong()));
+}
+
+} // namespace util
diff --git a/scripts/build-settings-index.py b/scripts/build-settings-index.py
deleted file mode 100644
index b2e0df1..0000000
--- a/scripts/build-settings-index.py
+++ /dev/null
@@ -1,452 +0,0 @@
-from __future__ import annotations
-
-import json
-import re
-import sys
-from collections import defaultdict
-from functools import cache
-from pathlib import Path
-
-
-@cache
-def read_lines(path: Path) -> tuple[str, ...]:
- return tuple(path.read_text().splitlines())
-
-
-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*"([^"]+)"')
-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_WEIGHT = {"title": 1.0, "keywords": 0.4}
-STOPWORDS = {"the", "a", "an", "of", "and", "or", "to", "on", "in", "for"}
-
-
-def find_pages_dir(settings: Path) -> Path:
- return settings / "Pages"
-
-
-def discover_files(settings: Path) -> dict[str, Path]:
- files: dict[str, Path] = {}
- for p in find_pages_dir(settings).rglob("*.qml"):
- files[p.stem] = p
- return files
-
-
-PAGE_NAME_RE = re.compile(r'^\s*name:\s*qsTr\("([^"]+)"\)')
-PAGE_ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"')
-
-
-def parse_page_registry(settings: Path) -> list[tuple[str, str]]:
- text = (settings / "PageRegistry.qml").read_text().splitlines()
-
- start = next(
- i for i, line in enumerate(text) if re.search(r"\bpages\s*:\s*\[", line)
- )
-
- out: list[tuple[str, str]] = []
- i = start + 1
-
- while i < len(text):
- line = text[i].strip()
-
- if line.startswith("]"):
- break
-
- if line.startswith("//") or not line:
- i += 1
- continue
-
- if line.startswith("{"):
- name = None
- icon = None
- i += 1
-
- while i < len(text):
- s = text[i].strip()
-
- if s.startswith("}"):
- if name is not None:
- out.append((icon or "tune", name))
- break
-
- if name is None:
- m = PAGE_NAME_RE.match(text[i])
- if m:
- name = m.group(1)
-
- if icon is None:
- mi = PAGE_ICON_RE.match(text[i])
- if mi:
- icon = mi.group(1)
-
- i += 1
-
- i += 1
-
- return out
-
-
-BLOCK_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$")
-
-
-def _strip_comment(line: str) -> str:
- return line.split("//", 1)[0].rstrip()
-
-
-def parse_block(
- lines: list[str], i: int
-) -> tuple[str, list[tuple[str, list]], int]:
- line = _strip_comment(lines[i]).strip()
- m = BLOCK_RE.match(line)
- if not m:
- raise ValueError(f"Expected block start at line {i + 1}: {lines[i]!r}")
-
- name = m.group(1)
- i += 1
- children: list[tuple[str, list]] = []
-
- while i < len(lines):
- s = _strip_comment(lines[i]).strip()
- if not s:
- i += 1
- continue
-
- if s.startswith("}"):
- return name, children, i + 1
-
- if BLOCK_RE.match(s):
- child_name, child_children, i = parse_block(lines, i)
- children.append((child_name, child_children))
- continue
-
- i += 1
-
- raise ValueError(f"Unterminated block: {name}")
-
-
-def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]:
- name, children = block
-
- if name != "Component":
- return [name]
-
- for child_name, child_children in children:
- if child_name == "StackPage":
- out: list[str] = []
- for grand_name, grand_children in child_children:
- if grand_name == "Component":
- out.extend(collect_page_names((grand_name, grand_children)))
- return out
-
- if child_name != "Component":
- return [child_name]
-
- return []
-
-
-def parse_page_comps(settings: Path) -> list[list[str]]:
- text = (settings / "PageCompRegistry.qml").read_text().splitlines()
-
- start = next(
- i
- for i, line in enumerate(text)
- if re.search(r"\bpageComps\s*:\s*\[", _strip_comment(line))
- )
-
- comps: list[list[str]] = []
- i = start + 1
-
- while i < len(text):
- s = _strip_comment(text[i]).strip()
- if not s:
- i += 1
- continue
- if s.startswith("]"):
- break
-
- if BLOCK_RE.match(s) and BLOCK_RE.match(s).group(1) == "Component":
- block = parse_block(text, i)
- names = collect_page_names((block[0], block[1]))
- if names:
- comps.append(names)
- i = block[2]
- continue
-
- i += 1
-
- return comps
-
-
-def dedup_crumbs(
- labels: list[str], icons: list[str]
-) -> tuple[list[str], list[str]]:
- out_labels: list[str] = []
- out_icons: list[str] = []
- for lbl, ico in zip(labels, icons, strict=False):
- if out_labels and out_labels[-1] == lbl:
- continue
- out_labels.append(lbl)
- out_icons.append(ico)
- return out_labels, out_icons
-
-
-def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]:
- comps = parse_page_comps(settings)
- registry = parse_page_registry(settings)
-
- top_meta: dict[int, tuple[str, str]] = {}
- for i, (icon, label) in enumerate(registry):
- top_meta[i] = (icon, label)
-
- nav_children: dict[str, dict[int, tuple[str, str, str]]] = {}
- for names in comps:
- for name in names:
- pf = files.get(name)
- if not pf:
- continue
- pending_icon = pending_label = None
- section = ""
- expect_section = False
- for ln in read_lines(pf):
- if SECTION_RE.match(ln):
- expect_section = True
- continue
- ml = LABEL_RE.match(ln)
- if ml:
- if expect_section:
- section = ml.group(1)
- expect_section = False
- else:
- pending_label = ml.group(1)
- continue
- mi = ICON_RE.match(ln)
- if mi:
- pending_icon = mi.group(1)
- mo = re.search(r"openSubPage\((\d+)\)", ln)
- if mo:
- pos = int(mo.group(1))
- nav_children.setdefault(name, {})[pos] = (
- pending_icon or "tune",
- pending_label or "",
- section,
- )
- pending_icon = pending_label = None
-
- nav: dict[str, dict] = {}
- for top_idx, names in enumerate(comps):
- if not names:
- continue
- main = names[0]
- main_icon, main_label = top_meta.get(top_idx, ("tune", main))
- nav[main] = {
- "pageIdx": top_idx,
- "subPath": [],
- "crumbIcons": [main_icon],
- "crumbLabels": [main_label],
- }
- children = dict(nav_children.get(main, {}))
- opened_via_subpage = set()
- for owner, kids in nav_children.items():
- 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])
- 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])
- label = re.sub(r"(?= len(names):
- continue
- child = names[pos]
- labels = [main_label] + ([section] if section else []) + [label]
- icons = [main_icon] + ([icon] if section else []) + [icon]
- labels, icons = dedup_crumbs(labels, icons)
- nav[child] = {
- "pageIdx": top_idx,
- "subPath": [pos],
- "crumbIcons": icons,
- "crumbLabels": labels,
- }
- for gpos, (gicon, glabel, gsection) in nav_children.get(
- child, {}
- ).items():
- if gpos >= len(names):
- continue
- glabels = labels + ([gsection] if gsection else []) + [glabel]
- gicons = icons + ([gicon] if gsection else []) + [gicon]
- glabels, gicons = dedup_crumbs(glabels, gicons)
- nav[names[gpos]] = {
- "pageIdx": top_idx,
- "subPath": [pos, gpos],
- "crumbIcons": gicons,
- "crumbLabels": glabels,
- }
- return nav
-
-
-def tokenize(text: str) -> list[str]:
- toks: list[str] = []
- for word in text.lower().split():
- parts = [p for p in re.split(r"[^a-z0-9]+", word) if p]
- for p in parts:
- if p not in STOPWORDS and p not in toks:
- toks.append(p)
- if len(parts) > 1:
- joined = "".join(parts)
- if joined not in toks:
- toks.append(joined)
- return toks
-
-
-SUBTEXT_RE = re.compile(r'^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)')
-SECTION_RE = re.compile(r"^\s*SectionHeader\s*\{")
-
-
-def extract_settings(
- files: dict[str, Path], nav: dict[str, dict]
-) -> list[dict]:
- entries: list[dict] = []
- for comp, meta in nav.items():
- pf = files.get(comp)
- if not pf:
- continue
- lines = read_lines(pf)
- section = ""
- i = 0
- while i < len(lines):
- if SECTION_RE.match(lines[i]):
- for j in range(i + 1, min(i + 4, len(lines))):
- m = LABEL_RE.match(lines[j])
- if m:
- section = m.group(1)
- break
- row_match = ROW_RE.match(lines[i])
- if row_match:
- row_type = row_match.group(1)
- label = anchor = subtext = None
- checked_path = toggled_path = None
- for j in range(i + 1, min(i + 12, len(lines))):
- if label is None:
- m = LABEL_RE.match(lines[j])
- if m:
- label = m.group(1)
- if anchor is None:
- a = ANCHOR_RE.match(lines[j])
- if a:
- anchor = a.group(1)
- if subtext is None:
- st = SUBTEXT_RE.match(lines[j])
- if st:
- subtext = st.group(1)
- if checked_path is None:
- ch = CHECKED_RE.match(lines[j])
- if ch:
- checked_path = ch.group(1)
- if toggled_path is None:
- tg = ONTOGGLED_RE.match(lines[j])
- if tg:
- toggled_path = tg.group(1)
- 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:
- extra = (
- " ".join(meta["crumbLabels"])
- + " "
- + section
- + " "
- + (subtext or "")
- )
- entries.append(
- {
- "pageIdx": meta["pageIdx"],
- "subPath": meta["subPath"],
- "crumbIcons": meta["crumbIcons"],
- "crumbLabels": meta["crumbLabels"],
- "title": label,
- "anchor": anchor,
- "section": section,
- "subtext": subtext or "",
- "togglePath": toggle_path,
- "keywords": " ".join(
- sorted(set(tokenize(label + " " + extra)))
- ),
- }
- )
- i += 1
- return entries
-
-
-def build_inverted_and_ranking(entries: list[dict]):
- inverted: dict[str, list[int]] = defaultdict(list)
- ranking: dict[str, dict[int, float]] = defaultdict(dict)
- for idx, e in enumerate(entries):
- fields = {"title": e["title"], "keywords": e["keywords"]}
- seen: set[str] = set()
- for field, text in fields.items():
- weight = FIELD_WEIGHT.get(field, 0.2)
- for tok in tokenize(text):
- if idx not in inverted[tok]:
- inverted[tok].append(idx)
- ranking[tok][idx] = max(ranking[tok].get(idx, 0.0), weight)
- seen.add(tok)
- 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()
- }
-
-
-def main() -> int:
- if len(sys.argv) != 3:
- print(__doc__)
- return 1
- settings = Path(sys.argv[1])
- out = Path(sys.argv[2])
- files = discover_files(settings)
- nav = build_nav_map(settings, files)
- entries = extract_settings(files, nav)
- inverted, ranking = build_inverted_and_ranking(entries)
- for e in entries:
- e.pop("keywords", None)
- out.write_text(
- json.dumps(
- {
- "version": 2,
- "entries": entries,
- "inverted": inverted,
- "ranking": ranking,
- },
- ensure_ascii=False,
- indent=2,
- )
- )
- print(
- f"settings index: {len(entries)} entries, "
- f"{len(inverted)} tokens -> {out}"
- )
- print("files:", len(files))
- print("comps:", len(parse_page_comps(settings)))
- print("registry:", len(parse_page_registry(settings)))
- print("nav:", len(nav))
- print("entries:", len(entries))
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/scripts/settings-indexer.js b/scripts/settings-indexer.js
new file mode 100644
index 0000000..2b36e86
--- /dev/null
+++ b/scripts/settings-indexer.js
@@ -0,0 +1,450 @@
+.pragma library
+
+const STOPWORDS = [
+ "a",
+ "an",
+ "and",
+ "are",
+ "for",
+ "in",
+ "not",
+ "notification",
+ "of",
+ "on",
+ "or",
+ "out",
+ "the",
+ "to",
+];
+
+const FIELD_WEIGHT = {
+ title: 1.0,
+ keywords: 0.4,
+};
+const SKIP_LABELS = ["Muted", "None"];
+
+function cleanLabel(text) {
+ return String(text ?? "")
+ .replace(/\s*\(?%\d+\)?/g, "")
+ .trim();
+}
+
+const PAGE_NAME_RE = /^\s*name:\s*qsTr\("([^"]+)"\)/;
+const ROW_RE =
+ /^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|RowButton|InfoRow|PopupRow|DefaultRow|TextFieldRow|IconTextButton|TimeDialogSelect)\s*\{/;
+const NAV_ROW_TYPES = ["NavRow", "IconTextButton"];
+const LABEL_RE = /^\s*(?:label|text):\s*qsTr\("([^"]+)"\)/;
+const ANCHOR_RE = /^\s*(?:property\s+string\s+)?settingAnchor:\s*"([^"]+)"/;
+const CHECKED_RE = /^\s*checked:\s*(?:GlobalConfig|Config)\.([\w.]+)\s*$/;
+const ONTOGGLED_RE =
+ /^\s*onToggled:\s*(?:GlobalConfig|Config)\.([\w.]+)\s*=\s*checked\s*$/;
+const ICON_RE = /^\s*icon:\s*"([^"]+)"/;
+const SUBTEXT_RE = /^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)/;
+const SECTION_RE = /^\s*SectionHeader\s*\{/;
+
+function tokenize(text) {
+ const toks = [];
+ for (const word of text.toLowerCase().split(/\s+/)) {
+ if (!word) continue;
+ const parts = word.split(/[^a-z0-9]+/).filter((p) => p);
+ for (const p of parts) {
+ if (!STOPWORDS.includes(p) && !toks.includes(p)) toks.push(p);
+ }
+ if (parts.length > 1) {
+ const joined = parts.join("");
+ if (!toks.includes(joined)) toks.push(joined);
+ }
+ }
+ return toks;
+}
+
+function discoverFiles(settingsDir, listFiles) {
+ const files = {};
+ for (const p of listFiles(`${settingsDir}/Pages`, ".qml")) {
+ const name = p.slice(p.lastIndexOf("/") + 1).replace(/\.qml$/, "");
+ files[name] = p;
+ }
+ return files;
+}
+
+function parsePageRegistry(settingsDir, readLines) {
+ const lines = readLines(`${settingsDir}/PageRegistry.qml`);
+ const out = [];
+ let inArray = false;
+ let depth = 0;
+ let label = null;
+ let icon = null;
+ for (const line of lines) {
+ const s = line.trim();
+ if (s.includes("pages:") && s.includes("[")) {
+ inArray = true;
+ continue;
+ }
+ if (!inArray) continue;
+ if (s.startsWith("//")) continue;
+ if (s.startsWith("]")) break;
+ if (s.startsWith("{")) {
+ depth++;
+ label = icon = null;
+ continue;
+ }
+ if (s.startsWith("}")) {
+ if (label !== null) out.push([icon || "tune", label]);
+ depth--;
+ continue;
+ }
+ if (depth >= 1) {
+ const m = PAGE_NAME_RE.exec(line);
+ if (m && label === null) label = m[1];
+ const mi = ICON_RE.exec(line);
+ if (mi && icon === null) icon = mi[1];
+ }
+ }
+ return out;
+}
+
+function parsePageComps(settingsDir, readFile) {
+ const text = readFile(`${settingsDir}/PageCompRegistry.qml`);
+ const start = text.indexOf("pageComps:");
+ if (start === -1) return [];
+ const comps = [];
+ let current = null;
+ let depth = 0;
+ for (const raw of text.slice(start).split("\n")) {
+ const line = raw.split("//")[0];
+ const s = line.trim();
+ if (s.startsWith("]")) break;
+ const atTop = depth === 0;
+ depth +=
+ (line.match(/\{/g) ?? []).length -
+ (line.match(/\}/g) ?? []).length;
+ if (atTop && /^Component\s*\{/.test(s)) {
+ current = [];
+ comps.push(current);
+ continue;
+ }
+ if (current !== null) {
+ const m = /(^|\s)([A-Z][A-Za-z0-9]*)\s*\{\s*\}/.exec(s);
+ if (m) current.push(m[2]);
+ }
+ }
+ return comps;
+}
+
+function dedupCrumbs(labels, icons) {
+ const outLabels = [];
+ const outIcons = [];
+ for (let i = 0; i < labels.length; i++) {
+ if (
+ outLabels.length > 0 &&
+ outLabels[outLabels.length - 1] === labels[i]
+ )
+ continue;
+ outLabels.push(labels[i]);
+ outIcons.push(icons[i]);
+ }
+ return [outLabels, outIcons];
+}
+
+function buildNavMap(settingsDir, files, readFile, readLines) {
+ const comps = parsePageComps(settingsDir, readFile);
+ const registry = parsePageRegistry(settingsDir, readLines);
+
+ const navChildren = {};
+ for (const names of comps) {
+ for (const name of names) {
+ const pf = files[name];
+ if (!pf) continue;
+ let pendingIcon = null;
+ let pendingLabel = null;
+ let section = "";
+ let expectSection = false;
+ for (const ln of readLines(pf)) {
+ if (SECTION_RE.test(ln)) {
+ expectSection = true;
+ continue;
+ }
+ const ml = LABEL_RE.exec(ln);
+ if (ml) {
+ if (expectSection) {
+ section = ml[1];
+ expectSection = false;
+ } else {
+ pendingLabel = ml[1];
+ }
+ continue;
+ }
+ const mi = ICON_RE.exec(ln);
+ if (mi) pendingIcon = mi[1];
+ const mo = /openSubPage\((\d+)\)/.exec(ln);
+ if (mo) {
+ const pos = parseInt(mo[1], 10);
+ if (!navChildren[name]) navChildren[name] = {};
+ navChildren[name][pos] = [
+ pendingIcon || "tune",
+ pendingLabel || "",
+ section,
+ ];
+ pendingIcon = pendingLabel = null;
+ }
+ }
+ }
+ }
+
+ const nav = {};
+ for (let topIdx = 0; topIdx < comps.length; topIdx++) {
+ const names = comps[topIdx];
+ if (names.length === 0) continue;
+ const main = names[0];
+ const [mainIcon, mainLabel] = registry[topIdx] ?? ["tune", main];
+ nav[main] = {
+ pageIdx: topIdx,
+ subPath: [],
+ crumbIcons: [mainIcon],
+ crumbLabels: [mainLabel],
+ };
+ const children = Object.assign({}, navChildren[main] ?? {});
+ const openedViaSubpage = [];
+ for (const owner in navChildren) {
+ const ownerGroup = comps.find((ns) => ns.includes(owner));
+ if (!ownerGroup) continue;
+ for (const kpos in navChildren[owner]) {
+ const k = parseInt(kpos, 10);
+ if (k < ownerGroup.length) openedViaSubpage.push(ownerGroup[k]);
+ }
+ }
+ for (let pos = 1; pos < names.length; pos++) {
+ if (!(pos in children) && !openedViaSubpage.includes(names[pos])) {
+ let label = names[pos].replace(/(Detail)?Page$/, "");
+ label = label.replace(/(?= names.length) continue;
+ const [icon, label, section] = children[posKey];
+ const child = names[pos];
+ let labels = [mainLabel]
+ .concat(section ? [section] : [])
+ .concat([label]);
+ let icons = [mainIcon].concat(section ? [icon] : []).concat([icon]);
+ [labels, icons] = dedupCrumbs(labels, icons);
+ nav[child] = {
+ pageIdx: topIdx,
+ subPath: [pos],
+ crumbIcons: icons,
+ crumbLabels: labels,
+ };
+ const grandChildren = navChildren[child] ?? {};
+ for (const gposKey in grandChildren) {
+ const gpos = parseInt(gposKey, 10);
+ if (gpos >= names.length) continue;
+ const [gicon, glabel, gsection] = grandChildren[gposKey];
+ let glabels = labels
+ .concat(gsection ? [gsection] : [])
+ .concat([glabel]);
+ let gicons = icons
+ .concat(gsection ? [gicon] : [])
+ .concat([gicon]);
+ [glabels, gicons] = dedupCrumbs(glabels, gicons);
+ nav[names[gpos]] = {
+ pageIdx: topIdx,
+ subPath: [pos, gpos],
+ crumbIcons: gicons,
+ crumbLabels: glabels,
+ };
+ }
+ }
+ }
+ return nav;
+}
+
+function findBlockEnd(lines, start) {
+ let depth = 0;
+ for (let i = start; i < lines.length; i++) {
+ const line = lines[i].split("//")[0];
+ depth +=
+ (line.match(/\{/g) ?? []).length -
+ (line.match(/\}/g) ?? []).length;
+ if (i > start && depth <= 0) return i;
+ }
+ return lines.length;
+}
+
+function extractSettings(files, nav, comps, readLines) {
+ const entries = [];
+ for (const comp in nav) {
+ const meta = nav[comp];
+ const pf = files[comp];
+ if (!pf) continue;
+ const lines = readLines(pf);
+ let section = "";
+ for (let i = 0; i < lines.length; i++) {
+ if (SECTION_RE.test(lines[i])) {
+ for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
+ const m = LABEL_RE.exec(lines[j]);
+ if (m) {
+ section = m[1];
+ break;
+ }
+ }
+ }
+ const rowMatch = ROW_RE.exec(lines[i]);
+ if (!rowMatch) continue;
+ const rowType = rowMatch[1];
+ let label = null;
+ let anchor = null;
+ let subtext = null;
+ let checkedPath = null;
+ let toggledPath = null;
+ let targetPos = null;
+ const rowEnd = findBlockEnd(lines, i);
+ for (let j = i + 1; j < rowEnd; j++) {
+ if (label === null) {
+ const m = LABEL_RE.exec(lines[j]);
+ if (m) label = m[1];
+ }
+ if (anchor === null) {
+ const a = ANCHOR_RE.exec(lines[j]);
+ if (a) anchor = a[1];
+ }
+ if (subtext === null) {
+ const st = SUBTEXT_RE.exec(lines[j]);
+ if (st) subtext = st[1];
+ }
+ if (checkedPath === null) {
+ const ch = CHECKED_RE.exec(lines[j]);
+ if (ch) checkedPath = ch[1];
+ }
+ if (toggledPath === null) {
+ const tg = ONTOGGLED_RE.exec(lines[j]);
+ if (tg) toggledPath = tg[1];
+ }
+ if (NAV_ROW_TYPES.includes(rowType) && targetPos === null) {
+ const sp = /openSubPage\((\d+)\)/.exec(lines[j]);
+ if (sp) targetPos = parseInt(sp[1], 10);
+ }
+ }
+ const togglePath =
+ rowType === "ToggleRow" &&
+ checkedPath &&
+ checkedPath === toggledPath
+ ? checkedPath
+ : "";
+ if (label && !SKIP_LABELS.includes(label) && anchor) {
+ const extra =
+ meta.crumbLabels.join(" ") +
+ " " +
+ section +
+ " " +
+ (subtext && !/%\d/.test(subtext) ? subtext : "");
+ const group = comps[meta.pageIdx] ?? [];
+ const targetSubPath =
+ targetPos !== null && targetPos < group.length
+ ? meta.subPath.concat([targetPos])
+ : [];
+ entries.push({
+ rowType: rowType,
+ pageIdx: meta.pageIdx,
+ subPath: meta.subPath,
+ targetSubPath: targetSubPath,
+ crumbIcons: meta.crumbIcons,
+ crumbLabels: meta.crumbLabels,
+ trailKey: meta.crumbLabels.join("/"),
+ title: cleanLabel(label),
+ anchor: anchor,
+ section: section,
+ subtext: subtext && !/%\d/.test(subtext) ? subtext : "",
+ togglePath: togglePath,
+ });
+ }
+ }
+ }
+ return mergeInfoRows(entries);
+}
+
+function mergeInfoRows(entries) {
+ const out = [];
+ const merged = {};
+
+ for (const entry of entries) {
+ const isInfo = entry.rowType === "InfoRow";
+ delete entry.rowType;
+
+ if (!isInfo || !entry.section) {
+ out.push(entry);
+ continue;
+ }
+
+ const key =
+ entry.anchor.split("-")[0] +
+ "/" +
+ entry.trailKey +
+ "/" +
+ entry.section;
+ const existing = merged[key];
+ if (existing === undefined) {
+ entry.keywords = entry.title;
+ entry.title = entry.section;
+ merged[key] = entry;
+ out.push(entry);
+ } else {
+ existing.keywords += " " + entry.title;
+ }
+ }
+ return out;
+}
+
+function buildInvertedAndRanking(entries) {
+ const inverted = {};
+ const ranking = {};
+ for (let idx = 0; idx < entries.length; idx++) {
+ const e = entries[idx];
+ const extra =
+ e.crumbLabels.join(" ") +
+ " " +
+ e.section +
+ " " +
+ e.subtext +
+ " " +
+ (e.keywords ?? "");
+ const fields = {
+ title: e.title,
+ keywords: tokenize(e.title + " " + extra)
+ .sort()
+ .join(" "),
+ };
+ for (const field in fields) {
+ const weight = FIELD_WEIGHT[field] ?? 0.2;
+ for (const tok of tokenize(fields[field])) {
+ if (!inverted[tok]) inverted[tok] = [];
+ if (!inverted[tok].includes(idx)) inverted[tok].push(idx);
+ if (!ranking[tok]) ranking[tok] = {};
+ ranking[tok][idx] = Math.max(ranking[tok][idx] ?? 0.0, weight);
+ }
+ }
+ }
+ for (const tok in inverted)
+ inverted[tok].sort((a, b) => ranking[tok][b] - ranking[tok][a]);
+ return [inverted, ranking];
+}
+
+function buildIndex(settingsDir, readFile, listFiles) {
+ const lineCache = {};
+ const readLines = (path) => {
+ if (!(path in lineCache)) lineCache[path] = readFile(path).split("\n");
+ return lineCache[path];
+ };
+ const files = discoverFiles(settingsDir, listFiles);
+ const nav = buildNavMap(settingsDir, files, readFile, readLines);
+ const comps = parsePageComps(settingsDir, readFile);
+ const entries = extractSettings(files, nav, comps, readLines);
+ const [inverted, ranking] = buildInvertedAndRanking(entries);
+ return {
+ version: 3,
+ entries: entries,
+ inverted: inverted,
+ ranking: ranking,
+ };
+}