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, + }; +}