From 47bab21e22943aca935da03d9328d13fe7be6e98 Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 20 Aug 2026 00:27:48 +0200 Subject: [PATCH 01/90] initial commit for llm chat tab in sidebar --- .../Sidebar/Chat/ChatContent.qml | 280 ++++++++++ .../Notifications/Sidebar/Chat/ChatList.qml | 92 ++++ .../Notifications/Sidebar/Chat/ChatPanel.qml | 73 +++ Modules/Notifications/Sidebar/Content.qml | 65 ++- Modules/Notifications/Sidebar/Props.qml | 4 + Modules/Notifications/Sidebar/Tabs.qml | 142 +++++ .../Notifications/Sidebar/Utils/Wrapper.qml | 21 +- Modules/Notifications/Sidebar/Wrapper.qml | 16 + Plugins/ZShell/CMakeLists.txt | 1 + Plugins/ZShell/Config/CMakeLists.txt | 1 + Plugins/ZShell/Config/config.cpp | 13 +- Plugins/ZShell/Config/config.hpp | 6 + Plugins/ZShell/Config/llm.hpp | 19 + Plugins/ZShell/Llm/CMakeLists.txt | 13 + Plugins/ZShell/Llm/chat.cpp | 494 ++++++++++++++++++ Plugins/ZShell/Llm/chat.hpp | 108 ++++ Plugins/ZShell/Llm/chatstore.cpp | 251 +++++++++ Plugins/ZShell/Llm/chatstore.hpp | 57 ++ Plugins/ZShell/Llm/message.cpp | 117 +++++ Plugins/ZShell/Llm/message.hpp | 87 +++ Plugins/ZShell/Llm/session.cpp | 150 ++++++ Plugins/ZShell/Llm/session.hpp | 84 +++ 22 files changed, 2085 insertions(+), 9 deletions(-) create mode 100644 Modules/Notifications/Sidebar/Chat/ChatContent.qml create mode 100644 Modules/Notifications/Sidebar/Chat/ChatList.qml create mode 100644 Modules/Notifications/Sidebar/Chat/ChatPanel.qml create mode 100644 Modules/Notifications/Sidebar/Tabs.qml create mode 100644 Plugins/ZShell/Config/llm.hpp create mode 100644 Plugins/ZShell/Llm/CMakeLists.txt create mode 100644 Plugins/ZShell/Llm/chat.cpp create mode 100644 Plugins/ZShell/Llm/chat.hpp create mode 100644 Plugins/ZShell/Llm/chatstore.cpp create mode 100644 Plugins/ZShell/Llm/chatstore.hpp create mode 100644 Plugins/ZShell/Llm/message.cpp create mode 100644 Plugins/ZShell/Llm/message.hpp create mode 100644 Plugins/ZShell/Llm/session.cpp create mode 100644 Plugins/ZShell/Llm/session.hpp diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml new file mode 100644 index 0000000..a859b96 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -0,0 +1,280 @@ +pragma ComponentBehavior: Bound + +import ZShell.Config +import ZShell.Llm +import Quickshell +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Services + +Item { + id: root + + required property ChatSession chatData + property bool following: true + readonly property int messageCount: chatData.messages.length + + signal requestClose + + function atBottom(): bool { + return list.contentHeight - list.height - list.contentY < 48; + } + + function send(): void { + if (Chat.busy || input.text.trim() === "") + return; + following = true; + Chat.send(chatData.id, input.text); + input.text = ""; + } + + VerticalFadeListView { + id: list + + anchors.bottom: inputRow.top + anchors.bottomMargin: Tokens.spacing.medium + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + cacheBuffer: height * 20 + clip: true + currentIndex: messages.values.length - 1 + fadeAmount: 0.1 + highlightFollowsCurrentItem: true + highlightRangeMode: ListView.ApplyRange + preferredHighlightEnd: list.height + spacing: Tokens.spacing.medium + + CustomScrollBar.vertical: CustomScrollBar { + flickable: list + } + Behavior on contentY { + Anim { + } + } + delegate: ColumnLayout { + id: messageRow + + readonly property bool isUser: modelData.role === ChatMessage.Role.User + required property var modelData + + anchors.left: parent.left + anchors.leftMargin: messageRow.isUser ? 0 : Tokens.spacing.extraSmall + anchors.right: parent.right + anchors.rightMargin: messageRow.isUser ? Tokens.spacing.extraSmall : 0 + + Loader { + Layout.fillWidth: true + Layout.rightMargin: Tokens.spacing.extraLarge + active: !messageRow.isUser && messageRow.modelData.reasoning !== "" + + sourceComponent: CustomRect { + id: reasoning + + property bool expanded: false + + color: Colors.palette.m3surfaceContainer + implicitHeight: expanded ? expandedText.implicitHeight : collapsedText.implicitHeight + Tokens.padding.medium * 2 + radius: Tokens.rounding.medium + + Behavior on implicitHeight { + Anim { + } + } + + Loader { + id: spinnerReasoning + + active: opacity > 0 + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.verticalCenter: parent.verticalCenter + opacity: messageRow.modelData.reasoningActive ? 1 : 0 + + Behavior on opacity { + Anim { + } + } + sourceComponent: LoadingIndicator { + implicitSize: collapsedText.implicitHeight + } + } + + MaterialIcon { + id: reasoningDone + + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.verticalCenter: parent.verticalCenter + font.pointSize: Tokens.font.size.large + opacity: messageRow.modelData.reasoningActive || reasoning.expanded ? 0 : 1 + text: "check" + + Behavior on opacity { + Anim { + } + } + } + + CustomText { + id: collapsedText + + anchors.left: messageRow.modelData.reasoningActive ? spinnerReasoning.right : reasoningDone.right + anchors.margins: Tokens.padding.medium + anchors.right: parent.right + anchors.top: parent.top + opacity: reasoning.expanded ? 0 : 1 + text: messageRow.modelData.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((messageRow.modelData.reasoningElapsedMs / 1000).toFixed(1)) + visible: opacity > 0 + + Behavior on opacity { + Anim { + } + } + } + + CustomText { + id: expandedText + + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.right: parent.right + anchors.top: parent.top + color: Colors.palette.m3onSurface + opacity: reasoning.expanded ? 1 : 0 + text: messageRow.modelData.reasoning + visible: opacity > 0 + wrapMode: Text.WordWrap + + Behavior on opacity { + Anim { + } + } + } + + StateLayer { + onClicked: reasoning.expanded = !reasoning.expanded + } + } + } + + CustomRect { + Layout.alignment: messageRow.isUser ? Qt.AlignRight : Qt.AlignLeft + color: messageRow.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer + implicitHeight: Math.max(msgText.implicitHeight + Tokens.padding.medium * 2, spinnerLoader.implicitHeight) + implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, messageRow.ListView.view.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge) + radius: Tokens.rounding.medium + visible: !messageRow.modelData.reasoningActive + + CustomText { + id: msgText + + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.right: parent.right + anchors.top: parent.top + color: messageRow.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + text: messageRow.modelData.content + wrapMode: Text.WordWrap + } + + Loader { + id: spinnerLoader + + active: messageRow.modelData.streaming && msgText.text.length === 0 + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + + sourceComponent: Item { + implicitHeight: 18 + implicitWidth: 18 + + LoadingIndicator { + anchors.fill: parent + } + } + } + } + } + model: ScriptModel { + id: messages + + values: root.chatData.messages + } + + Component.onCompleted: positionViewAtEnd() + } + + ColumnLayout { + id: emptyState + + anchors.fill: parent + spacing: Tokens.spacing.small + visible: root.messageCount === 0 && !Chat.busy + + Item { + Layout.fillHeight: true + } + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3outlineVariant + font.pointSize: 36 + text: "chat" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3onSurfaceVariant + text: qsTr("No messages yet") + } + + Item { + Layout.fillHeight: true + } + } + + RowLayout { + id: inputRow + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.extraSmall + + CustomTextField { + id: input + + Layout.fillWidth: true + placeholderText: qsTr("Send a message...") + type: CustomTextField.Filled + + onAccepted: root.send() + } + + IconButton { + id: actionBtn + + enabled: Chat.busy || input.text.trim() !== "" + font.pointSize: 20 + icon: Chat.busy ? "stop" : "send" + type: IconButton.Filled + + onClicked: Chat.busy ? Chat.stop() : root.send() + } + } + + IconButton { + anchors.left: parent.left + anchors.top: parent.top + icon: "arrow_back" + inactiveColor: Colors.tPalette.m3surfaceContainerHigh + inactiveOnColor: Colors.palette.m3onSurfaceVariant + isRound: true + type: IconButton.Tonal + + onClicked: root.requestClose() + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml new file mode 100644 index 0000000..c98c385 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -0,0 +1,92 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts +import ZShell.Llm +import ZShell.Config +import qs.Services +import qs.Components + +Item { + id: root + + property alias model: list.model + + signal loadChatRequest(content: ChatSession) + signal newChatRequest + + CustomListView { + id: list + + anchors.fill: parent + cacheBuffer: height * 2 + clip: true + spacing: Tokens.spacing.medium + + delegate: CustomRect { + id: chatItem + + required property var modelData + + color: Colors.tPalette.m3surfaceContainer + implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin + implicitWidth: ListView.view.width + radius: Tokens.rounding.medium + + ColumnLayout { + id: layout + + anchors.bottomMargin: Tokens.padding.small + anchors.fill: parent + anchors.leftMargin: Tokens.padding.medium + anchors.rightMargin: Tokens.padding.medium + anchors.topMargin: Tokens.padding.small + spacing: 0 + + CustomText { + id: title + + text: chatItem.modelData.title + } + + CustomText { + id: timestamp + + color: Colors.palette.m3outline + font.pointSize: Tokens.font.size.small + text: chatItem.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP") + } + } + + StateLayer { + onClicked: { + root.loadChatRequest(chatItem.modelData); + } + } + } + } + + IconButton { + id: newChatBtn + + anchors.bottom: parent.bottom + anchors.margins: Tokens.padding.small + anchors.right: parent.right + font.pointSize: Math.round(18 * 1.2) + icon: "add" + padding: 8 + radius: Tokens.rounding.full + + onClicked: { + root.newChatRequest(); + } + + Elevation { + anchors.fill: parent + level: newChatBtn.stateLayer.containsMouse ? 4 : 3 + radius: parent.radius + z: -1 + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml new file mode 100644 index 0000000..57674de --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml @@ -0,0 +1,73 @@ +pragma ComponentBehavior: Bound + +import ZShell.Config +import ZShell.Llm +import QtQuick +import QtQuick.Controls +import Quickshell +import qs.Modules.Notifications.Sidebar +import qs.Components +import qs.Services + +Item { + id: root + + property bool chatOpen: false + required property Props props + + anchors.fill: parent + anchors.margins: Tokens.padding.small + + Component.onCompleted: { + if (root.props.inChat) { + stack.push(chatList); + stack.push(chatContent, { + "chatData": root.props.chatSession + }); + } else + stack.push(chatList); + } + + StackView { + id: stack + + anchors.fill: parent + } + + Component { + id: chatList + + ChatList { + model: ScriptModel { + values: Chat.chats.values + } + + onLoadChatRequest: chat => { + stack.push(chatContent, { + "chatData": chat + }); + root.props.inChat = true; + root.props.chatSession = chat; + } + onNewChatRequest: { + const data = Chat.chats.insert(); + stack.push(chatContent, { + "chatData": data + }); + root.props.inChat = true; + root.props.chatSession = data; + } + } + } + + Component { + id: chatContent + + ChatContent { + onRequestClose: { + stack.pop(); + root.props.inChat = false; + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 06f0c5d..cfd33c9 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -1,7 +1,11 @@ -import qs.Components +pragma ComponentBehavior: Bound + import ZShell.Config +import ZShell.Llm import QtQuick import QtQuick.Layouts +import qs.Components +import qs.Modules.Notifications.Sidebar.Chat import qs.Services Item { @@ -16,15 +20,64 @@ Item { anchors.fill: parent spacing: Tokens.spacing.small - CustomRect { + Tabs { + Layout.fillWidth: true + dashState: root.props + nonAnimWidth: layout.width + } + + CustomClippingRect { Layout.fillHeight: true Layout.fillWidth: true - color: Colors.tPalette.m3surfaceContainerLow radius: Tokens.rounding.small - NotifDock { - props: root.props - visibilities: root.visibilities + Item { + id: pages + + anchors.fill: parent + opacity: root.props.currentTab === 0 ? 1 : 0 + visible: opacity > 0.01 + + Behavior on opacity { + Anim { + } + } + + CustomRect { + anchors.fill: parent + color: Colors.tPalette.m3surfaceContainerLow + radius: Tokens.rounding.small + + NotifDock { + props: root.props + visibilities: root.visibilities + } + } + } + + Item { + id: chatPage + + anchors.fill: parent + opacity: visible ? 1 : 0 + visible: root.props.currentTab === 1 + z: 1 + + Behavior on opacity { + Anim { + } + } + + CustomRect { + anchors.fill: parent + color: Colors.tPalette.m3surfaceContainerLow + radius: Tokens.rounding.small + + ChatPanel { + anchors.fill: parent + props: root.props + } + } } } diff --git a/Modules/Notifications/Sidebar/Props.qml b/Modules/Notifications/Sidebar/Props.qml index 21a2541..02c0354 100644 --- a/Modules/Notifications/Sidebar/Props.qml +++ b/Modules/Notifications/Sidebar/Props.qml @@ -1,7 +1,11 @@ import Quickshell +import ZShell.Llm PersistentProperties { + property ChatSession chatSession + property int currentTab: 0 property list expandedNotifs: [] + property bool inChat: false reloadableId: "sidebar" } diff --git a/Modules/Notifications/Sidebar/Tabs.qml b/Modules/Notifications/Sidebar/Tabs.qml new file mode 100644 index 0000000..2a9bfb8 --- /dev/null +++ b/Modules/Notifications/Sidebar/Tabs.qml @@ -0,0 +1,142 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import QtQuick.Templates +import Quickshell +import ZShell.Config +import qs.Components +import qs.Services + +Item { + id: root + + readonly property alias count: bar.count + required property PersistentProperties dashState + required property real nonAnimWidth + + implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight + + TabBar { + id: bar + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + background: null + currentIndex: root.dashState.currentTab + implicitHeight: contentHeight + + contentItem: RowLayout { + spacing: 0 + + Repeater { + model: bar.contentModel + } + } + + onCurrentIndexChanged: root.state.currentTab = currentIndex + + Tab { + iconName: "notifications" + text: qsTr("Notifications") + } + + Tab { + iconName: "chat" + text: qsTr("Chat") + } + } + + Item { + id: indicator + + anchors.top: bar.bottom + clip: true + implicitHeight: 3 + implicitWidth: bar.currentItem.implicitWidth + x: { + const tab = bar.currentItem; + const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count; + return width * tab.TabBar.index + (width - tab.implicitWidth) / 2; + } + + Behavior on implicitWidth { + Anim { + } + } + Behavior on x { + Anim { + } + } + + CustomRect { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + color: Colors.palette.m3primary + implicitHeight: parent.implicitHeight * 2 + radius: Tokens.rounding.full + } + } + + CustomRect { + id: separator + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: indicator.bottom + color: Colors.palette.m3outlineVariant + implicitHeight: 1 + } + + component Tab: TabButton { + id: tab + + readonly property bool current: TabBar.tabBar.currentItem === this + required property string iconName + + Layout.fillWidth: true + Layout.preferredWidth: 1 + background: null + implicitHeight: implicitContentHeight + implicitWidth: implicitContentWidth + + contentItem: Item { + implicitHeight: icon.height + label.height + implicitWidth: Math.max(icon.width, label.width) + + StateLayer { + color: tab.current ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + radius: Tokens.rounding.small + + onClicked: root.dashState.currentTab = tab.TabBar.index + } + + MaterialIcon { + id: icon + + anchors.bottom: label.top + anchors.horizontalCenter: parent.horizontalCenter + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + fill: tab.current ? 1 : 0 + font.pointSize: 18 + text: tab.iconName + + Behavior on fill { + Anim { + } + } + } + + CustomText { + id: label + + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + text: tab.text + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Utils/Wrapper.qml b/Modules/Notifications/Sidebar/Utils/Wrapper.qml index 63a01b6..00936ed 100644 --- a/Modules/Notifications/Sidebar/Utils/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Utils/Wrapper.qml @@ -23,9 +23,12 @@ Item { required property var visibilities anchors.bottomMargin: (-implicitHeight - 5) * offsetScale - implicitHeight: content.implicitHeight + 8 * 2 + clip: chatActive + implicitHeight: chatActive ? 21 : content.implicitHeight + 8 * 2 implicitWidth: sidebar.width * (1 - sidebar.offsetScale) - opacity: 1 - offsetScale + opacity: (1 - offsetScale) * chatFade + readonly property bool chatActive: sidebar.chatActive + property real chatFade: chatActive ? 0 : 1 visible: offsetScale < 1 Behavior on offsetScale { @@ -35,6 +38,20 @@ Item { } } + Behavior on chatFade { + Anim { + duration: Tokens.anim.durations.expressiveDefaultSpatial + easing: Tokens.anim.expressiveDefaultSpatial + } + } + + Behavior on implicitHeight { + Anim { + duration: Tokens.anim.durations.expressiveDefaultSpatial + easing: Tokens.anim.expressiveDefaultSpatial + } + } + Loader { id: content diff --git a/Modules/Notifications/Sidebar/Wrapper.qml b/Modules/Notifications/Sidebar/Wrapper.qml index 245247f..dc92aa7 100644 --- a/Modules/Notifications/Sidebar/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Wrapper.qml @@ -1,7 +1,11 @@ pragma ComponentBehavior: Bound import qs.Components +import qs.Components.Toast +import ZShell import ZShell.Config +import ZShell.Llm +import Quickshell import QtQuick Item { @@ -12,6 +16,7 @@ Item { readonly property Props props: Props { } readonly property bool shouldBeActive: root.visibilities.sidebar && Config.sidebar.enabled + readonly property bool chatActive: props.currentTab === 1 required property var visibilities anchors.rightMargin: (-implicitWidth - 5) * offsetScale @@ -26,6 +31,17 @@ Item { } } + Connections { + target: Chat + + function onErrorOccurred(message: string): void { + if (root.shouldBeActive && root.chatActive) + return; + + Toaster.toast(qsTr("Chat"), message, "error_outline", Toast.Error); + } + } + Loader { id: content diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index d260f80..a20edf1 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -79,3 +79,4 @@ add_subdirectory(Services) add_subdirectory(Components) add_subdirectory(Blobs) add_subdirectory(Config) +add_subdirectory(Llm) diff --git a/Plugins/ZShell/Config/CMakeLists.txt b/Plugins/ZShell/Config/CMakeLists.txt index bee1215..55b3f7d 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -16,6 +16,7 @@ qml_module(ZShell-config dock.hpp general.hpp launcher.hpp + llm.hpp lock.hpp notifs.hpp osd.hpp diff --git a/Plugins/ZShell/Config/config.cpp b/Plugins/ZShell/Config/config.cpp index 268b4c1..8e0515d 100644 --- a/Plugins/ZShell/Config/config.cpp +++ b/Plugins/ZShell/Config/config.cpp @@ -9,6 +9,7 @@ #include "dock.hpp" #include "general.hpp" #include "launcher.hpp" +#include "llm.hpp" #include "lock.hpp" #include "notifs.hpp" #include "osd.hpp" @@ -33,6 +34,8 @@ namespace ZShell::config { +Config* Config::s_instance = nullptr; + Config::Config(QObject* parent) : ConfigObject(parent) , m_appearance(new Appearance(this)) @@ -44,6 +47,7 @@ Config::Config(QObject* parent) , m_dock(new Dock(this)) , m_general(new General(this)) , m_launcher(new Launcher(this)) + , m_llm(new Llm(this)) , m_lock(new Lock(this)) , m_notifs(new Notifs(this)) , m_osd(new Osd(this)) @@ -51,6 +55,7 @@ Config::Config(QObject* parent) , m_services(new Services(this)) , m_sidebar(new Sidebar(this)) , m_utilities(new Utilities(this)) { + s_instance = this; connect(this, &ConfigObject::propertiesChanged, this, &Config::scheduleSave); m_saveTimer.setSingleShot(true); @@ -81,8 +86,14 @@ Config::Config(QObject* parent) m_firstLoadDone = true; } +Config* Config::instance() { + return s_instance; +} + Config* Config::create(QQmlEngine*, QJSEngine*) { - return new Config(); + if (!s_instance) + s_instance = new Config(); + return s_instance; } QString Config::filePath() const { diff --git a/Plugins/ZShell/Config/config.hpp b/Plugins/ZShell/Config/config.hpp index e1881c5..6bbc3d3 100644 --- a/Plugins/ZShell/Config/config.hpp +++ b/Plugins/ZShell/Config/config.hpp @@ -24,6 +24,7 @@ class Dashboard; class Dock; class General; class Launcher; +class Llm; class Lock; class Notifs; class Osd; @@ -46,6 +47,7 @@ class Config : public ConfigObject { Q_MOC_INCLUDE("dock.hpp") Q_MOC_INCLUDE("general.hpp") Q_MOC_INCLUDE("launcher.hpp") + Q_MOC_INCLUDE("llm.hpp") Q_MOC_INCLUDE("lock.hpp") Q_MOC_INCLUDE("notifs.hpp") Q_MOC_INCLUDE("osd.hpp") @@ -63,6 +65,7 @@ class Config : public ConfigObject { CONFIG_SUBOBJECT(Dock, dock) CONFIG_SUBOBJECT(General, general) CONFIG_SUBOBJECT(Launcher, launcher) + CONFIG_SUBOBJECT(Llm, llm) CONFIG_SUBOBJECT(Lock, lock) CONFIG_SUBOBJECT(Notifs, notifs) CONFIG_SUBOBJECT(Osd, osd) @@ -74,6 +77,7 @@ class Config : public ConfigObject { public: explicit Config(QObject* parent = nullptr); static Config* create(QQmlEngine*, QJSEngine*); + [[nodiscard]] static Config* instance(); Q_INVOKABLE void load(); Q_INVOKABLE void saveNow(); @@ -103,6 +107,8 @@ class Config : public ConfigObject { bool m_loading = false; bool m_firstLoadDone = false; QFuture m_loadFuture; + + static Config* s_instance; }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/llm.hpp b/Plugins/ZShell/Config/llm.hpp new file mode 100644 index 0000000..e68584d --- /dev/null +++ b/Plugins/ZShell/Config/llm.hpp @@ -0,0 +1,19 @@ +#pragma once +#include "configobject.hpp" +#include + +namespace ZShell::config { + +class Llm : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(QString, endpoint, "http://localhost:8080") + CFG_PROPERTY(QString, model, "") + CFG_PROPERTY(double, temperature, 0.7) + + public: + explicit Llm(QObject* parent = nullptr) : ConfigObject(parent) {} +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt new file mode 100644 index 0000000..9474ceb --- /dev/null +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -0,0 +1,13 @@ +qml_module(ZShell-llm + URI ZShell.Llm + SOURCES + chat.hpp chat.cpp + session.hpp session.cpp + message.hpp message.cpp + chatstore.hpp chatstore.cpp + LIBRARIES + Qt::Network + ZShell-config +) + +target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../Config) diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp new file mode 100644 index 0000000..f053599 --- /dev/null +++ b/Plugins/ZShell/Llm/chat.cpp @@ -0,0 +1,494 @@ +#include "chat.hpp" + +#include "config.hpp" +#include "llm.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell { + +QString Chat::titleFrom(const QString& content) { + const QString flat = content.simplified(); + if (flat.isEmpty()) + return QString(); + if (flat.size() <= 48) + return flat; + return flat.left(47) + QStringLiteral("…"); +} + +QString Chat::completionsPath(const QString& endpoint, const QString& subpath) { + QString base = endpoint.trimmed(); + while (base.endsWith('/')) + base.chop(1); + if (!base.endsWith("/v1")) + base += "/v1"; + return base + subpath; +} + +QString Chat::serverErrorMessage( + const QByteArray& body, const QString& fallback) { + const QJsonDocument doc = QJsonDocument::fromJson(body); + if (doc.isObject()) { + const QJsonObject obj = doc.object(); + if (obj.contains("error")) { + const QJsonValue errorValue = obj["error"]; + if (errorValue.isObject()) { + const QString message = errorValue.toObject()["message"].toString(); + if (!message.isEmpty()) + return message; + } else if (!errorValue.toString().isEmpty()) { + return errorValue.toString(); + } + } + } + return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed") + : fallback; +} + +void Chat::setBusy(Chat* chat, bool value) { + if (chat->m_busy == value) + return; + chat->m_busy = value; + Q_EMIT chat->busyChanged(); +} + +void Chat::setStreamingChatId(Chat* chat, const QString& id) { + if (chat->m_streamingChatId == id) + return; + chat->m_streamingChatId = id; + Q_EMIT chat->streamingChatIdChanged(); +} + +Chat::Chat(QObject* parent) : QObject(parent), m_store(new ChatStore(this)) { + if (!config::Config::instance()) + new config::Config(); + + const auto* llm = config::Config::instance()->llm(); + m_endpoint = llm->endpoint(); + m_temperature = llm->temperature(); + m_model = llm->model(); + + connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { + if (m_endpoint != llm->endpoint()) { + m_endpoint = llm->endpoint(); + Q_EMIT endpointChanged(); + probeContextSize(); + } + if (m_model.isEmpty()) + refreshModels(); + }); + connect(llm, &config::Llm::modelChanged, this, [this, llm]() { + if (m_model == llm->model()) + return; + m_model = llm->model(); + Q_EMIT modelChanged(); + if (m_model.isEmpty()) + refreshModels(); + }); + connect( + llm, + &config::Llm::temperatureChanged, + this, + [this, llm]() { m_temperature = llm->temperature(); }); + + if (m_model.isEmpty()) + refreshModels(); + probeContextSize(); +} + +Chat::~Chat() { + if (m_reply) + m_reply->abort(); +} + +Chat* Chat::s_instance = nullptr; + +Chat* Chat::create(QQmlEngine*, QJSEngine*) { + if (!s_instance) + s_instance = new Chat(); + return s_instance; +} + +void Chat::send(const QString& chatId, const QString& content) { + const QString text = content.trimmed(); + if (text.isEmpty() || m_busy) + return; + + ChatSession* session = m_store->sessionById(chatId); + if (!session) { + qWarning() << "Chat: unknown chat id" << chatId; + return; + } + + if (!m_lastError.isEmpty()) { + m_lastError.clear(); + Q_EMIT lastErrorChanged(); + } + + session->ensureLoaded(); + session->appendMessage( + ChatMessage::Role::User, + text, + QDateTime::currentMSecsSinceEpoch()); + while (session->messageCount() > 200) + session->removeMessage(session->messages().first()); + if (session->title().isEmpty()) + session->setTitle(titleFrom(text)); + if (m_contextSize > 0 && m_lastTokenCount > m_contextSize * 4 / 5) + trimHistory(session, m_contextSize); + + m_active = session; + beginAssistant(); + m_store->persist(session); +} + +void Chat::beginAssistant() { + m_streaming = m_active->appendMessage( + ChatMessage::Role::Assistant, + QString(), + QDateTime::currentMSecsSinceEpoch()); + m_streaming->setStreaming(true); + setBusy(this, true); + setStreamingChatId(this, m_active->id()); + + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); + if (!url.isValid() || url.host().isEmpty()) { + fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); + return; + } + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "text/event-stream"); + + QJsonArray messages; + for (const auto* message : m_active->messages()) { + if (message == m_streaming) + continue; + QJsonObject messageObj; + messageObj[QStringLiteral("role")] = + message->role() == ChatMessage::Role::User + ? QStringLiteral("user") + : QStringLiteral("assistant"); + messageObj[QStringLiteral("content")] = message->content(); + if (!message->reasoning().isEmpty()) + messageObj[QStringLiteral("reasoning_content")] = message->reasoning(); + messages.append(messageObj); + } + + QJsonObject body; + QJsonObject streamOptions; + streamOptions[QStringLiteral("include_usage")] = true; + body[QStringLiteral("stream_options")] = streamOptions; + body[QStringLiteral("messages")] = messages; + body[QStringLiteral("stream")] = true; + body[QStringLiteral("temperature")] = m_temperature; + if (!m_model.isEmpty()) + body[QStringLiteral("model")] = m_model; + + m_buffer.clear(); + m_reply = m_manager.post(request, QJsonDocument(body).toJson()); + + connect(m_reply, &QNetworkReply::readyRead, this, [this]() { + if (m_reply) + m_buffer.append(m_reply->readAll()); + drainBuffer(); + }); + connect(m_reply, &QNetworkReply::finished, this, [this]() { + QNetworkReply* reply = m_reply; + if (!reply) + return; + m_reply = nullptr; + + const QNetworkReply::NetworkError error = reply->error(); + const QString errorString = reply->errorString(); + const QByteArray responseBody = reply->readAll(); + reply->deleteLater(); + + drainBuffer(); + if (!m_streaming) + return; + + if (error == QNetworkReply::NoError || + error == QNetworkReply::OperationCanceledError) + finalize(); + else + fail(serverErrorMessage(responseBody, errorString)); + }); +} + +void Chat::stop() { + if (!m_busy) + return; + if (m_reply) + m_reply->abort(); +} + +void Chat::dismissError() { + if (m_lastError.isEmpty()) + return; + m_lastError.clear(); + Q_EMIT lastErrorChanged(); +} + +void Chat::clearConversation(const QString& chatId) { + ChatSession* session = m_store->sessionById(chatId); + if (!session) + return; + if (m_busy && m_active == session) { + m_pendingClearChatId = chatId; + stop(); + return; + } + session->ensureLoaded(); + session->clearMessages(); + m_store->persist(session); +} + +void Chat::finalize() { + if (!m_streaming) + return; + + ChatSession* session = m_active; + endStream(); + if (session && m_pendingClearChatId == session->id()) { + session->clearMessages(); + m_pendingClearChatId.clear(); + } + if (session) + m_store->persist(session); +} + +void Chat::endStream() { + if (!m_streaming) + return; + m_streaming->setStreaming(false); + if (m_streaming->content().isEmpty() && m_streaming->reasoning().isEmpty() && m_active) + m_active->removeMessage(m_streaming); + m_streaming = nullptr; + setBusy(this, false); + setStreamingChatId(this, QString()); +} + +void Chat::fail(const QString& message) { + qWarning() << "Chat:" << message; + + const bool overflow = message.contains("overflow") || + message.contains("exceed") || + m_lastTokenCount > m_contextSize; + const QString shown = overflow + ? QStringLiteral( + "%1 (using ~%2 of %3 tokens; the oldest messages were auto-removed " + "so the next one will fit)") + .arg(message, QString::number(m_lastTokenCount), + QString::number(m_contextSize)) + : message; + m_lastError = shown; + Q_EMIT lastErrorChanged(); + + if (m_streaming) { + ChatSession* session = m_active; + endStream(); + if (overflow) + trimHistory(session, m_contextSize); + if (session && m_pendingClearChatId == session->id()) { + session->clearMessages(); + m_pendingClearChatId.clear(); + } + if (session) + m_store->persist(session); + } + Q_EMIT errorOccurred(shown); +} + +void Chat::drainBuffer() { + while (true) { + const qsizetype newline = m_buffer.indexOf('\n'); + if (newline < 0) + break; + const QByteArray line = m_buffer.left(newline).trimmed(); + m_buffer.remove(0, newline + 1); + handleLine(line); + } +} + +void Chat::handleLine(const QByteArray& line) { + if (line.isEmpty() || line.startsWith('#') || !line.startsWith("data:")) + return; + + const QByteArray data = line.mid(5).trimmed(); + if (data == "[DONE]") { + finalize(); + return; + } + + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (!doc.isObject()) + return; + const QJsonObject obj = doc.object(); + updateTokenUsage(obj); + + if (obj.contains("error")) { + const QJsonObject error = obj["error"].toObject(); + const QString message = error["message"].toString(); + fail(message.isEmpty() ? QStringLiteral("LLM server returned an error") : message); + return; + } + + for (const QJsonValue& choiceValue : obj["choices"].toArray()) { + const QJsonObject delta = choiceValue.toObject()["delta"].toObject(); + if (!m_streaming) + continue; + m_streaming->appendContent(delta["content"].toString()); + QString reasoning = delta["reasoning_content"].toString(); + if (reasoning.isEmpty()) + reasoning = delta["reasoning"].toString(); + m_streaming->appendReasoning(reasoning); + } +} + +void Chat::refreshModels() { + const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); + if (!url.isValid() || url.host().isEmpty()) + return; + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + if (error != QNetworkReply::NoError) { + qWarning() << "Chat: failed to fetch models:" << error; + return; + } + const QJsonArray dataArr = + QJsonDocument::fromJson(data).object()["data"].toArray(); + + QStringList models; + QSet seen; + for (const QJsonValue& value : dataArr) { + const QString id = value.toObject()["id"].toString(); + if (!id.isEmpty() && !seen.contains(id)) { + seen.insert(id); + models.append(id); + } + } + if (models.isEmpty()) + return; + + m_availableModels = models; + Q_EMIT availableModelsChanged(); + + if (m_model.isEmpty()) { + m_model = models.first(); + Q_EMIT modelChanged(); + } + }); +} + +void Chat::selectModel(const QString& id) { + if (id.isEmpty()) + return; + m_model = id; + Q_EMIT modelChanged(); + config::Config::instance()->llm()->set_model(id); +} + +void Chat::setContextSize(int size) { + if (size <= 0 || m_contextSize == size) + return; + m_contextSize = size; + Q_EMIT contextSizeChanged(); +} + +void Chat::probeContextSize() { + QString base = m_endpoint.trimmed(); + while (base.endsWith('/')) + base.chop(1); + const QUrl url = QUrl::fromUserInput(base + "/props"); + if (!url.isValid() || url.host().isEmpty()) + return; + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect( + reply, + &QNetworkReply::finished, + this, + [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + int size = 0; + if (error == QNetworkReply::NoError) { + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (doc.isArray()) { + for (const auto& value : doc.array()) { + const QJsonObject slot = value.toObject(); + if (slot.contains("n_ctx")) { + size = slot["n_ctx"].toInt(0); + if (size > 0) + break; + } + } + } else if (doc.isObject()) { + const QJsonObject obj = doc.object(); + size = obj["n_ctx"].toInt(0); + if (size <= 0) + size = obj["default_generation_settings"].toObject()["n_ctx"].toInt(0); + } + } + setContextSize(size > 0 ? size : 4096); + }); +} + +void Chat::updateTokenUsage(const QJsonObject& data) { + if (m_contextSize <= 0) + return; + const QJsonObject usage = data["usage"].toObject(); + if (usage.isEmpty()) + return; + const double used = usage.value("prompt_tokens").toDouble() + + usage.value("completion_tokens").toDouble(); + if (used > 0) + m_lastTokenCount = qMin(static_cast(used), m_contextSize * 2); +} + +void Chat::trimHistory(ChatSession* session, int contextSize) { + if (!session || contextSize <= 0) + return; + const int budget = contextSize * 4 / 5; + auto estimate = [&](const ChatMessage* message) -> qsizetype { + return (message->content().size() + + message->reasoning().size()) / + 4; + }; + qsizetype total = 0; + for (const auto* message : session->messages()) + total += estimate(message); + while (total > budget && session->messageCount() >= 2) { + ChatMessage* first = session->messages().first(); + const qsizetype used = estimate(first); + session->removeMessage(first); + total -= used; + if (session->messageCount() >= 2 && + session->messages().first()->role() == ChatMessage::Role::Assistant) { + ChatMessage* second = session->messages().first(); + const qsizetype usedSecond = estimate(second); + session->removeMessage(second); + total -= usedSecond; + } + } +} + +} // namespace ZShell diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp new file mode 100644 index 0000000..8ba0944 --- /dev/null +++ b/Plugins/ZShell/Llm/chat.hpp @@ -0,0 +1,108 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "chatstore.hpp" + +class QQmlEngine; +class QJSEngine; +class QNetworkReply; + +namespace ZShell { + +class Chat : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged) + Q_PROPERTY(QString model READ model NOTIFY modelChanged) + Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged) + Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) + Q_PROPERTY(ChatStore* chats READ chats CONSTANT) + Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged) + + public: + explicit Chat(QObject* parent = nullptr); + ~Chat(); + + [[nodiscard]] bool busy() const { return m_busy; } + [[nodiscard]] QString endpoint() const { return m_endpoint; } + [[nodiscard]] QString model() const { return m_model; } + [[nodiscard]] QStringList availableModels() const { return m_availableModels; } + [[nodiscard]] int contextSize() const { return m_contextSize; } + [[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; } + [[nodiscard]] QString lastError() const { return m_lastError; } + [[nodiscard]] ChatStore* chats() const { return m_store; } + [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } + [[nodiscard]] ChatSession* streamingSession() const { return m_active; } + + Q_INVOKABLE void send(const QString& chatId, const QString& content); + Q_INVOKABLE void stop(); + Q_INVOKABLE void clearConversation(const QString& chatId); + Q_INVOKABLE void dismissError(); + Q_INVOKABLE void refreshModels(); + Q_INVOKABLE void selectModel(const QString& id); + + static Chat* create(QQmlEngine*, QJSEngine*); + + Q_SIGNALS: + void busyChanged(); + void endpointChanged(); + void modelChanged(); + void availableModelsChanged(); + void contextSizeChanged(); + void errorOccurred(const QString& message); + void lastErrorChanged(); + void streamingChatIdChanged(); + + private: + void beginAssistant(); + void endStream(); + void finalize(); + void fail(const QString& message); + void handleLine(const QByteArray& line); + void drainBuffer(); + void probeContextSize(); + void setContextSize(int size); + void updateTokenUsage(const QJsonObject& data); + static void trimHistory(ChatSession* session, int contextSize); + + static QString titleFrom(const QString& content); + static QString completionsPath(const QString& endpoint, const QString& subpath); + static QString serverErrorMessage( + const QByteArray& body, const QString& fallback); + static void setBusy(Chat* chat, bool value); + static void setStreamingChatId(Chat* chat, const QString& id); + + QNetworkAccessManager m_manager; + ChatStore* m_store = nullptr; + QNetworkReply* m_reply = nullptr; + QByteArray m_buffer; + ChatSession* m_active = nullptr; + ChatMessage* m_streaming = nullptr; + QString m_pendingClearChatId; + bool m_busy = false; + QString m_endpoint; + QString m_model; + QStringList m_availableModels; + QString m_lastError; + QString m_streamingChatId; + double m_temperature = 0.7; + int m_contextSize = 0; + int m_lastTokenCount = 0; + + static Chat* s_instance; + + friend class ChatStore; +}; + +} // namespace ZShell diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp new file mode 100644 index 0000000..4dfb055 --- /dev/null +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -0,0 +1,251 @@ +#include "chatstore.hpp" + +#include "chat.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace ZShell { + +ChatStore::ChatStore(QObject* parent) : QObject(parent) { + load(); +} + +QString ChatStore::dir() { + const QString base = + QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + + QStringLiteral("/zshell"); + return base + QStringLiteral("/chats"); +} + +QString ChatStore::pathFor(const QString& id) { + return dir() + QStringLiteral("/") + id + QStringLiteral(".json"); +} + +bool ChatStore::writeFile(const QString& path, const QJsonObject& doc) { + QDir().mkpath(QFileInfo(path).absolutePath()); + + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + qWarning() << "ChatStore: failed to open" << path << "for writing:" + << file.errorString(); + return false; + } + if (file.write(QJsonDocument(doc).toJson(QJsonDocument::Indented)) < 0 || + !file.commit()) { + qWarning() << "ChatStore: failed to write" << path << ":" + << file.errorString(); + return false; + } + return true; +} + +int ChatStore::count() const { + return static_cast(m_sessions.size()); +} + +QVariantList ChatStore::values() const { + QVariantList vals; + vals.reserve(m_sessions.size()); + for (const auto* session : m_sessions) + vals.append(QVariant::fromValue(session)); + return vals; +} + +ChatSession* ChatStore::at(int index) const { + if (index < 0 || index >= m_sessions.size()) + return nullptr; + return m_sessions.at(index); +} + +ChatSession* ChatStore::insert(int index) { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + auto* session = new ChatSession(QString::number(now), this); + session->setPath(pathFor(session->id())); + session->setMeta(QString(), now, now, 0); + const int pos = + index >= 0 && index <= m_sessions.size() ? index : 0; + m_sessions.insert(pos, session); + Q_EMIT countChanged(); + Q_EMIT valuesChanged(); + return session; +} + +void ChatStore::remove(int index) { + removeSession(at(index)); +} + +void ChatStore::remove(ChatSession* chat) { + removeSession(chat); +} + +void ChatStore::removeSession(ChatSession* session) { + if (!session || !m_sessions.contains(session)) + return; + if (isStreaming(session)) { + qWarning() << "ChatStore: cannot remove a chat that is streaming"; + return; + } + const QList before = m_sessions; + QFile::remove(session->path()); + if (auto* chat = qobject_cast(parent())) + if (chat->m_active == session) + chat->m_active = nullptr; + m_sessions.removeOne(session); + session->deleteLater(); + notify(before); +} + +void ChatStore::move(int from, int to) { + if (from < 0 || from >= m_sessions.size() || to < 0 || + to >= m_sessions.size() || from == to) + return; + m_sessions.move(from, to); + Q_EMIT valuesChanged(); +} + +void ChatStore::clear() { + const QList before = m_sessions; + QList keep; + for (ChatSession* session : std::as_const(m_sessions)) { + if (isStreaming(session)) { + keep.append(session); + continue; + } + QFile::remove(session->path()); + session->deleteLater(); + } + m_sessions = keep; + notify(before); +} + +ChatSession* ChatStore::sessionById(const QString& id) { + for (auto* session : m_sessions) + if (session->id() == id) + return session; + return nullptr; +} + +void ChatStore::persist(ChatSession* session) { + if (!session || !m_sessions.contains(session)) + return; + session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); + if (!writeFile(session->path(), session->document())) + return; + sortAndNotify(); +} + +bool ChatStore::isStreaming(ChatSession* session) const { + const auto* chat = qobject_cast(parent()); + return chat && chat->streamingSession() == session; +} + +void ChatStore::sortAndNotify() { + const QList before = m_sessions; + std::stable_sort( + m_sessions.begin(), + m_sessions.end(), + [](const ChatSession* a, const ChatSession* b) { + return a->updatedAtMs() > b->updatedAtMs(); + }); + notify(before); +} + +void ChatStore::notify(const QList& before) { + if (before.size() != m_sessions.size()) + Q_EMIT countChanged(); + bool same = before.size() == m_sessions.size(); + for (int i = 0; same && i < m_sessions.size(); ++i) + if (before.at(i) != m_sessions.at(i)) + same = false; + if (!same) + Q_EMIT valuesChanged(); +} + +void ChatStore::load() { + const QFileInfoList files = QDir(dir()).entryInfoList( + QStringList{QStringLiteral("*.json")}, + QDir::Files, + QDir::Time); + for (const QFileInfo& info : files) { + QFile file(info.absoluteFilePath()); + if (!file.open(QIODevice::ReadOnly)) + continue; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + if (!doc.isObject()) + continue; + const QJsonObject obj = doc.object(); + auto* session = + new ChatSession(obj["id"].toString(info.completeBaseName()), this); + session->setPath(info.absoluteFilePath()); + session->setMeta( + obj["title"].toString(), + obj["createdAt"].toVariant().toLongLong(), + obj["updatedAt"].toVariant().toLongLong(), + int(obj["messages"].toArray().size())); + m_sessions.append(session); + } + sortAndNotify(); + migrateLegacy(); +} + +void ChatStore::migrateLegacy() { + const QString path = dir() + QStringLiteral("/chat.json"); + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) + return; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + const QJsonArray arr = doc.isArray() ? doc.array() : QJsonArray(); + + QString title; + qint64 first = 0; + qint64 last = 0; + QJsonArray messages; + for (const QJsonValue& value : arr) { + const QJsonObject msg = value.toObject(); + const QString content = msg["content"].toString(); + if (content.isEmpty()) + continue; + if (first == 0) + first = msg["timestamp"].toVariant().toLongLong(); + last = msg["timestamp"].toVariant().toLongLong(); + if (title.isEmpty() && msg["role"].toString() == "user") + title = Chat::titleFrom(content); + messages.append(msg); + } + if (messages.isEmpty()) + return; + + const QString id = QString::number(QDateTime::currentMSecsSinceEpoch()); + auto* session = new ChatSession(id, this); + session->setPath(pathFor(id)); + + QJsonObject out; + out[QStringLiteral("id")] = id; + out[QStringLiteral("title")] = title; + out[QStringLiteral("createdAt")] = first; + out[QStringLiteral("updatedAt")] = last; + out[QStringLiteral("messages")] = messages; + if (!writeFile(session->path(), out)) + return; + QFile::remove(path); + + session->setMeta(title, first, last, int(messages.size())); + m_sessions.append(session); + sortAndNotify(); +} + +} // namespace ZShell diff --git a/Plugins/ZShell/Llm/chatstore.hpp b/Plugins/ZShell/Llm/chatstore.hpp new file mode 100644 index 0000000..3f6d299 --- /dev/null +++ b/Plugins/ZShell/Llm/chatstore.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "session.hpp" + +#include +#include +#include + +namespace ZShell { + +class Chat; + +class ChatStore : public QObject { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged) + + public: + explicit ChatStore(QObject* parent = nullptr); + + [[nodiscard]] int count() const; + [[nodiscard]] QVariantList values() const; + [[nodiscard]] ChatSession* at(int index) const; + + Q_INVOKABLE ChatSession* insert(int index = -1); + Q_INVOKABLE void remove(int index); + Q_INVOKABLE void remove(ChatSession* chat); + Q_INVOKABLE void move(int from, int to); + Q_INVOKABLE void clear(); + + [[nodiscard]] ChatSession* sessionById(const QString& id); + void persist(ChatSession* session); + + Q_SIGNALS: + void countChanged(); + void valuesChanged(); + + private: + void load(); + void migrateLegacy(); + void sortAndNotify(); + void removeSession(ChatSession* session); + bool isStreaming(ChatSession* session) const; + void notify(const QList& before); + + QList m_sessions; + + static QString dir(); + static QString pathFor(const QString& id); + static bool writeFile(const QString& path, const QJsonObject& doc); + + friend class Chat; +}; + +} // namespace ZShell diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp new file mode 100644 index 0000000..4153587 --- /dev/null +++ b/Plugins/ZShell/Llm/message.cpp @@ -0,0 +1,117 @@ +#include "message.hpp" + +#include + +namespace ZShell { + +ChatMessage::ChatMessage( + Role role, + const QString& content, + qint64 timestamp, + QObject* parent) +: QObject(parent), m_role(role), m_content(content), m_timestamp(timestamp) { + m_timer.setParent(this); + m_timer.setInterval(500); + m_timer.setTimerType(Qt::CoarseTimer); + connect(&m_timer, &QTimer::timeout, this, [this]() { + if (!reasoningInFlight() && !contentInFlight()) { + m_timer.stop(); + return; + } + Q_EMIT elapsedMsChanged(); + }); +} + +qint64 ChatMessage::reasoningElapsedMs() const { + if (m_reasoningStartedAt <= 0) + return 0; + const qint64 end = m_reasoningEndedAt > 0 + ? m_reasoningEndedAt + : QDateTime::currentMSecsSinceEpoch(); + return end - m_reasoningStartedAt; +} + +qint64 ChatMessage::contentElapsedMs() const { + if (m_contentStartedAt <= 0) + return 0; + const qint64 end = m_contentEndedAt > 0 + ? m_contentEndedAt + : QDateTime::currentMSecsSinceEpoch(); + return end - m_contentStartedAt; +} + +void ChatMessage::updateReasoningActive() { + const bool active = m_streaming && !m_reasoning.isEmpty() && m_content.isEmpty(); + if (m_reasoningActive == active) + return; + m_reasoningActive = active; + Q_EMIT reasoningActiveChanged(); +} + +void ChatMessage::appendContent(const QString& piece) { + if (piece.isEmpty()) + return; + if (m_content.isEmpty()) { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (reasoningInFlight()) + m_reasoningEndedAt = now; + m_contentStartedAt = now; + if (!m_timer.isActive()) + m_timer.start(); + } + m_content += piece; + Q_EMIT contentChanged(); + updateReasoningActive(); + Q_EMIT elapsedMsChanged(); +} + +void ChatMessage::appendReasoning(const QString& piece) { + if (piece.isEmpty()) + return; + if (m_reasoning.isEmpty()) { + m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); + if (!m_timer.isActive()) + m_timer.start(); + } + m_reasoning += piece; + Q_EMIT reasoningChanged(); + updateReasoningActive(); + Q_EMIT elapsedMsChanged(); +} + +void ChatMessage::setReasoning(const QString& value) { + if (m_reasoning == value) + return; + m_reasoning = value; + Q_EMIT reasoningChanged(); +} + +void ChatMessage::setElapsedMs(qint64 reasoningMs, qint64 contentMs) { + if (reasoningMs > 0) { + m_reasoningStartedAt = m_timestamp; + m_reasoningEndedAt = m_timestamp + reasoningMs; + } + if (contentMs > 0) { + m_contentStartedAt = m_timestamp; + m_contentEndedAt = m_timestamp + contentMs; + } +} + +void ChatMessage::setStreaming(bool value) { + if (m_streaming == value) + return; + m_streaming = value; + Q_EMIT streamingChanged(); + if (!value) { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (reasoningInFlight()) + m_reasoningEndedAt = now; + if (contentInFlight()) + m_contentEndedAt = now; + m_timer.stop(); + Q_EMIT elapsedMsChanged(); + } + updateReasoningActive(); +} + +} // namespace ZShell diff --git a/Plugins/ZShell/Llm/message.hpp b/Plugins/ZShell/Llm/message.hpp new file mode 100644 index 0000000..66202d4 --- /dev/null +++ b/Plugins/ZShell/Llm/message.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include + +namespace ZShell { + +class Chat; + +class ChatMessage : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat messages are created by the Chat singleton") + + Q_PROPERTY(Role role READ role NOTIFY roleChanged) + Q_PROPERTY(QString content READ content NOTIFY contentChanged) + Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) + Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) + Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) + Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + + public: + enum class Role : int { + User = 0, + Assistant + }; + Q_ENUM(Role) + + explicit ChatMessage( + Role role, + const QString& content, + qint64 timestamp, + QObject* parent = nullptr); + + [[nodiscard]] Role role() const { return m_role; } + [[nodiscard]] QString content() const { return m_content; } + [[nodiscard]] QString reasoning() const { return m_reasoning; } + [[nodiscard]] bool reasoningActive() const { return m_reasoningActive; } + [[nodiscard]] qint64 reasoningElapsedMs() const; + [[nodiscard]] qint64 contentElapsedMs() const; + [[nodiscard]] bool streaming() const { return m_streaming; } + [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + + void appendContent(const QString& piece); + void appendReasoning(const QString& piece); + void setReasoning(const QString& value); + void setElapsedMs(qint64 reasoningMs, qint64 contentMs); + void setStreaming(bool value); + + Q_SIGNALS: + void roleChanged(); + void contentChanged(); + void reasoningChanged(); + void reasoningActiveChanged(); + void elapsedMsChanged(); + void streamingChanged(); + + private: + void updateReasoningActive(); + + [[nodiscard]] bool reasoningInFlight() const { + return m_reasoningStartedAt > 0 && m_reasoningEndedAt <= 0; + } + [[nodiscard]] bool contentInFlight() const { + return m_contentStartedAt > 0 && m_contentEndedAt <= 0; + } + + QTimer m_timer; + Role m_role; + QString m_content; + QString m_reasoning; + bool m_reasoningActive = false; + bool m_streaming = false; + qint64 m_timestamp; + qint64 m_reasoningStartedAt = 0; + qint64 m_reasoningEndedAt = 0; + qint64 m_contentStartedAt = 0; + qint64 m_contentEndedAt = 0; + + friend class Chat; +}; + +} // namespace ZShell diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp new file mode 100644 index 0000000..4e7d4a1 --- /dev/null +++ b/Plugins/ZShell/Llm/session.cpp @@ -0,0 +1,150 @@ +#include "session.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell { + +ChatSession::ChatSession(const QString& id, QObject* parent) +: QObject(parent), m_id(id) { +} + +void ChatSession::setTitle(const QString& value) { + if (m_title == value) + return; + m_title = value; + Q_EMIT titleChanged(); +} + +void ChatSession::setUpdatedAt(qint64 value) { + if (m_updatedAt == value) + return; + m_updatedAt = value; + Q_EMIT updatedAtChanged(); +} + +void ChatSession::setCount(int value) { + if (m_messageCount == value) + return; + m_messageCount = value; + Q_EMIT messageCountChanged(); +} + +void ChatSession::setMeta( + const QString& title, + qint64 createdAt, + qint64 updatedAt, + int messageCount) { + m_title = title; + m_createdAt = createdAt; + m_updatedAt = updatedAt; + m_messageCount = messageCount; +} + +void ChatSession::ensureLoaded() { + if (m_loaded || m_path.isEmpty()) + return; + m_loaded = true; + loadMessages(); +} + +void ChatSession::loadMessages() { + QFile file(m_path); + if (file.open(QIODevice::ReadOnly)) { + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + if (doc.isObject()) { + const QJsonObject obj = doc.object(); + m_title = obj["title"].toString(m_title); + m_createdAt = obj["createdAt"].toVariant().toLongLong() || m_createdAt; + m_updatedAt = obj["updatedAt"].toVariant().toLongLong() || m_updatedAt; + for (const QJsonValue& value : obj["messages"].toArray()) { + const QJsonObject msg = value.toObject(); + const QString content = msg["content"].toString(); + const QString reasoning = msg["reasoning"].toString(); + if (content.isEmpty() && reasoning.isEmpty()) + continue; + auto* message = new ChatMessage( + msg["role"].toString() == "user" ? ChatMessage::Role::User + : ChatMessage::Role::Assistant, + content, + msg["timestamp"].toVariant().toLongLong(), + this); + message->setReasoning(reasoning); + message->setElapsedMs( + msg["reasoningElapsedMs"].toVariant().toLongLong(), + msg["contentElapsedMs"].toVariant().toLongLong()); + m_messages.append(message); + } + } + } else { + qWarning() << "ChatSession: failed to load" << m_path << ":" + << file.errorString(); + } + setCount(int(m_messages.size())); + Q_EMIT titleChanged(); + Q_EMIT messagesChanged(); +} + +ChatMessage* ChatSession::appendMessage( + ChatMessage::Role role, const QString& content, qint64 timestamp) { + ensureLoaded(); + auto* message = new ChatMessage(role, content, timestamp, this); + m_messages.append(message); + setCount(int(m_messages.size())); + Q_EMIT messagesChanged(); + return message; +} + +void ChatSession::removeMessage(ChatMessage* message) { + if (!message || !m_messages.removeOne(message)) + return; + delete message; + setCount(int(m_messages.size())); + Q_EMIT messagesChanged(); +} + +void ChatSession::clearMessages() { + ensureLoaded(); + if (m_messages.isEmpty()) + return; + qDeleteAll(m_messages); + m_messages.clear(); + setCount(0); + Q_EMIT messagesChanged(); +} + +QJsonObject ChatSession::document() { + ensureLoaded(); + QJsonArray arr; + for (const auto* message : m_messages) { + QJsonObject messageObj; + messageObj[QStringLiteral("role")] = + message->role() == ChatMessage::Role::User + ? QStringLiteral("user") + : QStringLiteral("assistant"); + messageObj[QStringLiteral("content")] = message->content(); + if (!message->reasoning().isEmpty()) + messageObj[QStringLiteral("reasoning")] = message->reasoning(); + const qint64 reasoningMs = message->reasoningElapsedMs(); + if (reasoningMs > 0) + messageObj[QStringLiteral("reasoningElapsedMs")] = reasoningMs; + const qint64 contentMs = message->contentElapsedMs(); + if (contentMs > 0) + messageObj[QStringLiteral("contentElapsedMs")] = contentMs; + messageObj[QStringLiteral("timestamp")] = message->timestamp(); + arr.append(messageObj); + } + QJsonObject doc; + doc[QStringLiteral("id")] = m_id; + doc[QStringLiteral("title")] = m_title; + doc[QStringLiteral("createdAt")] = m_createdAt; + doc[QStringLiteral("updatedAt")] = m_updatedAt; + doc[QStringLiteral("messages")] = arr; + return doc; +} + +} // namespace ZShell diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp new file mode 100644 index 0000000..0de284a --- /dev/null +++ b/Plugins/ZShell/Llm/session.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include "message.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ZShell { + +class ChatSession : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat sessions are managed by Chat.chats") + + Q_PROPERTY(QString id READ id CONSTANT) + Q_PROPERTY(QString title READ title NOTIFY titleChanged) + Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT) + Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged) + Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) + Q_PROPERTY(QList messages READ messages NOTIFY messagesChanged) + + public: + explicit ChatSession(const QString& id, QObject* parent = nullptr); + + [[nodiscard]] QString id() const { return m_id; } + [[nodiscard]] QString title() const { return m_title; } + [[nodiscard]] QDateTime createdAt() const { + return QDateTime::fromMSecsSinceEpoch(m_createdAt); + } + [[nodiscard]] QDateTime updatedAt() const { + return QDateTime::fromMSecsSinceEpoch(m_updatedAt); + } + [[nodiscard]] qint64 createdAtMs() const { return m_createdAt; } + [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } + [[nodiscard]] int messageCount() const { return m_messageCount; } + [[nodiscard]] QList messages() { + ensureLoaded(); + return m_messages; + } + + void setTitle(const QString& value); + void setUpdatedAt(qint64 value); + + void setPath(const QString& value) { m_path = value; } + [[nodiscard]] QString path() const { return m_path; } + void setMeta( + const QString& title, + qint64 createdAt, + qint64 updatedAt, + int messageCount); + + void ensureLoaded(); + [[nodiscard]] bool isLoaded() const { return m_loaded; } + ChatMessage* appendMessage( + ChatMessage::Role role, const QString& content, qint64 timestamp); + void removeMessage(ChatMessage* message); + void clearMessages(); + [[nodiscard]] QJsonObject document(); + + Q_SIGNALS: + void titleChanged(); + void updatedAtChanged(); + void messageCountChanged(); + void messagesChanged(); + + private: + void loadMessages(); + void setCount(int value); + + QString m_id; + QString m_title; + qint64 m_createdAt = 0; + qint64 m_updatedAt = 0; + int m_messageCount = 0; + QString m_path; + QList m_messages; + bool m_loaded = false; +}; + +} // namespace ZShell From 9657e5092af5d766aa92d19ce11ad523739e37ab Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 20 Aug 2026 17:40:33 +0200 Subject: [PATCH 02/90] add removal of chat sessions, auto title + icon, proper stick-to-bottom as response is streamed --- .../Sidebar/Chat/ChatContent.qml | 113 ++++-- .../Sidebar/Chat/ChatDelegate.qml | 88 +++++ .../Notifications/Sidebar/Chat/ChatList.qml | 166 ++++++-- .../Notifications/Sidebar/Chat/ChatPanel.qml | 3 + Plugins/ZShell/Llm/CMakeLists.txt | 1 + Plugins/ZShell/Llm/chat.cpp | 166 +++++++- Plugins/ZShell/Llm/chat.hpp | 9 + Plugins/ZShell/Llm/chatstore.cpp | 357 +++++++++++------- Plugins/ZShell/Llm/chatstore.hpp | 15 +- Plugins/ZShell/Llm/message.cpp | 12 +- Plugins/ZShell/Llm/session.cpp | 102 ++--- Plugins/ZShell/Llm/session.hpp | 17 +- 12 files changed, 776 insertions(+), 273 deletions(-) create mode 100644 Modules/Notifications/Sidebar/Chat/ChatDelegate.qml diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index a859b96..8438147 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -17,10 +17,6 @@ Item { signal requestClose - function atBottom(): bool { - return list.contentHeight - list.height - list.contentY < 48; - } - function send(): void { if (Chat.busy || input.text.trim() === "") return; @@ -29,30 +25,62 @@ Item { input.text = ""; } + RowLayout { + id: header + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: Tokens.spacing.large + + IconButton { + icon: "arrow_back" + inactiveColor: Colors.tPalette.m3surfaceContainerHigh + inactiveOnColor: Colors.palette.m3onSurfaceVariant + isRound: true + type: IconButton.Tonal + + onClicked: root.requestClose() + } + + CustomText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pointSize: Tokens.font.size.larger + text: qsTr(root.chatData.title) + } + } + VerticalFadeListView { id: list + readonly property real bottomThreshold: Tokens.spacing.extraSmall + property bool stickToBottom: true + + function scrollToEnd(animated: bool): void { + const target = Math.max(0, list.contentHeight - list.height); + if (!animated) { + scrollAnim.stop(); + list.contentY = target; + return; + } + scrollAnim.to = target; + scrollAnim.restart(); + } + anchors.bottom: inputRow.top anchors.bottomMargin: Tokens.spacing.medium anchors.left: parent.left anchors.right: parent.right - anchors.top: parent.top + anchors.top: header.bottom cacheBuffer: height * 20 clip: true currentIndex: messages.values.length - 1 - fadeAmount: 0.1 - highlightFollowsCurrentItem: true - highlightRangeMode: ListView.ApplyRange - preferredHighlightEnd: list.height spacing: Tokens.spacing.medium CustomScrollBar.vertical: CustomScrollBar { flickable: list } - Behavior on contentY { - Anim { - } - } delegate: ColumnLayout { id: messageRow @@ -176,6 +204,7 @@ Item { anchors.top: parent.top color: messageRow.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface text: messageRow.modelData.content + textFormat: CustomText.MarkdownText wrapMode: Text.WordWrap } @@ -204,7 +233,23 @@ Item { values: root.chatData.messages } - Component.onCompleted: positionViewAtEnd() + Component.onCompleted: scrollToEnd(false) + onContentHeightChanged: { + if (list.stickToBottom) + list.scrollToEnd(false); + } + onMovementEnded: { + list.stickToBottom = (list.contentY >= list.contentHeight - list.height - list.bottomThreshold); + } + onMovementStarted: list.stickToBottom = false + + Anim { + id: scrollAnim + + property: "contentY" + target: list + type: Anim.DefaultEffects + } } ColumnLayout { @@ -236,6 +281,31 @@ Item { } } + IconButton { + id: scrollToBottom + + anchors.bottom: inputRow.top + anchors.bottomMargin: Tokens.spacing.extraLarge + anchors.horizontalCenter: parent.horizontalCenter + font.pointSize: Tokens.font.size.large + icon: "arrow_downward" + isRound: true + padding: Tokens.padding.extraSmall + scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 ? 1 : 0 + type: IconButton.Filled + visible: scale > 0 + + Behavior on scale { + Anim { + } + } + + onClicked: { + list.stickToBottom = true; + list.scrollToEnd(true); + } + } + RowLayout { id: inputRow @@ -258,23 +328,12 @@ Item { id: actionBtn enabled: Chat.busy || input.text.trim() !== "" - font.pointSize: 20 + font.pointSize: Tokens.font.size.normal * 2 icon: Chat.busy ? "stop" : "send" + isRound: true type: IconButton.Filled onClicked: Chat.busy ? Chat.stop() : root.send() } } - - IconButton { - anchors.left: parent.left - anchors.top: parent.top - icon: "arrow_back" - inactiveColor: Colors.tPalette.m3surfaceContainerHigh - inactiveOnColor: Colors.palette.m3onSurfaceVariant - isRound: true - type: IconButton.Tonal - - onClicked: root.requestClose() - } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml new file mode 100644 index 0000000..129958a --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml @@ -0,0 +1,88 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +CustomRect { + id: root + + property alias layout: layout + required property ChatSession modelData + + color: Colors.tPalette.m3surfaceContainer + implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin + + MaterialIcon { + id: chatIcon + + anchors.left: parent.left + anchors.leftMargin: Tokens.padding.large + anchors.verticalCenter: parent.verticalCenter + font.pointSize: Tokens.font.size.extraLarge + text: root.modelData.icon || "check" + } + + ColumnLayout { + id: layout + + anchors.bottom: parent.bottom + anchors.bottomMargin: Tokens.padding.small + anchors.left: chatIcon.right + anchors.leftMargin: Tokens.padding.medium + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.medium + anchors.top: parent.top + anchors.topMargin: Tokens.padding.small + spacing: Tokens.spacing.extraSmall + + CustomRect { + id: titleContainer + + readonly property bool enoughSpace: width > title.implicitWidth + timeSep.implicitWidth + timeSep.anchors.leftMargin + timestamp.implicitWidth + timestamp.anchors.leftMargin + + Layout.fillWidth: true + implicitHeight: enoughSpace ? title.implicitHeight : title.implicitHeight + timestamp.implicitHeight + timestamp.anchors.topMargin + + CustomText { + id: title + + text: root.modelData.title + } + + CustomText { + id: timeSep + + anchors.left: title.right + anchors.leftMargin: Tokens.spacing.small + anchors.verticalCenter: title.verticalCenter + color: Colors.palette.m3onSurfaceVariant + text: "•" + visible: titleContainer.enoughSpace + } + + CustomText { + id: timestamp + + anchors.left: titleContainer.enoughSpace ? timeSep.right : parent.left + anchors.leftMargin: titleContainer.enoughSpace ? Tokens.spacing.small : 0 + anchors.top: titleContainer.enoughSpace ? undefined : title.bottom + anchors.topMargin: Tokens.spacing.extraSmall + anchors.verticalCenter: titleContainer.enoughSpace ? title.verticalCenter : undefined + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.small + text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP") + } + } + + CustomText { + Layout.fillWidth: true + color: Colors.palette.m3outline + elide: Text.ElideRight + font.pointSize: Tokens.font.size.small + maximumLineCount: 1 + text: root.modelData.messages[root.modelData.messages.length - 1]?.content.replace(/\s+/g, " ").trim() ?? "" + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml index c98c385..20979fb 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatList.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -13,55 +13,165 @@ Item { property alias model: list.model + signal deleteChatRequest(content: ChatSession) signal loadChatRequest(content: ChatSession) signal newChatRequest + CustomText { + id: header + + anchors.left: parent.left + anchors.margins: Tokens.padding.extraSmall + anchors.right: parent.right + anchors.top: parent.top + color: Colors.palette.m3outline + font.family: "CaskaydiaCove NF" + font.pointSize: 13 + font.weight: 500 + text: qsTr("%1 Chat%2").arg(list.count).arg(list.count > 1 ? "s" : "") + } + CustomListView { id: list - anchors.fill: parent + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.top: header.bottom + anchors.topMargin: Tokens.spacing.medium cacheBuffer: height * 2 clip: true spacing: Tokens.spacing.medium - delegate: CustomRect { - id: chatItem + delegate: Component { + Item { + id: chatItem - required property var modelData + property bool closing: false + property bool editVisible: false + required property var modelData + property real startY: 0.0 - color: Colors.tPalette.m3surfaceContainer - implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin - implicitWidth: ListView.view.width - radius: Tokens.rounding.medium + anchors.fill: undefined + implicitHeight: chat.layout.implicitHeight + chat.layout.anchors.topMargin + chat.layout.anchors.bottomMargin + implicitWidth: ListView.view.width - ColumnLayout { - id: layout + onEditVisibleChanged: if (!editVisible) + chat.x = 0 - anchors.bottomMargin: Tokens.padding.small - anchors.fill: parent - anchors.leftMargin: Tokens.padding.medium - anchors.rightMargin: Tokens.padding.medium - anchors.topMargin: Tokens.padding.small - spacing: 0 + ParallelAnimation { + id: removeAnim - CustomText { - id: title + onFinished: root.deleteChatRequest(chatItem.modelData) + onStarted: chatItem.closing = true - text: chatItem.modelData.title + Anim { + property: "implicitHeight" + target: chatItem + to: 0 + } + + Anim { + property: "x" + target: chat + to: -chat.implicitWidth + } + + Anim { + property: "opacity" + target: chatItem + to: 0 + } } - CustomText { - id: timestamp + ChatDelegate { + id: chat - color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small - text: chatItem.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP") + implicitWidth: chatItem.implicitWidth + modelData: chatItem.modelData + radius: Tokens.rounding.medium + + Behavior on x { + Anim { + } + } + Behavior on y { + Anim { + } + } + + StateLayer { + cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.PointingHandCursor + drag.axis: Drag.XAxis + drag.target: chat + hoverEnabled: true + preventStealing: true + + onClicked: { + if (chatItem.editVisible) + chatItem.editVisible = false; + else + root.loadChatRequest(chatItem.modelData); + } + onPressed: event => { + chatItem.startY = event.y; + } + onReleased: event => { + if (chat.x > -(editTools.implicitWidth + Tokens.padding.extraSmall)) { + chat.x = 0; + } else { + chatItem.editVisible = true; + chat.x = -(editTools.implicitWidth + Tokens.spacing.medium + Tokens.padding.extraSmall); + } + } + } } - } - StateLayer { - onClicked: { - root.loadChatRequest(chatItem.modelData); + CustomClippingRect { + anchors.left: chat.right + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.extraSmall + anchors.verticalCenter: chat.verticalCenter + implicitHeight: editTools.implicitHeight + + RowLayout { + id: editTools + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + IconButton { + id: deleteChatBtn + + font.pointSize: Tokens.font.size.large + icon: "close" + radius: Tokens.rounding.full + + transform: [ + Scale { + property bool shouldBeActive: (chat.x < -deleteChatBtn.implicitWidth) && !chatItem.closing + + origin.x: deleteChatBtn.implicitWidth / 2 + origin.y: deleteChatBtn.implicitWidth / 2 + xScale: shouldBeActive ? 1 : 0 + yScale: shouldBeActive ? 1 : 0 + + Behavior on xScale { + Anim { + } + } + Behavior on yScale { + Anim { + } + } + } + ] + + onClicked: { + removeAnim.start(); + } + } + } } } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml index 57674de..bc87a94 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml @@ -42,6 +42,9 @@ Item { values: Chat.chats.values } + onDeleteChatRequest: chat => { + Chat.chats.remove(chat); + } onLoadChatRequest: chat => { stack.push(chatContent, { "chatData": chat diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt index 9474ceb..be25202 100644 --- a/Plugins/ZShell/Llm/CMakeLists.txt +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -7,6 +7,7 @@ qml_module(ZShell-llm chatstore.hpp chatstore.cpp LIBRARIES Qt::Network + Qt::Sql ZShell-config ) diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index f053599..98e429e 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -140,8 +140,15 @@ void Chat::send(const QString& chatId, const QString& content) { QDateTime::currentMSecsSinceEpoch()); while (session->messageCount() > 200) session->removeMessage(session->messages().first()); - if (session->title().isEmpty()) + if (session->title().isEmpty()) { session->setTitle(titleFrom(text)); + qInfo() << "Chat: new session" << chatId + << "fallback title" << session->title() + << "- requesting generated title and icon, model" << m_model + << "endpoint" << m_endpoint; + requestTitle(chatId, text); + requestIcon(chatId, text); + } if (m_contextSize > 0 && m_lastTokenCount > m_contextSize * 4 / 5) trimHistory(session, m_contextSize); @@ -226,6 +233,162 @@ void Chat::beginAssistant() { }); } +void Chat::shortRequest( + const QString& tag, + const QString& systemPrompt, + const QString& userText, + std::function onResult) { + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); + if (!url.isValid() || url.host().isEmpty()) + return; + + qInfo() << "Chat:" << tag << "request POST" << url.toString() + << "model=" << m_model; + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + + QJsonArray messages; + QJsonObject system; + system[QStringLiteral("role")] = QStringLiteral("system"); + system[QStringLiteral("content")] = systemPrompt; + messages.append(system); + QJsonObject user; + user[QStringLiteral("role")] = QStringLiteral("user"); + user[QStringLiteral("content")] = userText.simplified().mid(0, 512); + messages.append(user); + + QJsonObject body; + if (!m_model.isEmpty()) + body[QStringLiteral("model")] = m_model; + body[QStringLiteral("stream")] = false; + body[QStringLiteral("temperature")] = 0.3; + body[QStringLiteral("max_tokens")] = 128; + QJsonObject templateKwargs; + templateKwargs[QStringLiteral("enable_thinking")] = false; + body[QStringLiteral("chat_template_kwargs")] = templateKwargs; + body[QStringLiteral("messages")] = messages; + + auto* reply = m_manager.post(request, QJsonDocument(body).toJson()); + connect( + reply, + &QNetworkReply::finished, + this, + [this, reply, tag, onResult = std::move(onResult)]() { + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + qInfo() << "Chat:" << tag << "request finished" + << "error=" << reply->error() << reply->errorString() + << "http=" << reply->attribute( + QNetworkRequest::HttpStatusCodeAttribute).toInt() + << "response=" + << QString::fromUtf8(data.left(400)).simplified(); + + if (reply->error() != QNetworkReply::NoError) + return; + + const QJsonDocument doc = QJsonDocument::fromJson(data); + const QJsonArray choices = + doc.object()[QStringLiteral("choices")].toArray(); + if (choices.isEmpty()) + return; + const QString result = + choices.at(0).toObject()[QStringLiteral("message")].toObject() + [QStringLiteral("content")].toString() + .trimmed(); + qInfo() << "Chat:" << tag << "raw result" << result; + onResult(result); + }); +} + +void Chat::requestTitle(const QString& chatId, const QString& userText) { + shortRequest( + QStringLiteral("title"), + QStringLiteral( + "Write a short, concise title for a chat conversation starting " + "with the user's message below. At most six words, no quotation " + "marks, no trailing punctuation. Reply with the title only."), + userText, + [this, chatId](QString title) { + ChatSession* session = m_store->sessionById(chatId); + if (!session) { + qWarning() << "Chat: title request: session gone" << chatId; + return; + } + const auto isQuote = [](QChar c) { + return c == QLatin1Char('"') || c == QLatin1Char('\'') || + c == QChar(u'\u201C') || c == QChar(u'\u201D') || + c == QChar(u'\u2018') || c == QChar(u'\u2019'); + }; + while (title.size() >= 2 && isQuote(title.at(0)) && + isQuote(title.at(title.size() - 1))) + title = title.mid(1, title.size() - 2).simplified(); + while (!title.isEmpty() && + (title.endsWith(QLatin1Char('.')) || + title.endsWith(QLatin1Char('!')) || + title.endsWith(QLatin1Char('?')))) + title.chop(1); + if (title.size() < 2) { + qWarning() << "Chat: title rejected (too short)" << chatId + << title; + return; + } + if (title.size() > 48) + title = title.left(47) + QStringLiteral("…"); + qInfo() << "Chat: applying generated title" << chatId << title + << "(was" << session->title() << ")"; + session->setTitle(title); + m_store->saveMeta(session); + }); +} + +void Chat::requestIcon(const QString& chatId, const QString& userText) { + const QStringList icons = { + "chat", "lightbulb", "code", + "description", "article", "school", + "work", "build", "science", + "palette", "music_note", "sports_esports", + "takeout_dining", "flight", "photo_camera", + "psychology_alt", "favorite", "savings", + "gamepad", "auto_awesome", + }; + const QString prompt = + QStringLiteral( + "Pick the single icon name from this list that best matches the " + "topic of the user's message below: %1. Reply with only the icon " + "name, exactly as written in the list, and nothing else.") + .arg(icons.join(QStringLiteral(", "))); + shortRequest( + QStringLiteral("icon"), + prompt, + userText, + [this, chatId, icons](QString name) { + ChatSession* session = m_store->sessionById(chatId); + if (!session) { + qWarning() << "Chat: icon request: session gone" << chatId; + return; + } + name = name.simplified().toLower(); + while (name.size() >= 2 && + (name.at(0) == QLatin1Char('"') || + name.at(0) == QLatin1Char('\''))) + name = name.mid(1).left(name.size() - 2).simplified(); + name.replace(QLatin1Char(' '), QLatin1Char('_')); + if (!icons.contains(name)) { + qWarning() << "Chat: icon not in list, using default" << chatId + << name; + name = QStringLiteral("chat"); + } + qInfo() << "Chat: applying generated icon" << chatId << name + << "(was" << session->icon() << ")"; + session->setIcon(name); + m_store->saveMeta(session); + }); +} + void Chat::stop() { if (!m_busy) return; @@ -275,6 +438,7 @@ void Chat::endStream() { if (m_streaming->content().isEmpty() && m_streaming->reasoning().isEmpty() && m_active) m_active->removeMessage(m_streaming); m_streaming = nullptr; + m_active = nullptr; setBusy(this, false); setStreamingChatId(this, QString()); } diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp index 8ba0944..3270668 100644 --- a/Plugins/ZShell/Llm/chat.hpp +++ b/Plugins/ZShell/Llm/chat.hpp @@ -8,6 +8,8 @@ #include #include +#include + #include "chatstore.hpp" class QQmlEngine; @@ -76,6 +78,13 @@ class Chat : public QObject { void updateTokenUsage(const QJsonObject& data); static void trimHistory(ChatSession* session, int contextSize); + void shortRequest( + const QString& tag, + const QString& systemPrompt, + const QString& userText, + std::function onResult); + void requestTitle(const QString& chatId, const QString& userText); + void requestIcon(const QString& chatId, const QString& userText); static QString titleFrom(const QString& content); static QString completionsPath(const QString& endpoint, const QString& subpath); static QString serverErrorMessage( diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp index 4dfb055..7990323 100644 --- a/Plugins/ZShell/Llm/chatstore.cpp +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -1,54 +1,104 @@ #include "chatstore.hpp" #include "chat.hpp" +#include "message.hpp" #include #include #include -#include #include -#include -#include -#include -#include -#include #include +#include +#include +#include +#include #include namespace ZShell { -ChatStore::ChatStore(QObject* parent) : QObject(parent) { +ChatStore::ChatStore(QObject* parent) +: QObject(parent), m_connectionName(QUuid::createUuid().toString()) { + openDb(); load(); } -QString ChatStore::dir() { - const QString base = - QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + - QStringLiteral("/zshell"); - return base + QStringLiteral("/chats"); +ChatStore::~ChatStore() { + if (m_connectionName.isEmpty()) + return; + QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); + db.close(); + QSqlDatabase::removeDatabase(m_connectionName); } -QString ChatStore::pathFor(const QString& id) { - return dir() + QStringLiteral("/") + id + QStringLiteral(".json"); +QSqlDatabase ChatStore::db() const { + return QSqlDatabase::database(m_connectionName); } -bool ChatStore::writeFile(const QString& path, const QJsonObject& doc) { +void ChatStore::openDb() { + const QString path = + QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation) + + QStringLiteral("/zshell/chats.sqlite"); QDir().mkpath(QFileInfo(path).absolutePath()); - QSaveFile file(path); - if (!file.open(QIODevice::WriteOnly)) { - qWarning() << "ChatStore: failed to open" << path << "for writing:" - << file.errorString(); - return false; + QSqlDatabase db = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); + db.setDatabaseName(path); + if (!db.open()) { + qWarning() << "ChatStore: failed to open database" << path << ":" + << db.lastError().text(); + return; } - if (file.write(QJsonDocument(doc).toJson(QJsonDocument::Indented)) < 0 || - !file.commit()) { - qWarning() << "ChatStore: failed to write" << path << ":" - << file.errorString(); - return false; + { + QSqlQuery pragma(db); + pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON")); + } + { + QSqlQuery query(db); + query.exec( + QStringLiteral( + "CREATE TABLE IF NOT EXISTS sessions (\n" + " id TEXT PRIMARY KEY,\n" + " title TEXT NOT NULL DEFAULT '',\n" + " icon TEXT NOT NULL DEFAULT '',\n" + " created_at INTEGER NOT NULL,\n" + " updated_at INTEGER NOT NULL,\n" + " pinned INTEGER NOT NULL DEFAULT 0\n" + ")")); + } + { + QSqlQuery info(db); + bool hasIcon = false; + if (info.exec(QStringLiteral("PRAGMA table_info(sessions)"))) + while (info.next()) + if (info.value(1).toString() == QLatin1String("icon")) { + hasIcon = true; + break; + } + if (!hasIcon) { + QSqlQuery alter(db); + if (!alter.exec(QStringLiteral( + "ALTER TABLE sessions ADD COLUMN icon TEXT NOT NULL " + "DEFAULT ''"))) + qWarning() << "ChatStore: failed to add icon column:" + << alter.lastError().text(); + } + } + { + QSqlQuery query(db); + query.exec( + QStringLiteral( + "CREATE TABLE IF NOT EXISTS messages (\n" + " session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE " + "CASCADE,\n" + " role TEXT NOT NULL,\n" + " content TEXT,\n" + " reasoning TEXT,\n" + " timestamp INTEGER NOT NULL,\n" + " reasoning_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" + " content_elapsed_ms INTEGER NOT NULL DEFAULT 0\n" + ")")); } - return true; } int ChatStore::count() const { @@ -71,8 +121,20 @@ ChatSession* ChatStore::at(int index) const { ChatSession* ChatStore::insert(int index) { const qint64 now = QDateTime::currentMSecsSinceEpoch(); - auto* session = new ChatSession(QString::number(now), this); - session->setPath(pathFor(session->id())); + const QString id = QString::number(now); + { + QSqlQuery query(db()); + query.prepare( + "INSERT INTO sessions (id, title, created_at, updated_at) " + "VALUES (:id, '', :created_at, :updated_at)"); + query.bindValue(":id", id); + query.bindValue(":created_at", now); + query.bindValue(":updated_at", now); + if (!query.exec()) + qWarning() << "ChatStore: failed to insert session" << id << ":" + << query.lastError().text(); + } + auto* session = new ChatSession(id, this); session->setMeta(QString(), now, now, 0); const int pos = index >= 0 && index <= m_sessions.size() ? index : 0; @@ -93,15 +155,18 @@ void ChatStore::remove(ChatSession* chat) { void ChatStore::removeSession(ChatSession* session) { if (!session || !m_sessions.contains(session)) return; - if (isStreaming(session)) { - qWarning() << "ChatStore: cannot remove a chat that is streaming"; - return; - } - const QList before = m_sessions; - QFile::remove(session->path()); if (auto* chat = qobject_cast(parent())) - if (chat->m_active == session) - chat->m_active = nullptr; + if (chat->m_active == session) { + chat->stop(); + chat->endStream(); + } + const QList before = m_sessions; + { + QSqlQuery query(db()); + query.prepare("DELETE FROM sessions WHERE id = :id"); + query.bindValue(":id", session->id()); + query.exec(); + } m_sessions.removeOne(session); session->deleteLater(); notify(before); @@ -116,18 +181,9 @@ void ChatStore::move(int from, int to) { } void ChatStore::clear() { - const QList before = m_sessions; - QList keep; - for (ChatSession* session : std::as_const(m_sessions)) { - if (isStreaming(session)) { - keep.append(session); - continue; - } - QFile::remove(session->path()); - session->deleteLater(); - } - m_sessions = keep; - notify(before); + const QList sessions = m_sessions; + for (ChatSession* session : sessions) + removeSession(session); } ChatSession* ChatStore::sessionById(const QString& id) { @@ -141,14 +197,134 @@ void ChatStore::persist(ChatSession* session) { if (!session || !m_sessions.contains(session)) return; session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); - if (!writeFile(session->path(), session->document())) + session->ensureLoaded(); + if (!saveSession(session)) return; sortAndNotify(); } -bool ChatStore::isStreaming(ChatSession* session) const { - const auto* chat = qobject_cast(parent()); - return chat && chat->streamingSession() == session; +void ChatStore::saveMeta(ChatSession* session) { + if (!session || !m_sessions.contains(session)) + return; + QSqlQuery query(db()); + query.prepare("UPDATE sessions SET title = :title, icon = :icon " + "WHERE id = :id"); + query.bindValue(":title", session->title()); + query.bindValue(":icon", session->icon()); + query.bindValue(":id", session->id()); + if (!query.exec()) + qWarning() << "ChatStore: failed to save meta for" << session->id() + << ":" << query.lastError().text(); +} + +bool ChatStore::saveSession(ChatSession* session) { + const QString id = session->id(); + QSqlDatabase handle = db(); + if (!handle.transaction()) { + qWarning() << "ChatStore: failed to begin transaction:" + << handle.lastError().text(); + return false; + } + bool ok = true; + { + QSqlQuery query(handle); + query.prepare( + "UPDATE sessions SET title = :title, updated_at = :updated_at " + "WHERE id = :id"); + query.bindValue(":title", session->title()); + query.bindValue(":updated_at", session->updatedAtMs()); + query.bindValue(":id", session->id()); + ok = query.exec(); + } + if (ok) { + QSqlQuery query(handle); + query.prepare("DELETE FROM messages WHERE session_id = :id"); + query.bindValue(":id", session->id()); + ok = query.exec(); + } + if (ok) { + QSqlQuery insert(handle); + ok = insert.prepare( + "INSERT INTO messages (session_id, role, content, reasoning, " + "timestamp, reasoning_elapsed_ms, content_elapsed_ms) " + "VALUES (:id, :role, :content, :reasoning, :timestamp, " + ":reasoning_elapsed_ms, :content_elapsed_ms)"); + for (const auto* message : session->messages()) { + insert.bindValue(":id", session->id()); + insert.bindValue( + ":role", + message->role() == ChatMessage::Role::User + ? QStringLiteral("user") + : QStringLiteral("assistant")); + insert.bindValue(":content", message->content()); + insert.bindValue(":reasoning", message->reasoning()); + insert.bindValue(":timestamp", message->timestamp()); + insert.bindValue(":reasoning_elapsed_ms", message->reasoningElapsedMs()); + insert.bindValue(":content_elapsed_ms", message->contentElapsedMs()); + if (!insert.exec()) { + ok = false; + qWarning() << "ChatStore: saveSession" << id << "insert failed:" + << insert.lastError().text(); + break; + } + } + } + if (!ok || !handle.commit()) { + qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back"; + handle.rollback(); + ok = false; + qWarning() << "ChatStore: failed to save session" << session->id() << ":" + << handle.lastError().text(); + } + return ok; +} + +void ChatStore::loadMessagesInto(ChatSession* session) { + QSqlQuery query(db()); + query.prepare( + "SELECT role, content, reasoning, timestamp, reasoning_elapsed_ms, " + "content_elapsed_ms FROM messages WHERE session_id = :id ORDER BY rowid"); + query.bindValue(":id", session->id()); + if (!query.exec()) { + qWarning() << "ChatStore: failed to load messages for" << session->id() + << ":" << query.lastError().text(); + return; + } + QList messages; + while (query.next()) { + auto* message = new ChatMessage( + query.value(0).toString() == QLatin1String("user") + ? ChatMessage::Role::User + : ChatMessage::Role::Assistant, + query.value(1).toString(), + query.value(3).toLongLong(), + session); + message->setReasoning(query.value(2).toString()); + message->setElapsedMs( + query.value(4).toLongLong(), query.value(5).toLongLong()); + messages.append(message); + } + session->adoptMessages(messages); +} + +void ChatStore::load() { + QSqlQuery query(db()); + query.exec( + "SELECT s.id, s.title, s.icon, s.created_at, s.updated_at, s.pinned, " + "(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS cnt " + "FROM sessions s ORDER BY s.pinned DESC, s.updated_at DESC"); + while (query.next()) { + auto* session = new ChatSession(query.value(0).toString(), this); + session->setMeta( + query.value(1).toString(), + query.value(3).toLongLong(), + query.value(4).toLongLong(), + query.value(6).toInt()); + session->setIcon(query.value(2).toString()); + session->setPinned(query.value(5).toInt() != 0); + m_sessions.append(session); + } + sortAndNotify(); } void ChatStore::sortAndNotify() { @@ -157,6 +333,8 @@ void ChatStore::sortAndNotify() { m_sessions.begin(), m_sessions.end(), [](const ChatSession* a, const ChatSession* b) { + if (a->pinned() != b->pinned()) + return a->pinned() > b->pinned(); return a->updatedAtMs() > b->updatedAtMs(); }); notify(before); @@ -173,79 +351,4 @@ void ChatStore::notify(const QList& before) { Q_EMIT valuesChanged(); } -void ChatStore::load() { - const QFileInfoList files = QDir(dir()).entryInfoList( - QStringList{QStringLiteral("*.json")}, - QDir::Files, - QDir::Time); - for (const QFileInfo& info : files) { - QFile file(info.absoluteFilePath()); - if (!file.open(QIODevice::ReadOnly)) - continue; - const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - file.close(); - if (!doc.isObject()) - continue; - const QJsonObject obj = doc.object(); - auto* session = - new ChatSession(obj["id"].toString(info.completeBaseName()), this); - session->setPath(info.absoluteFilePath()); - session->setMeta( - obj["title"].toString(), - obj["createdAt"].toVariant().toLongLong(), - obj["updatedAt"].toVariant().toLongLong(), - int(obj["messages"].toArray().size())); - m_sessions.append(session); - } - sortAndNotify(); - migrateLegacy(); -} - -void ChatStore::migrateLegacy() { - const QString path = dir() + QStringLiteral("/chat.json"); - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) - return; - const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - file.close(); - const QJsonArray arr = doc.isArray() ? doc.array() : QJsonArray(); - - QString title; - qint64 first = 0; - qint64 last = 0; - QJsonArray messages; - for (const QJsonValue& value : arr) { - const QJsonObject msg = value.toObject(); - const QString content = msg["content"].toString(); - if (content.isEmpty()) - continue; - if (first == 0) - first = msg["timestamp"].toVariant().toLongLong(); - last = msg["timestamp"].toVariant().toLongLong(); - if (title.isEmpty() && msg["role"].toString() == "user") - title = Chat::titleFrom(content); - messages.append(msg); - } - if (messages.isEmpty()) - return; - - const QString id = QString::number(QDateTime::currentMSecsSinceEpoch()); - auto* session = new ChatSession(id, this); - session->setPath(pathFor(id)); - - QJsonObject out; - out[QStringLiteral("id")] = id; - out[QStringLiteral("title")] = title; - out[QStringLiteral("createdAt")] = first; - out[QStringLiteral("updatedAt")] = last; - out[QStringLiteral("messages")] = messages; - if (!writeFile(session->path(), out)) - return; - QFile::remove(path); - - session->setMeta(title, first, last, int(messages.size())); - m_sessions.append(session); - sortAndNotify(); -} - } // namespace ZShell diff --git a/Plugins/ZShell/Llm/chatstore.hpp b/Plugins/ZShell/Llm/chatstore.hpp index 3f6d299..8526216 100644 --- a/Plugins/ZShell/Llm/chatstore.hpp +++ b/Plugins/ZShell/Llm/chatstore.hpp @@ -2,8 +2,9 @@ #include "session.hpp" -#include #include +#include +#include #include namespace ZShell { @@ -19,6 +20,7 @@ class ChatStore : public QObject { public: explicit ChatStore(QObject* parent = nullptr); + ~ChatStore(); [[nodiscard]] int count() const; [[nodiscard]] QVariantList values() const; @@ -32,24 +34,25 @@ class ChatStore : public QObject { [[nodiscard]] ChatSession* sessionById(const QString& id); void persist(ChatSession* session); + void saveMeta(ChatSession* session); + void loadMessagesInto(ChatSession* session); Q_SIGNALS: void countChanged(); void valuesChanged(); private: + void openDb(); void load(); - void migrateLegacy(); + bool saveSession(ChatSession* session); void sortAndNotify(); void removeSession(ChatSession* session); - bool isStreaming(ChatSession* session) const; void notify(const QList& before); QList m_sessions; + QString m_connectionName; - static QString dir(); - static QString pathFor(const QString& id); - static bool writeFile(const QString& path, const QJsonObject& doc); + [[nodiscard]] QSqlDatabase db() const; friend class Chat; }; diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp index 4153587..4f41aee 100644 --- a/Plugins/ZShell/Llm/message.cpp +++ b/Plugins/ZShell/Llm/message.cpp @@ -41,7 +41,7 @@ qint64 ChatMessage::contentElapsedMs() const { } void ChatMessage::updateReasoningActive() { - const bool active = m_streaming && !m_reasoning.isEmpty() && m_content.isEmpty(); + const bool active = m_streaming && m_content.isEmpty(); if (m_reasoningActive == active) return; m_reasoningActive = active; @@ -69,7 +69,8 @@ void ChatMessage::appendReasoning(const QString& piece) { if (piece.isEmpty()) return; if (m_reasoning.isEmpty()) { - m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); + if (m_reasoningStartedAt <= 0) + m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); if (!m_timer.isActive()) m_timer.start(); } @@ -102,7 +103,12 @@ void ChatMessage::setStreaming(bool value) { return; m_streaming = value; Q_EMIT streamingChanged(); - if (!value) { + if (value) { + if (m_reasoningStartedAt <= 0) + m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); + if (!m_timer.isActive()) + m_timer.start(); + } else { const qint64 now = QDateTime::currentMSecsSinceEpoch(); if (reasoningInFlight()) m_reasoningEndedAt = now; diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp index 4e7d4a1..7986108 100644 --- a/Plugins/ZShell/Llm/session.cpp +++ b/Plugins/ZShell/Llm/session.cpp @@ -1,16 +1,14 @@ #include "session.hpp" +#include "chatstore.hpp" + #include -#include -#include -#include -#include +#include namespace ZShell { ChatSession::ChatSession(const QString& id, QObject* parent) -: QObject(parent), m_id(id) { -} +: QObject(parent), m_id(id) {} void ChatSession::setTitle(const QString& value) { if (m_title == value) @@ -19,6 +17,13 @@ void ChatSession::setTitle(const QString& value) { Q_EMIT titleChanged(); } +void ChatSession::setIcon(const QString& value) { + if (m_icon == value) + return; + m_icon = value; + Q_EMIT iconChanged(); +} + void ChatSession::setUpdatedAt(qint64 value) { if (m_updatedAt == value) return; @@ -26,6 +31,13 @@ void ChatSession::setUpdatedAt(qint64 value) { Q_EMIT updatedAtChanged(); } +void ChatSession::setPinned(bool value) { + if (m_pinned == value) + return; + m_pinned = value; + Q_EMIT pinnedChanged(); +} + void ChatSession::setCount(int value) { if (m_messageCount == value) return; @@ -45,47 +57,17 @@ void ChatSession::setMeta( } void ChatSession::ensureLoaded() { - if (m_loaded || m_path.isEmpty()) + if (m_loaded) return; m_loaded = true; - loadMessages(); + if (auto* store = qobject_cast(parent())) + store->loadMessagesInto(this); } -void ChatSession::loadMessages() { - QFile file(m_path); - if (file.open(QIODevice::ReadOnly)) { - const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - file.close(); - if (doc.isObject()) { - const QJsonObject obj = doc.object(); - m_title = obj["title"].toString(m_title); - m_createdAt = obj["createdAt"].toVariant().toLongLong() || m_createdAt; - m_updatedAt = obj["updatedAt"].toVariant().toLongLong() || m_updatedAt; - for (const QJsonValue& value : obj["messages"].toArray()) { - const QJsonObject msg = value.toObject(); - const QString content = msg["content"].toString(); - const QString reasoning = msg["reasoning"].toString(); - if (content.isEmpty() && reasoning.isEmpty()) - continue; - auto* message = new ChatMessage( - msg["role"].toString() == "user" ? ChatMessage::Role::User - : ChatMessage::Role::Assistant, - content, - msg["timestamp"].toVariant().toLongLong(), - this); - message->setReasoning(reasoning); - message->setElapsedMs( - msg["reasoningElapsedMs"].toVariant().toLongLong(), - msg["contentElapsedMs"].toVariant().toLongLong()); - m_messages.append(message); - } - } - } else { - qWarning() << "ChatSession: failed to load" << m_path << ":" - << file.errorString(); - } - setCount(int(m_messages.size())); - Q_EMIT titleChanged(); +void ChatSession::adoptMessages(QList messages) { + qDeleteAll(m_messages); + m_messages = messages; + setCount(m_messages.size()); Q_EMIT messagesChanged(); } @@ -94,7 +76,7 @@ ChatMessage* ChatSession::appendMessage( ensureLoaded(); auto* message = new ChatMessage(role, content, timestamp, this); m_messages.append(message); - setCount(int(m_messages.size())); + setCount(m_messages.size()); Q_EMIT messagesChanged(); return message; } @@ -103,7 +85,7 @@ void ChatSession::removeMessage(ChatMessage* message) { if (!message || !m_messages.removeOne(message)) return; delete message; - setCount(int(m_messages.size())); + setCount(m_messages.size()); Q_EMIT messagesChanged(); } @@ -117,34 +99,4 @@ void ChatSession::clearMessages() { Q_EMIT messagesChanged(); } -QJsonObject ChatSession::document() { - ensureLoaded(); - QJsonArray arr; - for (const auto* message : m_messages) { - QJsonObject messageObj; - messageObj[QStringLiteral("role")] = - message->role() == ChatMessage::Role::User - ? QStringLiteral("user") - : QStringLiteral("assistant"); - messageObj[QStringLiteral("content")] = message->content(); - if (!message->reasoning().isEmpty()) - messageObj[QStringLiteral("reasoning")] = message->reasoning(); - const qint64 reasoningMs = message->reasoningElapsedMs(); - if (reasoningMs > 0) - messageObj[QStringLiteral("reasoningElapsedMs")] = reasoningMs; - const qint64 contentMs = message->contentElapsedMs(); - if (contentMs > 0) - messageObj[QStringLiteral("contentElapsedMs")] = contentMs; - messageObj[QStringLiteral("timestamp")] = message->timestamp(); - arr.append(messageObj); - } - QJsonObject doc; - doc[QStringLiteral("id")] = m_id; - doc[QStringLiteral("title")] = m_title; - doc[QStringLiteral("createdAt")] = m_createdAt; - doc[QStringLiteral("updatedAt")] = m_updatedAt; - doc[QStringLiteral("messages")] = arr; - return doc; -} - } // namespace ZShell diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp index 0de284a..1180374 100644 --- a/Plugins/ZShell/Llm/session.hpp +++ b/Plugins/ZShell/Llm/session.hpp @@ -3,7 +3,6 @@ #include "message.hpp" #include -#include #include #include #include @@ -18,8 +17,10 @@ class ChatSession : public QObject { Q_PROPERTY(QString id READ id CONSTANT) Q_PROPERTY(QString title READ title NOTIFY titleChanged) + Q_PROPERTY(QString icon READ icon NOTIFY iconChanged) Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT) Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged) + Q_PROPERTY(bool pinned READ pinned NOTIFY pinnedChanged) Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) Q_PROPERTY(QList messages READ messages NOTIFY messagesChanged) @@ -28,6 +29,7 @@ class ChatSession : public QObject { [[nodiscard]] QString id() const { return m_id; } [[nodiscard]] QString title() const { return m_title; } + [[nodiscard]] QString icon() const { return m_icon; } [[nodiscard]] QDateTime createdAt() const { return QDateTime::fromMSecsSinceEpoch(m_createdAt); } @@ -36,6 +38,7 @@ class ChatSession : public QObject { } [[nodiscard]] qint64 createdAtMs() const { return m_createdAt; } [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } + [[nodiscard]] bool pinned() const { return m_pinned; } [[nodiscard]] int messageCount() const { return m_messageCount; } [[nodiscard]] QList messages() { ensureLoaded(); @@ -43,10 +46,10 @@ class ChatSession : public QObject { } void setTitle(const QString& value); + void setIcon(const QString& value); void setUpdatedAt(qint64 value); + void setPinned(bool value); - void setPath(const QString& value) { m_path = value; } - [[nodiscard]] QString path() const { return m_path; } void setMeta( const QString& title, qint64 createdAt, @@ -55,28 +58,30 @@ class ChatSession : public QObject { void ensureLoaded(); [[nodiscard]] bool isLoaded() const { return m_loaded; } + void adoptMessages(QList messages); ChatMessage* appendMessage( ChatMessage::Role role, const QString& content, qint64 timestamp); void removeMessage(ChatMessage* message); void clearMessages(); - [[nodiscard]] QJsonObject document(); Q_SIGNALS: void titleChanged(); + void iconChanged(); void updatedAtChanged(); + void pinnedChanged(); void messageCountChanged(); void messagesChanged(); private: - void loadMessages(); void setCount(int value); QString m_id; QString m_title; + QString m_icon; qint64 m_createdAt = 0; qint64 m_updatedAt = 0; + bool m_pinned = false; int m_messageCount = 0; - QString m_path; QList m_messages; bool m_loaded = false; }; From 37c6a9986ed3c509469b258b2a6410b47cc136aa Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 20 Aug 2026 20:24:54 +0200 Subject: [PATCH 03/90] start initial work on detaching chat to floating window --- .../Sidebar/Chat/ChatContent.qml | 6 +- .../Sidebar/Chat/ChatDelegate.qml | 9 ++- .../Notifications/Sidebar/Chat/ChatList.qml | 8 ++- .../Notifications/Sidebar/Chat/ChatPanel.qml | 60 +++++++++++++------ .../Notifications/Sidebar/Chat/ChatState.qml | 14 +++++ Modules/Notifications/Sidebar/Chat/Detach.qml | 51 ++++++++++++++++ .../Sidebar/Chat/SidebarView.qml | 57 ++++++++++++++++++ Modules/Notifications/Sidebar/Content.qml | 12 +++- Modules/Notifications/Sidebar/Props.qml | 2 - 9 files changed, 190 insertions(+), 29 deletions(-) create mode 100644 Modules/Notifications/Sidebar/Chat/ChatState.qml create mode 100644 Modules/Notifications/Sidebar/Chat/Detach.qml create mode 100644 Modules/Notifications/Sidebar/Chat/SidebarView.qml diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 8438147..637e273 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -11,7 +11,7 @@ import qs.Services Item { id: root - required property ChatSession chatData + property ChatSession chatData property bool following: true readonly property int messageCount: chatData.messages.length @@ -257,7 +257,7 @@ Item { anchors.fill: parent spacing: Tokens.spacing.small - visible: root.messageCount === 0 && !Chat.busy + visible: (root.messageCount === 0 && !Chat.busy) || !root.chatData Item { Layout.fillHeight: true @@ -273,7 +273,7 @@ Item { CustomText { Layout.alignment: Qt.AlignHCenter color: Colors.tPalette.m3onSurfaceVariant - text: qsTr("No messages yet") + text: !root.chatData ? qsTr("Open a previous chat or start a new one") : qsTr("No messages yet") } Item { diff --git a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml index 129958a..b56685f 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml @@ -8,6 +8,7 @@ import qs.Services CustomRect { id: root + required property Item chatItem property alias layout: layout required property ChatSession modelData @@ -20,6 +21,7 @@ CustomRect { anchors.left: parent.left anchors.leftMargin: Tokens.padding.large anchors.verticalCenter: parent.verticalCenter + color: root.chatItem.ListView.isCurrentItem ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface font.pointSize: Tokens.font.size.extraLarge text: root.modelData.icon || "check" } @@ -48,6 +50,7 @@ CustomRect { CustomText { id: title + color: root.chatItem.ListView.isCurrentItem ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface text: root.modelData.title } @@ -57,7 +60,7 @@ CustomRect { anchors.left: title.right anchors.leftMargin: Tokens.spacing.small anchors.verticalCenter: title.verticalCenter - color: Colors.palette.m3onSurfaceVariant + color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant text: "•" visible: titleContainer.enoughSpace } @@ -70,7 +73,7 @@ CustomRect { anchors.top: titleContainer.enoughSpace ? undefined : title.bottom anchors.topMargin: Tokens.spacing.extraSmall anchors.verticalCenter: titleContainer.enoughSpace ? title.verticalCenter : undefined - color: Colors.palette.m3onSurfaceVariant + color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant font.pointSize: Tokens.font.size.small text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP") } @@ -78,7 +81,7 @@ CustomRect { CustomText { Layout.fillWidth: true - color: Colors.palette.m3outline + color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant elide: Text.ElideRight font.pointSize: Tokens.font.size.small maximumLineCount: 1 diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml index 20979fb..160936b 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatList.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -11,10 +11,11 @@ import qs.Components Item { id: root + property alias list: list property alias model: list.model signal deleteChatRequest(content: ChatSession) - signal loadChatRequest(content: ChatSession) + signal loadChatRequest(content: ChatSession, index: int) signal newChatRequest CustomText { @@ -47,8 +48,10 @@ Item { Item { id: chatItem + property alias chat: chat property bool closing: false property bool editVisible: false + required property int index required property var modelData property real startY: 0.0 @@ -87,6 +90,7 @@ Item { ChatDelegate { id: chat + chatItem: chatItem implicitWidth: chatItem.implicitWidth modelData: chatItem.modelData radius: Tokens.rounding.medium @@ -111,7 +115,7 @@ Item { if (chatItem.editVisible) chatItem.editVisible = false; else - root.loadChatRequest(chatItem.modelData); + root.loadChatRequest(chatItem.modelData, chatItem.index); } onPressed: event => { chatItem.startY = event.y; diff --git a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml index bc87a94..606e65e 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml @@ -5,6 +5,7 @@ import ZShell.Llm import QtQuick import QtQuick.Controls import Quickshell +import qs.Helpers import qs.Modules.Notifications.Sidebar import qs.Components import qs.Services @@ -12,26 +13,49 @@ import qs.Services Item { id: root - property bool chatOpen: false - required property Props props - anchors.fill: parent anchors.margins: Tokens.padding.small Component.onCompleted: { - if (root.props.inChat) { - stack.push(chatList); - stack.push(chatContent, { - "chatData": root.props.chatSession + if (ChatState.inChat) { + stackLoader.item?.push(chatList); + stackLoader.item?.push(chatContent, { + "chatData": ChatState.chatSession }); } else - stack.push(chatList); + stackLoader.item?.push(chatList); } - StackView { - id: stack + Loader { + id: stackLoader + active: !ChatState.isWindow || root.width <= (ChatState.screen.width / 4) anchors.fill: parent + + sourceComponent: StackView { + id: stack + } + } + + Loader { + id: sidebarViewLoader + + active: ChatState.isWindow && root.width > (ChatState.screen.width / 4) + anchors.fill: parent + + sourceComponent: SidebarView { + } + } + + IconButton { + anchors.right: parent.right + icon: "close" + visible: !ChatState.isWindow + + onClicked: { + Detach.create(); + Visibilities.getForActive().sidebar = false; + } } Component { @@ -46,19 +70,19 @@ Item { Chat.chats.remove(chat); } onLoadChatRequest: chat => { - stack.push(chatContent, { + stackLoader.item?.push(chatContent, { "chatData": chat }); - root.props.inChat = true; - root.props.chatSession = chat; + ChatState.inChat = true; + ChatState.chatSession = chat; } onNewChatRequest: { const data = Chat.chats.insert(); - stack.push(chatContent, { + stackLoader.item?.push(chatContent, { "chatData": data }); - root.props.inChat = true; - root.props.chatSession = data; + ChatState.inChat = true; + ChatState.chatSession = data; } } } @@ -68,8 +92,8 @@ Item { ChatContent { onRequestClose: { - stack.pop(); - root.props.inChat = false; + stackLoader.item?.pop(); + ChatState.inChat = false; } } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatState.qml b/Modules/Notifications/Sidebar/Chat/ChatState.qml new file mode 100644 index 0000000..df96065 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatState.qml @@ -0,0 +1,14 @@ +pragma Singleton + +import QtQuick +import Quickshell +import ZShell.Llm + +Singleton { + id: root + + property ChatSession chatSession + property bool inChat: false + property bool isWindow: false + property ShellScreen screen +} diff --git a/Modules/Notifications/Sidebar/Chat/Detach.qml b/Modules/Notifications/Sidebar/Chat/Detach.qml new file mode 100644 index 0000000..86fe6bd --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Detach.qml @@ -0,0 +1,51 @@ +pragma Singleton + +import Quickshell +import QtQuick +import ZShell.Config +import qs.Services + +Singleton { + id: root + + function create(parent: Item, props: var): void { + chatComp.createObject(parent ?? dummy, props); + ChatState.isWindow = true; + } + + QtObject { + id: dummy + } + + Component { + id: chatComp + + FloatingWindow { + id: win + + property var props + + color: Colors.tPalette.m3surface + implicitHeight: chat.implicitHeight + implicitWidth: chat.implicitWidth + minimumSize.height: Config.sidebar.sizes.width + minimumSize.width: Config.sidebar.sizes.width + surfaceFormat.opaque: false + title: qsTr("ZShell - Chat") + + Component.onCompleted: ChatState.screen = screen + onVisibleChanged: { + if (!visible) { + destroy(); + ChatState.isWindow = false; + } + } + + ChatPanel { + id: chat + + anchors.fill: parent + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/SidebarView.qml b/Modules/Notifications/Sidebar/Chat/SidebarView.qml new file mode 100644 index 0000000..fc9c5b4 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/SidebarView.qml @@ -0,0 +1,57 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +RowLayout { + id: root + + ChatList { + id: chatList + + Layout.fillHeight: true + Layout.fillWidth: true + Layout.maximumWidth: Config.sidebar.sizes.width + Layout.minimumWidth: Config.sidebar.sizes.width / 2 + list.currentIndex: -1 + list.highlightFollowsCurrentItem: false + + list.highlight: CustomRect { + color: Colors.palette.m3primary + implicitHeight: chatList.list.currentItem?.implicitHeight ?? 0 + implicitWidth: chatList.list.width + radius: Tokens.rounding.medium + x: chatList.list.currentItem?.chat.x ?? 0 + y: chatList.list.currentItem?.y ?? 0 + + Behavior on y { + Anim { + duration: Tokens.anim.durations.small + easing: Tokens.anim.expressiveDefaultEffects + } + } + } + model: ScriptModel { + values: Chat.chats.values + } + + onLoadChatRequest: (chat, index) => { + chatList.list.currentIndex = index; + chatContent.chatData = chat; + } + } + + ChatContent { + id: chatContent + + Layout.fillHeight: true + Layout.fillWidth: true + Layout.minimumWidth: Config.sidebar.sizes.width + Layout.preferredWidth: Math.round(Config.sidebar.sizes.width * 1.5) + } +} diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index cfd33c9..290a3d0 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -14,6 +14,15 @@ Item { required property Props props required property var visibilities + Connections { + function onIsWindowChanged(): void { + if (ChatState.isWindow) + root.props.currentTab = 0; + } + + target: ChatState + } + ColumnLayout { id: layout @@ -22,8 +31,10 @@ Item { Tabs { Layout.fillWidth: true + Layout.preferredHeight: ChatState.isWindow ? 0 : implicitHeight dashState: root.props nonAnimWidth: layout.width + visible: height > 0 } CustomClippingRect { @@ -75,7 +86,6 @@ Item { ChatPanel { anchors.fill: parent - props: root.props } } } diff --git a/Modules/Notifications/Sidebar/Props.qml b/Modules/Notifications/Sidebar/Props.qml index 02c0354..dcb4eb0 100644 --- a/Modules/Notifications/Sidebar/Props.qml +++ b/Modules/Notifications/Sidebar/Props.qml @@ -2,10 +2,8 @@ import Quickshell import ZShell.Llm PersistentProperties { - property ChatSession chatSession property int currentTab: 0 property list expandedNotifs: [] - property bool inChat: false reloadableId: "sidebar" } From aae3757daf5568823796869a5a6a7347a24d3b51 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 23 Aug 2026 16:37:11 +0200 Subject: [PATCH 04/90] better chat list view + anchor to bottom. sqlite db for chats + LIFO-ordered --- .qmlformat.ini | 3 +- Components/CustomFlickable.qml | 5 +- Components/CustomListView.qml | 31 +- Components/TextEditBase.qml | 99 +++ Components/VerticalFadeListView.qml | 5 +- Drawers/Panels.qml | 1 - Drawers/Windows.qml | 9 +- .../Sidebar/Chat/ChatContent.qml | 296 ++------ .../Sidebar/Chat/ChatDelegate.qml | 322 ++++++-- .../Notifications/Sidebar/Chat/ChatInput.qml | 87 +++ .../Notifications/Sidebar/Chat/ChatList.qml | 139 +--- .../Notifications/Sidebar/Chat/ChatPanel.qml | 23 +- .../Sidebar/Chat/Content/Actions.qml | 114 +++ .../Sidebar/Chat/Content/EmptyBackground.qml | 32 + .../Sidebar/Chat/Content/MessageDelegate.qml | 164 ++++ .../Sidebar/Chat/Content/Reasoning.qml | 107 +++ .../Sidebar/Chat/SidebarView.qml | 72 +- Modules/Notifications/Sidebar/Content.qml | 24 +- .../Sidebar/Utils/Cards/Toggles.qml | 2 - .../Notifications/Sidebar/Utils/Content.qml | 6 +- .../Notifications/Sidebar/Utils/Wrapper.qml | 26 +- Plugins/ZShell/Llm/CMakeLists.txt | 7 +- Plugins/ZShell/Llm/chat.cpp | 711 +++--------------- Plugins/ZShell/Llm/chat.hpp | 76 +- Plugins/ZShell/Llm/chatstore.cpp | 179 +++-- Plugins/ZShell/Llm/chatstore.hpp | 19 +- Plugins/ZShell/Llm/generation.cpp | 126 ++++ Plugins/ZShell/Llm/generation.hpp | 70 ++ Plugins/ZShell/Llm/llmclient.cpp | 568 ++++++++++++++ Plugins/ZShell/Llm/llmclient.hpp | 103 +++ Plugins/ZShell/Llm/message.cpp | 167 ++-- Plugins/ZShell/Llm/message.hpp | 100 ++- Plugins/ZShell/Llm/messagemodel.cpp | 107 +++ Plugins/ZShell/Llm/messagemodel.hpp | 68 ++ Plugins/ZShell/Llm/session.cpp | 200 +++-- Plugins/ZShell/Llm/session.hpp | 44 +- 36 files changed, 2653 insertions(+), 1459 deletions(-) create mode 100644 Components/TextEditBase.qml create mode 100644 Modules/Notifications/Sidebar/Chat/ChatInput.qml create mode 100644 Modules/Notifications/Sidebar/Chat/Content/Actions.qml create mode 100644 Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml create mode 100644 Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml create mode 100644 Modules/Notifications/Sidebar/Chat/Content/Reasoning.qml create mode 100644 Plugins/ZShell/Llm/generation.cpp create mode 100644 Plugins/ZShell/Llm/generation.hpp create mode 100644 Plugins/ZShell/Llm/llmclient.cpp create mode 100644 Plugins/ZShell/Llm/llmclient.hpp create mode 100644 Plugins/ZShell/Llm/messagemodel.cpp create mode 100644 Plugins/ZShell/Llm/messagemodel.hpp diff --git a/.qmlformat.ini b/.qmlformat.ini index 405a8d1..3956296 100644 --- a/.qmlformat.ini +++ b/.qmlformat.ini @@ -3,8 +3,9 @@ FunctionsSpacing=true IndentWidth=4 MaxColumnWidth=-1 NewlineType=native -NormalizeOrder=true +GroupAttributesTogether=true ObjectsSpacing=true SemicolonRule=always +SingleLineEmptyObjects=true SortImports=false UseTabs=true diff --git a/Components/CustomFlickable.qml b/Components/CustomFlickable.qml index 249f093..796fa77 100644 --- a/Components/CustomFlickable.qml +++ b/Components/CustomFlickable.qml @@ -28,6 +28,9 @@ Flickable { interval: 10 running: root.doneFakeFlick - onTriggered: root.doneFakeFlick = false + onTriggered: { + root.doneFakeFlick = false; + root.returnToBounds(); + } } } diff --git a/Components/CustomListView.qml b/Components/CustomListView.qml index 0b8d618..84c6aba 100644 --- a/Components/CustomListView.qml +++ b/Components/CustomListView.qml @@ -10,24 +10,27 @@ ListView { maximumFlickVelocity: 3000 rebound: Transition { - onRunningChanged: { - if (!running && !root.doneFakeFlick) { - root.doneFakeFlick = true; - root.flick(1, 1); - root.flick(-1, -1); - Qt.callLater(() => root.cancelFlick()); - } - } + // onRunningChanged: { + // if (!running && !root.doneFakeFlick) { + // root.doneFakeFlick = true; + // root.flick(1, 1); + // root.flick(-1, -1); + // Qt.callLater(() => root.cancelFlick()); + // } + // } Anim { properties: "x,y" + type: Anim.DefaultEffects } } - Timer { - interval: 10 - running: root.doneFakeFlick - - onTriggered: root.doneFakeFlick = false - } + // Timer { + // interval: 10 + // running: root.doneFakeFlick + // + // onTriggered: { + // root.doneFakeFlick = false; + // } + // } } diff --git a/Components/TextEditBase.qml b/Components/TextEditBase.qml new file mode 100644 index 0000000..1dd7776 --- /dev/null +++ b/Components/TextEditBase.qml @@ -0,0 +1,99 @@ +import QtQuick +import QtQuick.Templates +import ZShell.Config +import qs.Services + +TextEdit { + id: root + + property bool animateCursor: true + property alias cursor: cursor + + color: Colors.palette.m3onSurface + cursorVisible: !readOnly + font.pointSize: Tokens.font.size.small + renderType: TextField.NativeRendering + selectedTextColor: color + selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4) + verticalAlignment: TextInput.AlignVCenter + + Behavior on color { + CAnim { + } + } + cursorDelegate: Item { + } + Behavior on selectionColor { + CAnim { + } + } + + CustomRect { + id: cursor + + property bool disableBlink + + color: Colors.palette.m3primary + implicitHeight: root.cursorRectangle.height + implicitWidth: 1.5 + radius: Tokens.rounding.largeIncreased + x: root.cursorRectangle.x + y: root.cursorRectangle.y + + Behavior on opacity { + Anim { + type: Anim.StandardSmall + } + } + Behavior on x { + enabled: root.animateCursor + + Anim { + duration: Tokens.anim.durations.expressiveFastEffects + easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1] + } + } + Behavior on y { + enabled: root.animateCursor + + Anim { + duration: Tokens.anim.durations.expressiveFastEffects + easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1] + } + } + + Connections { + function onCursorPositionChanged(): void { + if (root.activeFocus && root.cursorVisible) { + cursor.opacity = 1; + cursor.disableBlink = true; + enableBlink.restart(); + } + } + + target: root + } + + Timer { + id: enableBlink + + interval: 500 + + onTriggered: cursor.disableBlink = false + } + + Timer { + interval: 500 + repeat: true + running: root.activeFocus && root.cursorVisible && !cursor.disableBlink + triggeredOnStart: true + + onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1 + } + + Binding { + cursor.opacity: 0 + when: !root.activeFocus || !root.cursorVisible + } + } +} diff --git a/Components/VerticalFadeListView.qml b/Components/VerticalFadeListView.qml index c563a0b..2430660 100644 --- a/Components/VerticalFadeListView.qml +++ b/Components/VerticalFadeListView.qml @@ -8,6 +8,7 @@ CustomListView { property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 property real fadeAmount: 0.1 + property real fadeThreshold: 0.0 readonly property bool horizontal: orientation === ListView.Horizontal property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1 @@ -28,11 +29,11 @@ CustomListView { } function marginEnd(): real { - return horizontal ? rightMargin : bottomMargin; + return horizontal ? rightMargin - fadeThreshold : bottomMargin - fadeThreshold; } function marginStart(): real { - return horizontal ? leftMargin : topMargin; + return horizontal ? leftMargin - fadeThreshold : topMargin - fadeThreshold; } function overshootStart(): real { diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 96ca9f8..8dd15c3 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -140,7 +140,6 @@ Item { anchors.bottom: parent.bottom anchors.right: parent.right - popouts: popouts sidebar: sidebar visibilities: root.visibilities } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index bac1fbc..779f623 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -63,12 +63,10 @@ CustomWindow { name: "Bar" Behavior on fsTransitionProg { - Anim { - } + Anim {} } Behavior on surfaceColor { - CAnim { - } + CAnim {} } contentItem.Keys.onEscapePressed: { @@ -293,8 +291,7 @@ CustomWindow { y: panels.popoutsWrapper.y + panels.popouts.y + geometry.insetTop(root.borderThickness) - (geometry.barOnTop ? panels.popouts.height * extraExtent : 0) Behavior on extraExtent { - Anim { - } + Anim {} } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 637e273..4a1220d 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -1,10 +1,8 @@ -pragma ComponentBehavior: Bound - import ZShell.Config import ZShell.Llm -import Quickshell import QtQuick import QtQuick.Layouts +import qs.Modules.Notifications.Sidebar.Chat.Content import qs.Components import qs.Services @@ -13,18 +11,23 @@ Item { property ChatSession chatData property bool following: true - readonly property int messageCount: chatData.messages.length signal requestClose - function send(): void { - if (Chat.busy || input.text.trim() === "") + function scrollToBottom(): void { + Qt.callLater(list.positionViewAtBeginning); + } + + function send(text: string): void { + if (text.trim() === "") return; following = true; - Chat.send(chatData.id, input.text); + chatData.sendMessage(text); input.text = ""; } + Component.onCompleted: input.focus = true + RowLayout { id: header @@ -54,194 +57,58 @@ Item { VerticalFadeListView { id: list - readonly property real bottomThreshold: Tokens.spacing.extraSmall - property bool stickToBottom: true + property bool userScrolledUp: false - function scrollToEnd(animated: bool): void { - const target = Math.max(0, list.contentHeight - list.height); - if (!animated) { - scrollAnim.stop(); - list.contentY = target; - return; - } - scrollAnim.to = target; - scrollAnim.restart(); - } - - anchors.bottom: inputRow.top + anchors.bottom: input.top anchors.bottomMargin: Tokens.spacing.medium anchors.left: parent.left anchors.right: parent.right anchors.top: header.bottom + anchors.topMargin: Tokens.spacing.medium cacheBuffer: height * 20 clip: true - currentIndex: messages.values.length - 1 + fadeAmount: 0.05 + fadeThreshold: Tokens.padding.medium + model: root.chatData.messagesModel spacing: Tokens.spacing.medium + verticalLayoutDirection: VerticalFadeListView.BottomToTop - CustomScrollBar.vertical: CustomScrollBar { - flickable: list - } - delegate: ColumnLayout { - id: messageRow - - readonly property bool isUser: modelData.role === ChatMessage.Role.User - required property var modelData - - anchors.left: parent.left - anchors.leftMargin: messageRow.isUser ? 0 : Tokens.spacing.extraSmall - anchors.right: parent.right - anchors.rightMargin: messageRow.isUser ? Tokens.spacing.extraSmall : 0 - - Loader { - Layout.fillWidth: true - Layout.rightMargin: Tokens.spacing.extraLarge - active: !messageRow.isUser && messageRow.modelData.reasoning !== "" - - sourceComponent: CustomRect { - id: reasoning - - property bool expanded: false - - color: Colors.palette.m3surfaceContainer - implicitHeight: expanded ? expandedText.implicitHeight : collapsedText.implicitHeight + Tokens.padding.medium * 2 - radius: Tokens.rounding.medium - - Behavior on implicitHeight { - Anim { - } - } - - Loader { - id: spinnerReasoning - - active: opacity > 0 - anchors.left: parent.left - anchors.margins: Tokens.padding.medium - anchors.verticalCenter: parent.verticalCenter - opacity: messageRow.modelData.reasoningActive ? 1 : 0 - - Behavior on opacity { - Anim { - } - } - sourceComponent: LoadingIndicator { - implicitSize: collapsedText.implicitHeight - } - } - - MaterialIcon { - id: reasoningDone - - anchors.left: parent.left - anchors.margins: Tokens.padding.medium - anchors.verticalCenter: parent.verticalCenter - font.pointSize: Tokens.font.size.large - opacity: messageRow.modelData.reasoningActive || reasoning.expanded ? 0 : 1 - text: "check" - - Behavior on opacity { - Anim { - } - } - } - - CustomText { - id: collapsedText - - anchors.left: messageRow.modelData.reasoningActive ? spinnerReasoning.right : reasoningDone.right - anchors.margins: Tokens.padding.medium - anchors.right: parent.right - anchors.top: parent.top - opacity: reasoning.expanded ? 0 : 1 - text: messageRow.modelData.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((messageRow.modelData.reasoningElapsedMs / 1000).toFixed(1)) - visible: opacity > 0 - - Behavior on opacity { - Anim { - } - } - } - - CustomText { - id: expandedText - - anchors.left: parent.left - anchors.margins: Tokens.padding.medium - anchors.right: parent.right - anchors.top: parent.top - color: Colors.palette.m3onSurface - opacity: reasoning.expanded ? 1 : 0 - text: messageRow.modelData.reasoning - visible: opacity > 0 - wrapMode: Text.WordWrap - - Behavior on opacity { - Anim { - } - } - } - - StateLayer { - onClicked: reasoning.expanded = !reasoning.expanded - } - } - } - - CustomRect { - Layout.alignment: messageRow.isUser ? Qt.AlignRight : Qt.AlignLeft - color: messageRow.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer - implicitHeight: Math.max(msgText.implicitHeight + Tokens.padding.medium * 2, spinnerLoader.implicitHeight) - implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, messageRow.ListView.view.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge) - radius: Tokens.rounding.medium - visible: !messageRow.modelData.reasoningActive - - CustomText { - id: msgText - - anchors.left: parent.left - anchors.margins: Tokens.padding.medium - anchors.right: parent.right - anchors.top: parent.top - color: messageRow.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - text: messageRow.modelData.content - textFormat: CustomText.MarkdownText - wrapMode: Text.WordWrap - } - - Loader { - id: spinnerLoader - - active: messageRow.modelData.streaming && msgText.text.length === 0 - anchors.left: parent.left - anchors.leftMargin: 8 - anchors.verticalCenter: parent.verticalCenter - - sourceComponent: Item { - implicitHeight: 18 - implicitWidth: 18 - - LoadingIndicator { - anchors.fill: parent - } - } - } + add: Transition { + Anim { + from: list.width + property: "x" + to: 0 } } - model: ScriptModel { - id: messages - - values: root.chatData.messages + delegate: MessageDelegate {} + displaced: Transition { + Anim { + property: "y" + } + } + move: Transition { + Anim { + property: "y" + } } - Component.onCompleted: scrollToEnd(false) + Component.onCompleted: positionViewAtBeginning() + onAtYEndChanged: { + if (atYEnd) + userScrolledUp = false; + } onContentHeightChanged: { - if (list.stickToBottom) - list.scrollToEnd(false); + if (!userScrolledUp && atYEnd) + root.scrollToBottom(); } - onMovementEnded: { - list.stickToBottom = (list.contentY >= list.contentHeight - list.height - list.bottomThreshold); + onCountChanged: { + if (!userScrolledUp) + root.scrollToBottom(); + } + onMovingChanged: { + if (moving) + userScrolledUp = !atYEnd; } - onMovementStarted: list.stickToBottom = false Anim { id: scrollAnim @@ -252,88 +119,53 @@ Item { } } - ColumnLayout { + EmptyBackground { id: emptyState anchors.fill: parent spacing: Tokens.spacing.small - visible: (root.messageCount === 0 && !Chat.busy) || !root.chatData - - Item { - Layout.fillHeight: true - } - - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - color: Colors.tPalette.m3outlineVariant - font.pointSize: 36 - text: "chat" - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: Colors.tPalette.m3onSurfaceVariant - text: !root.chatData ? qsTr("Open a previous chat or start a new one") : qsTr("No messages yet") - } - - Item { - Layout.fillHeight: true - } + visible: !root.chatData } IconButton { id: scrollToBottom - anchors.bottom: inputRow.top + anchors.bottom: input.top anchors.bottomMargin: Tokens.spacing.extraLarge anchors.horizontalCenter: parent.horizontalCenter font.pointSize: Tokens.font.size.large icon: "arrow_downward" isRound: true padding: Tokens.padding.extraSmall - scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 ? 1 : 0 + scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 && list.contentHeight > list.height ? 1 : 0 type: IconButton.Filled visible: scale > 0 Behavior on scale { - Anim { - } + Anim {} } onClicked: { - list.stickToBottom = true; - list.scrollToEnd(true); + list.positionViewAtBeginning(); } } - RowLayout { - id: inputRow + ChatInput { + id: input anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right - spacing: Tokens.spacing.extraSmall + bg.border.color: Colors.palette.m3outlineVariant + bg.color: Colors.tPalette.m3surfaceContainerLowest + font.pointSize: Tokens.font.size.normal + implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) + placeholderText: qsTr("Send a message") + sendIcon.font.pointSize: Tokens.font.size.large + sendIcon.icon: "arrow_upward" + sendIcon.padding: Tokens.padding.extraSmall - CustomTextField { - id: input - - Layout.fillWidth: true - placeholderText: qsTr("Send a message...") - type: CustomTextField.Filled - - onAccepted: root.send() - } - - IconButton { - id: actionBtn - - enabled: Chat.busy || input.text.trim() !== "" - font.pointSize: Tokens.font.size.normal * 2 - icon: Chat.busy ? "stop" : "send" - isRound: true - type: IconButton.Filled - - onClicked: Chat.busy ? Chat.stop() : root.send() - } + onAccepted: root.send(text) + onSendPressed: root.send(text) } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml index b56685f..4784f31 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml @@ -1,91 +1,311 @@ import QtQuick -import QtQuick.Layouts +import ZShell.Components import ZShell.Config import ZShell.Llm import qs.Components import qs.Services -CustomRect { +CustomClippingRect { id: root - required property Item chatItem - property alias layout: layout + property bool expanded: false required property ChatSession modelData + signal clicked(content: ChatSession) + signal remove(content: ChatSession) + color: Colors.tPalette.m3surfaceContainer - implicitHeight: layout.implicitHeight + layout.anchors.topMargin + layout.anchors.bottomMargin + implicitHeight: { + let h = 0; + + h += infoContainer.implicitHeight; + + if (expanded) + h += body.implicitHeight + body.topMargin + actionRow.implicitHeight + actionRow.anchors.topMargin; + else + h += preview.implicitHeight + preview.topMargin; + + h += Tokens.padding.small; + + const icon = chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2; + if (h < icon) + return icon; + return h; + } + radius: Tokens.rounding.large + + Behavior on implicitHeight { + enabled: titleText.readOnly + + Anim {} + } MaterialIcon { id: chatIcon anchors.left: parent.left anchors.leftMargin: Tokens.padding.large - anchors.verticalCenter: parent.verticalCenter - color: root.chatItem.ListView.isCurrentItem ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + anchors.top: parent.top + anchors.topMargin: Tokens.padding.large font.pointSize: Tokens.font.size.extraLarge text: root.modelData.icon || "check" } - ColumnLayout { - id: layout + Item { + id: infoContainer - anchors.bottom: parent.bottom - anchors.bottomMargin: Tokens.padding.small anchors.left: chatIcon.right - anchors.leftMargin: Tokens.padding.medium - anchors.right: parent.right - anchors.rightMargin: Tokens.padding.medium + anchors.leftMargin: Tokens.spacing.medium anchors.top: parent.top - anchors.topMargin: Tokens.padding.small - spacing: Tokens.spacing.extraSmall + anchors.right: expandBtn.left + anchors.rightMargin: Tokens.spacing.medium + implicitHeight: { + let h = 0; + h += title.implicitHeight + title.anchors.topMargin; + h += timestamp.implicitHeight + timestamp.anchors.topMargin; + if (root.expanded) + h += created.implicitHeight + created.anchors.topMargin; + return h; + } CustomRect { - id: titleContainer + id: title - readonly property bool enoughSpace: width > title.implicitWidth + timeSep.implicitWidth + timeSep.anchors.leftMargin + timestamp.implicitWidth + timestamp.anchors.leftMargin + anchors.left: parent.left + anchors.topMargin: Tokens.padding.extraSmall + anchors.top: parent.top + color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 1) + implicitHeight: titleText.implicitHeight + Tokens.padding.extraSmall * 2 + radius: Tokens.rounding.full + implicitWidth: titleText.implicitWidth + Tokens.padding.large * 2 + opacity: titleText.readOnly ? 0 : 1 - Layout.fillWidth: true - implicitHeight: enoughSpace ? title.implicitHeight : title.implicitHeight + timestamp.implicitHeight + timestamp.anchors.topMargin + Behavior on opacity { + Anim {} + } + } - CustomText { - id: title + TextEditBase { + id: titleText - color: root.chatItem.ListView.isCurrentItem ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - text: root.modelData.title + anchors.left: parent.left + anchors.leftMargin: readOnly ? 0 : Tokens.padding.large + anchors.top: parent.top + anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2 + font.pointSize: Tokens.font.size.normal + text: root.modelData.title + readOnly: true + + Behavior on anchors.leftMargin { + Anim {} + } + Behavior on anchors.topMargin { + Anim {} } - CustomText { - id: timeSep - - anchors.left: title.right - anchors.leftMargin: Tokens.spacing.small - anchors.verticalCenter: title.verticalCenter - color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant - text: "•" - visible: titleContainer.enoughSpace - } - - CustomText { - id: timestamp - - anchors.left: titleContainer.enoughSpace ? timeSep.right : parent.left - anchors.leftMargin: titleContainer.enoughSpace ? Tokens.spacing.small : 0 - anchors.top: titleContainer.enoughSpace ? undefined : title.bottom - anchors.topMargin: Tokens.spacing.extraSmall - anchors.verticalCenter: titleContainer.enoughSpace ? title.verticalCenter : undefined - color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small - text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP") + onEditingFinished: root.modelData.title = text + onReadOnlyChanged: { + if (!readOnly) { + this.forceActiveFocus(); + cursorPosition = text.length; + } else { + root.forceActiveFocus(); + } } } CustomText { - Layout.fillWidth: true - color: root.chatItem.ListView.isCurrentItem ? Qt.alpha(Colors.palette.m3onPrimary, 0.9) : Colors.palette.m3onSurfaceVariant - elide: Text.ElideRight + id: created + + anchors.top: title.bottom + anchors.topMargin: Tokens.spacing.extraSmall + anchors.left: parent.left + anchors.right: parent.right font.pointSize: Tokens.font.size.small - maximumLineCount: 1 - text: root.modelData.messages[root.modelData.messages.length - 1]?.content.replace(/\s+/g, " ").trim() ?? "" + color: Colors.palette.m3onSurfaceVariant + text: qsTr("Created on: %1").arg(root.modelData.createdAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP")) + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + + CustomText { + id: updated + + anchors.top: created.bottom + anchors.topMargin: Tokens.spacing.small + anchors.left: parent.left + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3onSurfaceVariant + text: qsTr("Updated on: ") + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + + CustomText { + id: timestamp + + anchors.left: parent.left + anchors.top: titleText.bottom + anchors.topMargin: Tokens.spacing.extraSmall + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.small + text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP") + + states: State { + name: "expanded" + when: root.expanded + + AnchorChanges { + target: timestamp + anchors.left: updated.right + anchors.top: undefined + anchors.verticalCenter: updated.verticalCenter + } + } + transitions: Transition { + AnchorAnim {} + } + } + } + + CustomText { + id: preview + + readonly property int topMargin: Tokens.spacing.extraSmall + + anchors.left: chatIcon.right + anchors.margins: Tokens.spacing.medium + anchors.right: parent.right + y: infoContainer.implicitHeight + topMargin + elide: Text.ElideRight + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.small + maximumLineCount: 1 + opacity: !root.expanded ? 1 : 0 + text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? "" + + Behavior on y { + Anim {} + } + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + CustomText { + id: body + + readonly property int topMargin: root.expanded ? Tokens.spacing.medium : Tokens.spacing.extraSmall + + anchors.left: chatIcon.right + anchors.margins: Tokens.spacing.medium + anchors.right: parent.right + y: infoContainer.implicitHeight + topMargin + animate: true + color: Colors.palette.m3onSurfaceVariant + maximumLineCount: 5 + opacity: root.expanded ? 1 : 0 + text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? "" + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + Behavior on y { + Anim {} + } + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + StateLayer { + id: sLayer + + property int startY + + onClicked: { + root.clicked(root.modelData); + } + } + + ButtonRow { + id: actionRow + + anchors.top: body.bottom + anchors.left: chatIcon.right + anchors.right: parent.right + anchors.topMargin: Tokens.spacing.small + anchors.leftMargin: Tokens.spacing.medium + anchors.rightMargin: Tokens.padding.medium + spacing: Tokens.spacing.medium + + IconButton { + icon: "close" + fillWidth: true + shapeMorph: true + isRound: true + font.pointSize: Tokens.font.size.large + inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3) + inactiveOnColor: Colors.palette.m3onSurfaceVariant + + onClicked: root.remove(root.modelData) + } + + IconButton { + icon: titleText.readOnly ? "edit" : "check" + label.animate: true + isToggle: true + fillWidth: true + shapeMorph: true + font.pointSize: Tokens.font.size.large + isRound: true + inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3) + inactiveOnColor: Colors.palette.m3onSurfaceVariant + activeColor: Colors.palette.m3secondary + activeOnColor: Colors.palette.m3onSecondary + checked: !titleText.readOnly + + onClicked: titleText.readOnly = !titleText.readOnly + } + } + + CustomRect { + id: expandBtn + + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Tokens.padding.small + implicitHeight: expandIcon.implicitHeight + implicitWidth: expandIcon.implicitHeight + radius: Tokens.rounding.full + color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 3) + + StateLayer { + onClicked: root.expanded = !root.expanded + } + + MaterialIcon { + id: expandIcon + + anchors.centerIn: parent + anchors.verticalCenterOffset: root.expanded ? -1 : 1 + rotation: root.expanded ? 180 : 0 + text: "expand_more" + + Behavior on anchors.verticalCenterOffset { + Anim {} + } + Behavior on rotation { + Anim {} + } } } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatInput.qml b/Modules/Notifications/Sidebar/Chat/ChatInput.qml new file mode 100644 index 0000000..290d0cd --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/ChatInput.qml @@ -0,0 +1,87 @@ +import QtQuick +import ZShell.Config +import qs.Components +import qs.Services + +TextFieldBase { + id: root + + readonly property alias bg: bg + readonly property alias sendIcon: sendIcon + + signal sendPressed + + bottomPadding: Tokens.padding.large + leftPadding: Tokens.padding.extraLarge + rightPadding: sendIcon.width + sendIcon.anchors.rightMargin + Tokens.spacing.small + topPadding: Tokens.padding.large + wrapMode: TextFieldBase.Wrap + + background: CustomRect { + id: bg + + anchors.fill: parent + color: Colors.tPalette.m3surfaceContainer + radius: Tokens.rounding.extraLarge + + StateLayer { + id: stateLayer + + cursorShape: Qt.IBeamCursor + enabled: !root.activeFocus + manualPressOverride: tapHandler.pressed + + onClicked: root.focus = true + } + } + + onPressed: { + if (stateLayer.enabled) + stateLayer.press(stateLayer.mouseX, stateLayer.mouseY); + } + + CustomText { + id: placeholder + + anchors.left: parent.left + anchors.leftMargin: root.leftPadding + anchors.verticalCenter: parent.verticalCenter + color: root.placeholderTextColor + font: root.font + opacity: root.text ? 0 : 1 + text: root.placeholderText + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + IconButton { + id: sendIcon + + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.small + anchors.verticalCenter: parent.verticalCenter + enabled: root.text + icon: "upward_arrow" + opacity: root.text ? 1 : 0 + radius: Tokens.rounding.full + radiusMorph: false + stateLayer.hoverEnabled: enabled + type: IconButton.Text + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + onClicked: root.sendPressed() + } + + TapHandler { + id: tapHandler + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml index 160936b..9ca55d9 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatList.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -15,7 +15,7 @@ Item { property alias model: list.model signal deleteChatRequest(content: ChatSession) - signal loadChatRequest(content: ChatSession, index: int) + signal loadChatRequest(content: ChatSession) signal newChatRequest CustomText { @@ -44,140 +44,13 @@ Item { clip: true spacing: Tokens.spacing.medium - delegate: Component { - Item { - id: chatItem + delegate: ChatDelegate { + id: chat - property alias chat: chat - property bool closing: false - property bool editVisible: false - required property int index - required property var modelData - property real startY: 0.0 + implicitWidth: ListView.view.width - anchors.fill: undefined - implicitHeight: chat.layout.implicitHeight + chat.layout.anchors.topMargin + chat.layout.anchors.bottomMargin - implicitWidth: ListView.view.width - - onEditVisibleChanged: if (!editVisible) - chat.x = 0 - - ParallelAnimation { - id: removeAnim - - onFinished: root.deleteChatRequest(chatItem.modelData) - onStarted: chatItem.closing = true - - Anim { - property: "implicitHeight" - target: chatItem - to: 0 - } - - Anim { - property: "x" - target: chat - to: -chat.implicitWidth - } - - Anim { - property: "opacity" - target: chatItem - to: 0 - } - } - - ChatDelegate { - id: chat - - chatItem: chatItem - implicitWidth: chatItem.implicitWidth - modelData: chatItem.modelData - radius: Tokens.rounding.medium - - Behavior on x { - Anim { - } - } - Behavior on y { - Anim { - } - } - - StateLayer { - cursorShape: drag.active ? Qt.ClosedHandCursor : Qt.PointingHandCursor - drag.axis: Drag.XAxis - drag.target: chat - hoverEnabled: true - preventStealing: true - - onClicked: { - if (chatItem.editVisible) - chatItem.editVisible = false; - else - root.loadChatRequest(chatItem.modelData, chatItem.index); - } - onPressed: event => { - chatItem.startY = event.y; - } - onReleased: event => { - if (chat.x > -(editTools.implicitWidth + Tokens.padding.extraSmall)) { - chat.x = 0; - } else { - chatItem.editVisible = true; - chat.x = -(editTools.implicitWidth + Tokens.spacing.medium + Tokens.padding.extraSmall); - } - } - } - } - - CustomClippingRect { - anchors.left: chat.right - anchors.right: parent.right - anchors.rightMargin: Tokens.padding.extraSmall - anchors.verticalCenter: chat.verticalCenter - implicitHeight: editTools.implicitHeight - - RowLayout { - id: editTools - - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - - IconButton { - id: deleteChatBtn - - font.pointSize: Tokens.font.size.large - icon: "close" - radius: Tokens.rounding.full - - transform: [ - Scale { - property bool shouldBeActive: (chat.x < -deleteChatBtn.implicitWidth) && !chatItem.closing - - origin.x: deleteChatBtn.implicitWidth / 2 - origin.y: deleteChatBtn.implicitWidth / 2 - xScale: shouldBeActive ? 1 : 0 - yScale: shouldBeActive ? 1 : 0 - - Behavior on xScale { - Anim { - } - } - Behavior on yScale { - Anim { - } - } - } - ] - - onClicked: { - removeAnim.start(); - } - } - } - } - } + onClicked: content => root.loadChatRequest(content) + onRemove: content => root.deleteChatRequest(content) } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml index 606e65e..2c9c44c 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml @@ -43,20 +43,19 @@ Item { active: ChatState.isWindow && root.width > (ChatState.screen.width / 4) anchors.fill: parent - sourceComponent: SidebarView { - } + sourceComponent: SidebarView {} } - IconButton { - anchors.right: parent.right - icon: "close" - visible: !ChatState.isWindow - - onClicked: { - Detach.create(); - Visibilities.getForActive().sidebar = false; - } - } + // IconButton { + // anchors.right: parent.right + // icon: "close" + // visible: !ChatState.isWindow + // + // onClicked: { + // Detach.create(); + // Visibilities.getForActive().sidebar = false; + // } + // } Component { id: chatList diff --git a/Modules/Notifications/Sidebar/Chat/Content/Actions.qml b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml new file mode 100644 index 0000000..2b0bade --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml @@ -0,0 +1,114 @@ +import QtQuick +import Quickshell +import ZShell.Config +import ZShell.Components +import ZShell.Llm +import qs.Components +import qs.Services + +Item { + id: root + + required property ChatGeneration current + required property TextEdit edit + required property bool hovered + readonly property bool isUser: message.role === ChatMessage.Role.User + required property ChatMessage message + + implicitHeight: root.current.content !== "" ? editButton.implicitHeight : 0 + implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small + + ButtonRow { + id: generationTools + + enabled: visible + visible: !root.isUser + + IconButton { + enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== 0 + icon: "chevron_left" + type: IconButton.Tonal + + onClicked: { + if (!root.edit.readOnly) + root.edit.readOnly = true; + root.message.setActiveGeneration(root.message.activeGenerationIndex - 1); + } + } + + IconButton { + enabled: false + icon: `${root.message.activeGenerationIndex + 1}` + label.font.family: Config.appearance.font.family.sans + label.font.pointSize: Tokens.font.size.small + type: IconButton.Text + } + + IconButton { + enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== root.message.generationCount - 1 + icon: "chevron_right" + type: IconButton.Tonal + + onClicked: { + if (!root.edit.readOnly) + root.edit.readOnly = true; + root.message.setActiveGeneration(root.message.activeGenerationIndex + 1); + } + } + } + + ButtonRow { + id: editTools + + anchors.right: parent.right + opacity: !root.current.streaming && root.hovered ? 1 : 0 + spacing: Tokens.spacing.small + visible: opacity > 0 + + Behavior on opacity { + Anim { + } + } + + IconButton { + icon: "refresh" + inactiveColor: Colors.palette.m3secondary + inactiveOnColor: Colors.palette.m3onSecondary + scale: root.edit.readOnly ? 1 : 0 + visible: !root.isUser + + Behavior on scale { + Anim { + } + } + + onClicked: { + root.message.retry(); + } + } + + IconButton { + icon: "content_copy" + inactiveColor: Colors.palette.m3secondary + inactiveOnColor: Colors.palette.m3onSecondary + scale: root.edit.readOnly ? 1 : 0 + + Behavior on scale { + Anim { + } + } + + onClicked: Quickshell.clipboardText = root.current.content + } + + IconButton { + id: editButton + + icon: root.edit.readOnly ? "edit" : "check" + inactiveColor: Colors.palette.m3tertiary + inactiveOnColor: Colors.palette.m3onTertiary + + onClicked: root.edit.readOnly = !root.edit.readOnly + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml b/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml new file mode 100644 index 0000000..ee21512 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml @@ -0,0 +1,32 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services + +ColumnLayout { + id: root + + spacing: Tokens.spacing.small + + Item { + Layout.fillHeight: true + } + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3outlineVariant + font.pointSize: 36 + text: "chat" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3onSurfaceVariant + text: qsTr("No messages yet") + } + + Item { + Layout.fillHeight: true + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml new file mode 100644 index 0000000..73e758d --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -0,0 +1,164 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +MouseArea { + id: root + + readonly property ChatGeneration current: modelData.activeGeneration + required property int index + readonly property bool isUser: modelData.role === ChatMessage.Role.User + required property ChatMessage modelData + property bool reasoningExpanded: false + property real savedContentY: -1 + + function handleReasoningToggle(expanded: bool): void { + const view = root.ListView.view; + if (!view) + return; + + if (expanded) { + root.savedContentY = view.contentY; + root.reasoningExpanded = true; + } else { + root.reasoningExpanded = false; + if (root.savedContentY !== -1) { + restoreAnim.to = root.savedContentY; + restoreAnim.restart(); + root.savedContentY = -1; + } + } + } + + anchors.left: parent.left + anchors.leftMargin: root.isUser ? 0 : Tokens.spacing.extraSmall + anchors.right: parent.right + anchors.rightMargin: root.isUser ? Tokens.spacing.extraSmall : 0 + hoverEnabled: true + // Explicit, synchronous height — sum of children, no Layout polish involved. + implicitHeight: reasoningLoader.implicitHeight + (reasoningLoader.active ? Tokens.spacing.medium : 0) + bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin + preventStealing: true + + Binding { + property: "contentY" + restoreMode: Binding.RestoreNone + target: root.ListView.view + value: root.y - Tokens.padding.large * 2 + when: root.reasoningExpanded && root.ListView.view && ((root.y - Tokens.padding.large * 2) < root.ListView.view.contentY) + } + + Anim { + id: restoreAnim + + property: "contentY" + target: root.ListView.view + type: Anim.DefaultEffects + } + + Loader { + id: reasoningLoader + + readonly property bool shouldBeActive: root.current.reasoningActive || root.current.content === "" + + active: !root.isUser + anchors.left: parent.left + anchors.right: parent.right + anchors.rightMargin: Tokens.spacing.extraLarge + anchors.top: parent.top + + sourceComponent: Reasoning { + current: root.current + + onExpandedChanged: { + root.handleReasoningToggle(expanded); + } + } + } + + CustomRect { + id: bubble + + anchors.left: root.isUser ? undefined : parent.left + anchors.right: root.isUser ? parent.right : undefined + anchors.top: reasoningLoader.active ? reasoningLoader.bottom : parent.top + anchors.topMargin: reasoningLoader.active ? Tokens.spacing.medium : 0 + color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer + implicitHeight: root.current.content !== "" ? msgText.contentHeight + Tokens.padding.medium * 2 : 0 + implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge) + radius: Tokens.rounding.medium + visible: !root.current.reasoningActive + + TextEditBase { + id: msgText + + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.right: parent.right + anchors.top: parent.top + animateCursor: false + color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary + font.pointSize: Tokens.font.size.smaller + readOnly: true + selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4) + text: root.current.content + textFormat: CustomText.MarkdownText + wrapMode: Text.WordWrap + + Keys.onPressed: event => { + if (event.key == Qt.Key_Return) { + if (!(event.modifiers & Qt.ShiftModifier)) { + readOnly = true; + event.accepted = true; + } + } else if (event.key == Qt.Key_Escape) { + text = root.current.content; + readOnly = true; + event.accepted = true; + } + } + onEditingFinished: { + const old = root.current.content; + if (old !== text) { + root.modelData.edit(text); + + if (root.isUser) + root.modelData.generate(); + } + + readOnly = true; + } + onReadOnlyChanged: { + if (readOnly) { + animateCursor = false; + root.forceActiveFocus(); + textFormat = CustomText.MarkdownText; + } else { + var raw = root.current.content; + textFormat = CustomText.PlainText; + text = raw; + forceActiveFocus(); + cursorPosition = text.length; + animateCursor = true; + } + } + } + } + + Actions { + id: actionsRow + + anchors.left: parent.left + anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right + anchors.top: bubble.bottom + anchors.topMargin: Tokens.spacing.medium + current: root.current + edit: msgText + hovered: root.containsMouse + message: root.modelData + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/Reasoning.qml b/Modules/Notifications/Sidebar/Chat/Content/Reasoning.qml new file mode 100644 index 0000000..74bad30 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/Reasoning.qml @@ -0,0 +1,107 @@ +import QtQuick +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +Item { + id: root + + required property ChatGeneration current + property bool expanded: false + readonly property bool isReasoning: root.current.reasoningActive || root.current.content === "" + + implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin + + LoadingIndicator { + id: spinnerReasoning + + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.verticalCenter: parent.verticalCenter + implicitSize: collapsedText.implicitHeight + opacity: root.isReasoning ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim { + } + } + } + + IconButton { + id: expandBtn + + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.verticalCenter: collapsedText.verticalCenter + font.pointSize: Tokens.font.size.large + icon: "keyboard_arrow_down" + inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline + opacity: root.isReasoning ? 0 : 1 + rotation: root.expanded ? 180 : 0 + type: IconButton.Text + + Behavior on rotation { + Anim { + } + } + + onClicked: root.expanded = !root.expanded + } + + CustomText { + id: collapsedText + + anchors.left: root.current.reasoningActive ? spinnerReasoning.right : expandBtn.right + anchors.margins: Tokens.padding.medium + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: 0 + color: Colors.palette.m3outline + font.pointSize: Tokens.font.size.small + text: root.current.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((root.current.reasoningElapsedMs / 1000).toFixed(1)) + + Behavior on opacity { + Anim { + } + } + } + + CustomClippingRect { + id: expandedRect + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: collapsedText.bottom + anchors.topMargin: Tokens.spacing.medium + color: Colors.palette.m3surfaceContainerLow + implicitHeight: root.expanded ? expandedText.implicitHeight + expandedText.anchors.margins * 2 : -anchors.topMargin + implicitWidth: expandedText.implicitWidth + expandedText.anchors.margins * 2 + opacity: root.expanded ? 1 : 0 + radius: Tokens.rounding.medium + visible: opacity > 0 + + Behavior on implicitHeight { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on opacity { + Anim { + } + } + + CustomText { + id: expandedText + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + color: Colors.palette.m3onSurface + font.pointSize: Tokens.font.size.small + text: root.current.reasoning + textFormat: CustomText.MarkdownText + wrapMode: Text.WordWrap + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/SidebarView.qml b/Modules/Notifications/Sidebar/Chat/SidebarView.qml index fc9c5b4..9b83259 100644 --- a/Modules/Notifications/Sidebar/Chat/SidebarView.qml +++ b/Modules/Notifications/Sidebar/Chat/SidebarView.qml @@ -8,26 +8,49 @@ import ZShell.Llm import qs.Components import qs.Services -RowLayout { +Item { id: root - ChatList { - id: chatList + property int breakpoint: 700 + property alias conversationModel: sidebar.model + property var currentConversation: null // whatever model item is "open" - Layout.fillHeight: true - Layout.fillWidth: true - Layout.maximumWidth: Config.sidebar.sizes.width - Layout.minimumWidth: Config.sidebar.sizes.width / 2 + readonly property bool isWide: root.width >= root.breakpoint + property bool narrowShowsSidebar: true + + function closeConversation() { + narrowShowsSidebar = true; + } + + function openConversation(conv) { + currentConversation = conv; + narrowShowsSidebar = false; // in narrow mode, jump to the chat + } + + ChatList { + id: sidebar + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: root.isWide ? undefined : parent.right + anchors.top: parent.top + implicitWidth: 20 list.currentIndex: -1 list.highlightFollowsCurrentItem: false + Behavior on anchors.right { + AnchorAnim {} + } + Behavior on implicitWidth { + Anim {} + } list.highlight: CustomRect { color: Colors.palette.m3primary - implicitHeight: chatList.list.currentItem?.implicitHeight ?? 0 - implicitWidth: chatList.list.width + implicitHeight: sidebar.list.currentItem?.implicitHeight ?? 0 + implicitWidth: sidebar.list.width radius: Tokens.rounding.medium - x: chatList.list.currentItem?.chat.x ?? 0 - y: chatList.list.currentItem?.y ?? 0 + x: sidebar.list.currentItem?.chat.x ?? 0 + y: sidebar.list.currentItem?.y ?? 0 Behavior on y { Anim { @@ -40,18 +63,29 @@ RowLayout { values: Chat.chats.values } + anchors.onRightChanged: { + if (anchors.right === undefined) + implicitWidth = 20; + } onLoadChatRequest: (chat, index) => { - chatList.list.currentIndex = index; - chatContent.chatData = chat; + sidebar.list.currentIndex = index; + root.openConversation(chat); } } - ChatContent { - id: chatContent + CustomClippingWrapperRect { + anchors.bottom: parent.bottom + anchors.left: sidebar.right + anchors.right: parent.right + anchors.top: parent.top + child: conversationView + } - Layout.fillHeight: true - Layout.fillWidth: true - Layout.minimumWidth: Config.sidebar.sizes.width - Layout.preferredWidth: Math.round(Config.sidebar.sizes.width * 1.5) + ChatContent { + id: conversationView + + anchors.fill: root + anchors.leftMargin: root.isWide ? Config.sidebar.sizes.width / 2 : 0 + chatData: root.currentConversation } } diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 290a3d0..4b76e12 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -45,14 +45,21 @@ Item { Item { id: pages - anchors.fill: parent + anchors.bottom: parent.bottom + anchors.top: parent.top + implicitWidth: parent.width opacity: root.props.currentTab === 0 ? 1 : 0 - visible: opacity > 0.01 + visible: opacity > 0 + x: root.props.currentTab === 0 ? 0 : -root.width Behavior on opacity { Anim { } } + Behavior on x { + Anim { + } + } CustomRect { anchors.fill: parent @@ -69,15 +76,22 @@ Item { Item { id: chatPage - anchors.fill: parent - opacity: visible ? 1 : 0 - visible: root.props.currentTab === 1 + 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 Behavior on opacity { Anim { } } + Behavior on x { + Anim { + } + } CustomRect { anchors.fill: parent diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml index f32e6b1..4fbf307 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml @@ -12,13 +12,11 @@ import qs.Services import qs.Helpers import qs.Daemons import qs.Modules.Settings -import qs.Modules.Bar.Popouts as BarPopouts CustomRect { id: root readonly property bool needExtraRow: quickToggles.length > 6 - required property BarPopouts.Wrapper popouts readonly property var quickToggles: { const seenIds = new Set(); diff --git a/Modules/Notifications/Sidebar/Utils/Content.qml b/Modules/Notifications/Sidebar/Utils/Content.qml index 922eff9..1bdf03d 100644 --- a/Modules/Notifications/Sidebar/Utils/Content.qml +++ b/Modules/Notifications/Sidebar/Utils/Content.qml @@ -1,14 +1,12 @@ import Quickshell import QtQuick import QtQuick.Layouts -import qs.Modules.Bar.Popouts as BarPopouts import qs.Modules.Notifications.Sidebar.Utils.Cards import ZShell.Config Item { id: root - required property BarPopouts.Wrapper popouts required property PersistentProperties props required property var visibilities @@ -21,8 +19,7 @@ Item { anchors.fill: parent spacing: 8 - IdleInhibit { - } + IdleInhibit {} Record { props: root.props @@ -32,7 +29,6 @@ Item { Toggles { Layout.fillWidth: true - popouts: root.popouts visibilities: root.visibilities } } diff --git a/Modules/Notifications/Sidebar/Utils/Wrapper.qml b/Modules/Notifications/Sidebar/Utils/Wrapper.qml index 00936ed..fa10251 100644 --- a/Modules/Notifications/Sidebar/Utils/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Utils/Wrapper.qml @@ -4,13 +4,11 @@ import QtQuick import Quickshell import ZShell.Config import qs.Components -import qs.Modules.Bar.Popouts as BarPopouts Item { id: root property real offsetScale: shouldBeActive ? 0 : 1 - required property BarPopouts.Wrapper popouts readonly property PersistentProperties props: PersistentProperties { property string recordingConfirmDelete property bool recordingListExpanded: false @@ -18,17 +16,14 @@ Item { reloadableId: "utilities" } - readonly property bool shouldBeActive: visibilities.sidebar + readonly property bool shouldBeActive: visibilities.sidebar && !sidebar.chatActive required property Item sidebar required property var visibilities anchors.bottomMargin: (-implicitHeight - 5) * offsetScale - clip: chatActive - implicitHeight: chatActive ? 21 : content.implicitHeight + 8 * 2 + implicitHeight: content.implicitHeight + Tokens.padding.small * 2 implicitWidth: sidebar.width * (1 - sidebar.offsetScale) - opacity: (1 - offsetScale) * chatFade - readonly property bool chatActive: sidebar.chatActive - property real chatFade: chatActive ? 0 : 1 + opacity: 1 - offsetScale visible: offsetScale < 1 Behavior on offsetScale { @@ -38,20 +33,6 @@ Item { } } - Behavior on chatFade { - Anim { - duration: Tokens.anim.durations.expressiveDefaultSpatial - easing: Tokens.anim.expressiveDefaultSpatial - } - } - - Behavior on implicitHeight { - Anim { - duration: Tokens.anim.durations.expressiveDefaultSpatial - easing: Tokens.anim.expressiveDefaultSpatial - } - } - Loader { id: content @@ -62,7 +43,6 @@ Item { sourceComponent: Content { implicitWidth: root.implicitWidth - 8 * 2 - popouts: root.popouts props: root.props visibilities: root.visibilities } diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt index be25202..05aca8c 100644 --- a/Plugins/ZShell/Llm/CMakeLists.txt +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -2,9 +2,12 @@ qml_module(ZShell-llm URI ZShell.Llm SOURCES chat.hpp chat.cpp - session.hpp session.cpp - message.hpp message.cpp chatstore.hpp chatstore.cpp + generation.hpp generation.cpp + llmclient.hpp llmclient.cpp + message.hpp message.cpp + messagemodel.hpp messagemodel.cpp + session.hpp session.cpp LIBRARIES Qt::Network Qt::Sql diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index 98e429e..08ae399 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -2,111 +2,119 @@ #include "config.hpp" #include "llm.hpp" +#include "llmclient.hpp" -#include #include -#include -#include -#include -#include -#include -#include -#include -namespace ZShell { +namespace ZShell::llm { -QString Chat::titleFrom(const QString& content) { - const QString flat = content.simplified(); - if (flat.isEmpty()) - return QString(); - if (flat.size() <= 48) - return flat; - return flat.left(47) + QStringLiteral("…"); -} - -QString Chat::completionsPath(const QString& endpoint, const QString& subpath) { - QString base = endpoint.trimmed(); - while (base.endsWith('/')) - base.chop(1); - if (!base.endsWith("/v1")) - base += "/v1"; - return base + subpath; -} - -QString Chat::serverErrorMessage( - const QByteArray& body, const QString& fallback) { - const QJsonDocument doc = QJsonDocument::fromJson(body); - if (doc.isObject()) { - const QJsonObject obj = doc.object(); - if (obj.contains("error")) { - const QJsonValue errorValue = obj["error"]; - if (errorValue.isObject()) { - const QString message = errorValue.toObject()["message"].toString(); - if (!message.isEmpty()) - return message; - } else if (!errorValue.toString().isEmpty()) { - return errorValue.toString(); - } - } - } - return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed") - : fallback; -} - -void Chat::setBusy(Chat* chat, bool value) { - if (chat->m_busy == value) - return; - chat->m_busy = value; - Q_EMIT chat->busyChanged(); -} - -void Chat::setStreamingChatId(Chat* chat, const QString& id) { - if (chat->m_streamingChatId == id) - return; - chat->m_streamingChatId = id; - Q_EMIT chat->streamingChatIdChanged(); -} - -Chat::Chat(QObject* parent) : QObject(parent), m_store(new ChatStore(this)) { +Chat::Chat(QObject* parent) +: QObject(parent), m_store(new ChatStore(this)), m_client(new LlmClient(this)) { if (!config::Config::instance()) new config::Config(); + m_store->setLlmClient(m_client); + const auto* llm = config::Config::instance()->llm(); - m_endpoint = llm->endpoint(); - m_temperature = llm->temperature(); - m_model = llm->model(); + m_client->setEndpoint(llm->endpoint()); + m_client->setModel(llm->model()); + m_client->setTemperature(llm->temperature()); connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { - if (m_endpoint != llm->endpoint()) { - m_endpoint = llm->endpoint(); - Q_EMIT endpointChanged(); - probeContextSize(); - } - if (m_model.isEmpty()) - refreshModels(); + m_client->setEndpoint(llm->endpoint()); }); connect(llm, &config::Llm::modelChanged, this, [this, llm]() { - if (m_model == llm->model()) - return; - m_model = llm->model(); - Q_EMIT modelChanged(); - if (m_model.isEmpty()) - refreshModels(); + m_client->setModel(llm->model()); + }); + connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() { + m_client->setTemperature(llm->temperature()); + }); + + connect(m_client, &LlmClient::busyChanged, this, [this]() { + // A fresh run supersedes the previous error. + if (m_client->busy() && !m_lastError.isEmpty()) { + m_lastError.clear(); + Q_EMIT lastErrorChanged(); + } + Q_EMIT busyChanged(); }); connect( - llm, - &config::Llm::temperatureChanged, + m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged); + connect( + m_client, &LlmClient::modelChanged, this, &Chat::modelChanged); + connect( + m_client, + &LlmClient::availableModelsChanged, this, - [this, llm]() { m_temperature = llm->temperature(); }); - - if (m_model.isEmpty()) - refreshModels(); - probeContextSize(); + &Chat::availableModelsChanged); + connect( + m_client, + &LlmClient::contextSizeChanged, + this, + &Chat::contextSizeChanged); + connect( + m_client, + &LlmClient::streamingChatIdChanged, + this, + &Chat::streamingChatIdChanged); + connect( + m_client, + &LlmClient::errorOccurred, + this, + [this](const QString& message) { + m_lastError = message; + Q_EMIT lastErrorChanged(); + Q_EMIT errorOccurred(message); + }); + connect( + m_store, + &ChatStore::sessionRemoved, + this, + [this](ChatSession* session) { m_client->sessionRemoved(session); }); + connect( + m_client, + &LlmClient::titleSuggested, + this, + [this](ChatSession* session, const QString& title) { + qInfo() << "Chat: applying generated title" << session->id() + << title << "(was" << session->title() << ")"; + session->setTitle(title); + m_store->saveMeta(session); + }); + connect( + m_client, + &LlmClient::iconSuggested, + this, + [this](ChatSession* session, const QString& icon) { + qInfo() << "Chat: applying generated icon" << session->id() + << icon << "(was" << session->icon() << ")"; + session->setIcon(icon); + m_store->saveMeta(session); + }); } -Chat::~Chat() { - if (m_reply) - m_reply->abort(); +bool Chat::busy() const { + return m_client->busy(); +} + +QString Chat::endpoint() const { + return m_client->endpoint(); +} + +QString Chat::model() const { + return m_client->model(); +} + +QStringList Chat::availableModels() const { + return m_client->availableModels(); +} + +int Chat::contextSize() const { + return m_client->contextSize(); +} + +QString Chat::streamingChatId() const { + return m_client->streamingChatId(); } Chat* Chat::s_instance = nullptr; @@ -117,283 +125,8 @@ Chat* Chat::create(QQmlEngine*, QJSEngine*) { return s_instance; } -void Chat::send(const QString& chatId, const QString& content) { - const QString text = content.trimmed(); - if (text.isEmpty() || m_busy) - return; - - ChatSession* session = m_store->sessionById(chatId); - if (!session) { - qWarning() << "Chat: unknown chat id" << chatId; - return; - } - - if (!m_lastError.isEmpty()) { - m_lastError.clear(); - Q_EMIT lastErrorChanged(); - } - - session->ensureLoaded(); - session->appendMessage( - ChatMessage::Role::User, - text, - QDateTime::currentMSecsSinceEpoch()); - while (session->messageCount() > 200) - session->removeMessage(session->messages().first()); - if (session->title().isEmpty()) { - session->setTitle(titleFrom(text)); - qInfo() << "Chat: new session" << chatId - << "fallback title" << session->title() - << "- requesting generated title and icon, model" << m_model - << "endpoint" << m_endpoint; - requestTitle(chatId, text); - requestIcon(chatId, text); - } - if (m_contextSize > 0 && m_lastTokenCount > m_contextSize * 4 / 5) - trimHistory(session, m_contextSize); - - m_active = session; - beginAssistant(); - m_store->persist(session); -} - -void Chat::beginAssistant() { - m_streaming = m_active->appendMessage( - ChatMessage::Role::Assistant, - QString(), - QDateTime::currentMSecsSinceEpoch()); - m_streaming->setStreaming(true); - setBusy(this, true); - setStreamingChatId(this, m_active->id()); - - const QUrl url = - QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); - if (!url.isValid() || url.host().isEmpty()) { - fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); - return; - } - - QNetworkRequest request(url); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader("Accept", "text/event-stream"); - - QJsonArray messages; - for (const auto* message : m_active->messages()) { - if (message == m_streaming) - continue; - QJsonObject messageObj; - messageObj[QStringLiteral("role")] = - message->role() == ChatMessage::Role::User - ? QStringLiteral("user") - : QStringLiteral("assistant"); - messageObj[QStringLiteral("content")] = message->content(); - if (!message->reasoning().isEmpty()) - messageObj[QStringLiteral("reasoning_content")] = message->reasoning(); - messages.append(messageObj); - } - - QJsonObject body; - QJsonObject streamOptions; - streamOptions[QStringLiteral("include_usage")] = true; - body[QStringLiteral("stream_options")] = streamOptions; - body[QStringLiteral("messages")] = messages; - body[QStringLiteral("stream")] = true; - body[QStringLiteral("temperature")] = m_temperature; - if (!m_model.isEmpty()) - body[QStringLiteral("model")] = m_model; - - m_buffer.clear(); - m_reply = m_manager.post(request, QJsonDocument(body).toJson()); - - connect(m_reply, &QNetworkReply::readyRead, this, [this]() { - if (m_reply) - m_buffer.append(m_reply->readAll()); - drainBuffer(); - }); - connect(m_reply, &QNetworkReply::finished, this, [this]() { - QNetworkReply* reply = m_reply; - if (!reply) - return; - m_reply = nullptr; - - const QNetworkReply::NetworkError error = reply->error(); - const QString errorString = reply->errorString(); - const QByteArray responseBody = reply->readAll(); - reply->deleteLater(); - - drainBuffer(); - if (!m_streaming) - return; - - if (error == QNetworkReply::NoError || - error == QNetworkReply::OperationCanceledError) - finalize(); - else - fail(serverErrorMessage(responseBody, errorString)); - }); -} - -void Chat::shortRequest( - const QString& tag, - const QString& systemPrompt, - const QString& userText, - std::function onResult) { - const QUrl url = - QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); - if (!url.isValid() || url.host().isEmpty()) - return; - - qInfo() << "Chat:" << tag << "request POST" << url.toString() - << "model=" << m_model; - - QNetworkRequest request(url); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader("Accept", "application/json"); - - QJsonArray messages; - QJsonObject system; - system[QStringLiteral("role")] = QStringLiteral("system"); - system[QStringLiteral("content")] = systemPrompt; - messages.append(system); - QJsonObject user; - user[QStringLiteral("role")] = QStringLiteral("user"); - user[QStringLiteral("content")] = userText.simplified().mid(0, 512); - messages.append(user); - - QJsonObject body; - if (!m_model.isEmpty()) - body[QStringLiteral("model")] = m_model; - body[QStringLiteral("stream")] = false; - body[QStringLiteral("temperature")] = 0.3; - body[QStringLiteral("max_tokens")] = 128; - QJsonObject templateKwargs; - templateKwargs[QStringLiteral("enable_thinking")] = false; - body[QStringLiteral("chat_template_kwargs")] = templateKwargs; - body[QStringLiteral("messages")] = messages; - - auto* reply = m_manager.post(request, QJsonDocument(body).toJson()); - connect( - reply, - &QNetworkReply::finished, - this, - [this, reply, tag, onResult = std::move(onResult)]() { - const QByteArray data = reply->readAll(); - reply->deleteLater(); - - qInfo() << "Chat:" << tag << "request finished" - << "error=" << reply->error() << reply->errorString() - << "http=" << reply->attribute( - QNetworkRequest::HttpStatusCodeAttribute).toInt() - << "response=" - << QString::fromUtf8(data.left(400)).simplified(); - - if (reply->error() != QNetworkReply::NoError) - return; - - const QJsonDocument doc = QJsonDocument::fromJson(data); - const QJsonArray choices = - doc.object()[QStringLiteral("choices")].toArray(); - if (choices.isEmpty()) - return; - const QString result = - choices.at(0).toObject()[QStringLiteral("message")].toObject() - [QStringLiteral("content")].toString() - .trimmed(); - qInfo() << "Chat:" << tag << "raw result" << result; - onResult(result); - }); -} - -void Chat::requestTitle(const QString& chatId, const QString& userText) { - shortRequest( - QStringLiteral("title"), - QStringLiteral( - "Write a short, concise title for a chat conversation starting " - "with the user's message below. At most six words, no quotation " - "marks, no trailing punctuation. Reply with the title only."), - userText, - [this, chatId](QString title) { - ChatSession* session = m_store->sessionById(chatId); - if (!session) { - qWarning() << "Chat: title request: session gone" << chatId; - return; - } - const auto isQuote = [](QChar c) { - return c == QLatin1Char('"') || c == QLatin1Char('\'') || - c == QChar(u'\u201C') || c == QChar(u'\u201D') || - c == QChar(u'\u2018') || c == QChar(u'\u2019'); - }; - while (title.size() >= 2 && isQuote(title.at(0)) && - isQuote(title.at(title.size() - 1))) - title = title.mid(1, title.size() - 2).simplified(); - while (!title.isEmpty() && - (title.endsWith(QLatin1Char('.')) || - title.endsWith(QLatin1Char('!')) || - title.endsWith(QLatin1Char('?')))) - title.chop(1); - if (title.size() < 2) { - qWarning() << "Chat: title rejected (too short)" << chatId - << title; - return; - } - if (title.size() > 48) - title = title.left(47) + QStringLiteral("…"); - qInfo() << "Chat: applying generated title" << chatId << title - << "(was" << session->title() << ")"; - session->setTitle(title); - m_store->saveMeta(session); - }); -} - -void Chat::requestIcon(const QString& chatId, const QString& userText) { - const QStringList icons = { - "chat", "lightbulb", "code", - "description", "article", "school", - "work", "build", "science", - "palette", "music_note", "sports_esports", - "takeout_dining", "flight", "photo_camera", - "psychology_alt", "favorite", "savings", - "gamepad", "auto_awesome", - }; - const QString prompt = - QStringLiteral( - "Pick the single icon name from this list that best matches the " - "topic of the user's message below: %1. Reply with only the icon " - "name, exactly as written in the list, and nothing else.") - .arg(icons.join(QStringLiteral(", "))); - shortRequest( - QStringLiteral("icon"), - prompt, - userText, - [this, chatId, icons](QString name) { - ChatSession* session = m_store->sessionById(chatId); - if (!session) { - qWarning() << "Chat: icon request: session gone" << chatId; - return; - } - name = name.simplified().toLower(); - while (name.size() >= 2 && - (name.at(0) == QLatin1Char('"') || - name.at(0) == QLatin1Char('\''))) - name = name.mid(1).left(name.size() - 2).simplified(); - name.replace(QLatin1Char(' '), QLatin1Char('_')); - if (!icons.contains(name)) { - qWarning() << "Chat: icon not in list, using default" << chatId - << name; - name = QStringLiteral("chat"); - } - qInfo() << "Chat: applying generated icon" << chatId << name - << "(was" << session->icon() << ")"; - session->setIcon(name); - m_store->saveMeta(session); - }); -} - void Chat::stop() { - if (!m_busy) - return; - if (m_reply) - m_reply->abort(); + m_client->stop(); } void Chat::dismissError() { @@ -403,256 +136,16 @@ void Chat::dismissError() { Q_EMIT lastErrorChanged(); } -void Chat::clearConversation(const QString& chatId) { - ChatSession* session = m_store->sessionById(chatId); - if (!session) - return; - if (m_busy && m_active == session) { - m_pendingClearChatId = chatId; - stop(); - return; - } - session->ensureLoaded(); - session->clearMessages(); - m_store->persist(session); -} - -void Chat::finalize() { - if (!m_streaming) - return; - - ChatSession* session = m_active; - endStream(); - if (session && m_pendingClearChatId == session->id()) { - session->clearMessages(); - m_pendingClearChatId.clear(); - } - if (session) - m_store->persist(session); -} - -void Chat::endStream() { - if (!m_streaming) - return; - m_streaming->setStreaming(false); - if (m_streaming->content().isEmpty() && m_streaming->reasoning().isEmpty() && m_active) - m_active->removeMessage(m_streaming); - m_streaming = nullptr; - m_active = nullptr; - setBusy(this, false); - setStreamingChatId(this, QString()); -} - -void Chat::fail(const QString& message) { - qWarning() << "Chat:" << message; - - const bool overflow = message.contains("overflow") || - message.contains("exceed") || - m_lastTokenCount > m_contextSize; - const QString shown = overflow - ? QStringLiteral( - "%1 (using ~%2 of %3 tokens; the oldest messages were auto-removed " - "so the next one will fit)") - .arg(message, QString::number(m_lastTokenCount), - QString::number(m_contextSize)) - : message; - m_lastError = shown; - Q_EMIT lastErrorChanged(); - - if (m_streaming) { - ChatSession* session = m_active; - endStream(); - if (overflow) - trimHistory(session, m_contextSize); - if (session && m_pendingClearChatId == session->id()) { - session->clearMessages(); - m_pendingClearChatId.clear(); - } - if (session) - m_store->persist(session); - } - Q_EMIT errorOccurred(shown); -} - -void Chat::drainBuffer() { - while (true) { - const qsizetype newline = m_buffer.indexOf('\n'); - if (newline < 0) - break; - const QByteArray line = m_buffer.left(newline).trimmed(); - m_buffer.remove(0, newline + 1); - handleLine(line); - } -} - -void Chat::handleLine(const QByteArray& line) { - if (line.isEmpty() || line.startsWith('#') || !line.startsWith("data:")) - return; - - const QByteArray data = line.mid(5).trimmed(); - if (data == "[DONE]") { - finalize(); - return; - } - - const QJsonDocument doc = QJsonDocument::fromJson(data); - if (!doc.isObject()) - return; - const QJsonObject obj = doc.object(); - updateTokenUsage(obj); - - if (obj.contains("error")) { - const QJsonObject error = obj["error"].toObject(); - const QString message = error["message"].toString(); - fail(message.isEmpty() ? QStringLiteral("LLM server returned an error") : message); - return; - } - - for (const QJsonValue& choiceValue : obj["choices"].toArray()) { - const QJsonObject delta = choiceValue.toObject()["delta"].toObject(); - if (!m_streaming) - continue; - m_streaming->appendContent(delta["content"].toString()); - QString reasoning = delta["reasoning_content"].toString(); - if (reasoning.isEmpty()) - reasoning = delta["reasoning"].toString(); - m_streaming->appendReasoning(reasoning); - } -} - void Chat::refreshModels() { - const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); - if (!url.isValid() || url.host().isEmpty()) - return; - - auto* reply = m_manager.get(QNetworkRequest(url)); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QNetworkReply::NetworkError error = reply->error(); - const QByteArray data = reply->readAll(); - reply->deleteLater(); - - if (error != QNetworkReply::NoError) { - qWarning() << "Chat: failed to fetch models:" << error; - return; - } - const QJsonArray dataArr = - QJsonDocument::fromJson(data).object()["data"].toArray(); - - QStringList models; - QSet seen; - for (const QJsonValue& value : dataArr) { - const QString id = value.toObject()["id"].toString(); - if (!id.isEmpty() && !seen.contains(id)) { - seen.insert(id); - models.append(id); - } - } - if (models.isEmpty()) - return; - - m_availableModels = models; - Q_EMIT availableModelsChanged(); - - if (m_model.isEmpty()) { - m_model = models.first(); - Q_EMIT modelChanged(); - } - }); + m_client->refreshModels(); } void Chat::selectModel(const QString& id) { if (id.isEmpty()) return; - m_model = id; - Q_EMIT modelChanged(); - config::Config::instance()->llm()->set_model(id); + m_client->setModel(id); + if (auto* config = config::Config::instance()) + config->llm()->set_model(id); } -void Chat::setContextSize(int size) { - if (size <= 0 || m_contextSize == size) - return; - m_contextSize = size; - Q_EMIT contextSizeChanged(); -} - -void Chat::probeContextSize() { - QString base = m_endpoint.trimmed(); - while (base.endsWith('/')) - base.chop(1); - const QUrl url = QUrl::fromUserInput(base + "/props"); - if (!url.isValid() || url.host().isEmpty()) - return; - - auto* reply = m_manager.get(QNetworkRequest(url)); - connect( - reply, - &QNetworkReply::finished, - this, - [this, reply]() { - const QNetworkReply::NetworkError error = reply->error(); - const QByteArray data = reply->readAll(); - reply->deleteLater(); - - int size = 0; - if (error == QNetworkReply::NoError) { - const QJsonDocument doc = QJsonDocument::fromJson(data); - if (doc.isArray()) { - for (const auto& value : doc.array()) { - const QJsonObject slot = value.toObject(); - if (slot.contains("n_ctx")) { - size = slot["n_ctx"].toInt(0); - if (size > 0) - break; - } - } - } else if (doc.isObject()) { - const QJsonObject obj = doc.object(); - size = obj["n_ctx"].toInt(0); - if (size <= 0) - size = obj["default_generation_settings"].toObject()["n_ctx"].toInt(0); - } - } - setContextSize(size > 0 ? size : 4096); - }); -} - -void Chat::updateTokenUsage(const QJsonObject& data) { - if (m_contextSize <= 0) - return; - const QJsonObject usage = data["usage"].toObject(); - if (usage.isEmpty()) - return; - const double used = usage.value("prompt_tokens").toDouble() + - usage.value("completion_tokens").toDouble(); - if (used > 0) - m_lastTokenCount = qMin(static_cast(used), m_contextSize * 2); -} - -void Chat::trimHistory(ChatSession* session, int contextSize) { - if (!session || contextSize <= 0) - return; - const int budget = contextSize * 4 / 5; - auto estimate = [&](const ChatMessage* message) -> qsizetype { - return (message->content().size() + - message->reasoning().size()) / - 4; - }; - qsizetype total = 0; - for (const auto* message : session->messages()) - total += estimate(message); - while (total > budget && session->messageCount() >= 2) { - ChatMessage* first = session->messages().first(); - const qsizetype used = estimate(first); - session->removeMessage(first); - total -= used; - if (session->messageCount() >= 2 && - session->messages().first()->role() == ChatMessage::Role::Assistant) { - ChatMessage* second = session->messages().first(); - const qsizetype usedSecond = estimate(second); - session->removeMessage(second); - total -= usedSecond; - } - } -} - -} // namespace ZShell +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp index 3270668..c9e98ac 100644 --- a/Plugins/ZShell/Llm/chat.hpp +++ b/Plugins/ZShell/Llm/chat.hpp @@ -1,23 +1,22 @@ #pragma once -#include -#include -#include #include #include #include #include -#include - #include "chatstore.hpp" class QQmlEngine; class QJSEngine; -class QNetworkReply; -namespace ZShell { +namespace ZShell::llm { +class LlmClient; + +// QML-facing facade. Persistence lives in ChatStore, network streaming in +// LlmClient; this class only wires them together and exposes the +// application-wide state. class Chat : public QObject { Q_OBJECT QML_ELEMENT @@ -29,27 +28,22 @@ class Chat : public QObject { Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged) Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged) Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) - Q_PROPERTY(ChatStore* chats READ chats CONSTANT) + Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged) public: explicit Chat(QObject* parent = nullptr); - ~Chat(); - [[nodiscard]] bool busy() const { return m_busy; } - [[nodiscard]] QString endpoint() const { return m_endpoint; } - [[nodiscard]] QString model() const { return m_model; } - [[nodiscard]] QStringList availableModels() const { return m_availableModels; } - [[nodiscard]] int contextSize() const { return m_contextSize; } - [[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; } + [[nodiscard]] bool busy() const; + [[nodiscard]] QString endpoint() const; + [[nodiscard]] QString model() const; + [[nodiscard]] QStringList availableModels() const; + [[nodiscard]] int contextSize() const; [[nodiscard]] QString lastError() const { return m_lastError; } [[nodiscard]] ChatStore* chats() const { return m_store; } - [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } - [[nodiscard]] ChatSession* streamingSession() const { return m_active; } + [[nodiscard]] QString streamingChatId() const; - Q_INVOKABLE void send(const QString& chatId, const QString& content); Q_INVOKABLE void stop(); - Q_INVOKABLE void clearConversation(const QString& chatId); Q_INVOKABLE void dismissError(); Q_INVOKABLE void refreshModels(); Q_INVOKABLE void selectModel(const QString& id); @@ -67,51 +61,11 @@ class Chat : public QObject { void streamingChatIdChanged(); private: - void beginAssistant(); - void endStream(); - void finalize(); - void fail(const QString& message); - void handleLine(const QByteArray& line); - void drainBuffer(); - void probeContextSize(); - void setContextSize(int size); - void updateTokenUsage(const QJsonObject& data); - static void trimHistory(ChatSession* session, int contextSize); - - void shortRequest( - const QString& tag, - const QString& systemPrompt, - const QString& userText, - std::function onResult); - void requestTitle(const QString& chatId, const QString& userText); - void requestIcon(const QString& chatId, const QString& userText); - static QString titleFrom(const QString& content); - static QString completionsPath(const QString& endpoint, const QString& subpath); - static QString serverErrorMessage( - const QByteArray& body, const QString& fallback); - static void setBusy(Chat* chat, bool value); - static void setStreamingChatId(Chat* chat, const QString& id); - - QNetworkAccessManager m_manager; ChatStore* m_store = nullptr; - QNetworkReply* m_reply = nullptr; - QByteArray m_buffer; - ChatSession* m_active = nullptr; - ChatMessage* m_streaming = nullptr; - QString m_pendingClearChatId; - bool m_busy = false; - QString m_endpoint; - QString m_model; - QStringList m_availableModels; + LlmClient* m_client = nullptr; QString m_lastError; - QString m_streamingChatId; - double m_temperature = 0.7; - int m_contextSize = 0; - int m_lastTokenCount = 0; static Chat* s_instance; - - friend class ChatStore; }; -} // namespace ZShell +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp index 7990323..3d4b286 100644 --- a/Plugins/ZShell/Llm/chatstore.cpp +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -1,6 +1,6 @@ #include "chatstore.hpp" -#include "chat.hpp" +#include "llmclient.hpp" #include "message.hpp" #include @@ -15,7 +15,7 @@ #include -namespace ZShell { +namespace ZShell::llm { ChatStore::ChatStore(QObject* parent) : QObject(parent), m_connectionName(QUuid::createUuid().toString()) { @@ -67,38 +67,42 @@ void ChatStore::openDb() { ")")); } { - QSqlQuery info(db); - bool hasIcon = false; - if (info.exec(QStringLiteral("PRAGMA table_info(sessions)"))) - while (info.next()) - if (info.value(1).toString() == QLatin1String("icon")) { - hasIcon = true; - break; - } - if (!hasIcon) { - QSqlQuery alter(db); - if (!alter.exec(QStringLiteral( - "ALTER TABLE sessions ADD COLUMN icon TEXT NOT NULL " - "DEFAULT ''"))) - qWarning() << "ChatStore: failed to add icon column:" - << alter.lastError().text(); - } + QSqlQuery query(db); + query.exec( + QStringLiteral( + "CREATE TABLE IF NOT EXISTS messages (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " session_id TEXT NOT NULL REFERENCES sessions (id) " + "ON DELETE CASCADE,\n" + " role TEXT NOT NULL,\n" + " timestamp INTEGER NOT NULL\n" + ")")); } { QSqlQuery query(db); query.exec( QStringLiteral( - "CREATE TABLE IF NOT EXISTS messages (\n" - " session_id TEXT NOT NULL REFERENCES sessions (id) ON DELETE " - "CASCADE,\n" - " role TEXT NOT NULL,\n" - " content TEXT,\n" - " reasoning TEXT,\n" + "CREATE TABLE IF NOT EXISTS generations (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " message_id INTEGER NOT NULL REFERENCES messages " + "(id) ON DELETE CASCADE,\n" + " content TEXT,\n" + " reasoning TEXT,\n" " timestamp INTEGER NOT NULL,\n" " reasoning_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" - " content_elapsed_ms INTEGER NOT NULL DEFAULT 0\n" + " content_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" + " is_active INTEGER NOT NULL DEFAULT 1\n" ")")); } + { + QSqlQuery query(db); + query.exec(QStringLiteral( + "CREATE INDEX IF NOT EXISTS idx_messages_session " + "ON messages (session_id)")); + query.exec(QStringLiteral( + "CREATE INDEX IF NOT EXISTS idx_generations_message " + "ON generations (message_id)")); + } } int ChatStore::count() const { @@ -121,7 +125,7 @@ ChatSession* ChatStore::at(int index) const { ChatSession* ChatStore::insert(int index) { const qint64 now = QDateTime::currentMSecsSinceEpoch(); - const QString id = QString::number(now); + const QString id = QUuid::createUuid().toString(); { QSqlQuery query(db()); query.prepare( @@ -136,8 +140,7 @@ ChatSession* ChatStore::insert(int index) { } auto* session = new ChatSession(id, this); session->setMeta(QString(), now, now, 0); - const int pos = - index >= 0 && index <= m_sessions.size() ? index : 0; + const int pos = index >= 0 && index <= m_sessions.size() ? index : 0; m_sessions.insert(pos, session); Q_EMIT countChanged(); Q_EMIT valuesChanged(); @@ -155,12 +158,8 @@ void ChatStore::remove(ChatSession* chat) { void ChatStore::removeSession(ChatSession* session) { if (!session || !m_sessions.contains(session)) return; - if (auto* chat = qobject_cast(parent())) - if (chat->m_active == session) { - chat->stop(); - chat->endStream(); - } const QList before = m_sessions; + Q_EMIT sessionRemoved(session); { QSqlQuery query(db()); query.prepare("DELETE FROM sessions WHERE id = :id"); @@ -174,7 +173,7 @@ void ChatStore::removeSession(ChatSession* session) { void ChatStore::move(int from, int to) { if (from < 0 || from >= m_sessions.size() || to < 0 || - to >= m_sessions.size() || from == to) + to >= m_sessions.size() || from == to) return; m_sessions.move(from, to); Q_EMIT valuesChanged(); @@ -193,6 +192,10 @@ ChatSession* ChatStore::sessionById(const QString& id) { return nullptr; } +void ChatStore::setLlmClient(LlmClient* client) { + m_llmClient = client; +} + void ChatStore::persist(ChatSession* session) { if (!session || !m_sessions.contains(session)) return; @@ -243,30 +246,58 @@ bool ChatStore::saveSession(ChatSession* session) { ok = query.exec(); } if (ok) { - QSqlQuery insert(handle); - ok = insert.prepare( - "INSERT INTO messages (session_id, role, content, reasoning, " - "timestamp, reasoning_elapsed_ms, content_elapsed_ms) " - "VALUES (:id, :role, :content, :reasoning, :timestamp, " - ":reasoning_elapsed_ms, :content_elapsed_ms)"); - for (const auto* message : session->messages()) { - insert.bindValue(":id", session->id()); - insert.bindValue( + QSqlQuery messageInsert(handle); + ok = messageInsert.prepare( + "INSERT INTO messages (session_id, role, timestamp) " + "VALUES (:id, :role, :timestamp)"); + QSqlQuery generationInsert(handle); + ok = ok && generationInsert.prepare( + "INSERT INTO generations (message_id, content, reasoning, " + "timestamp, reasoning_elapsed_ms, content_elapsed_ms, " + "is_active) VALUES (:mid, :content, :reasoning, :timestamp, " + ":reasoning_elapsed_ms, :content_elapsed_ms, :is_active)"); + // The model holds messages most recent first; the database keeps + // natural rowid order, so iterate from the oldest row up. + const auto* model = session->messagesModel(); + for (int row = model->rowCount() - 1; ok && row >= 0; --row) { + const auto* message = model->at(row); + messageInsert.bindValue(":id", session->id()); + messageInsert.bindValue( ":role", message->role() == ChatMessage::Role::User ? QStringLiteral("user") : QStringLiteral("assistant")); - insert.bindValue(":content", message->content()); - insert.bindValue(":reasoning", message->reasoning()); - insert.bindValue(":timestamp", message->timestamp()); - insert.bindValue(":reasoning_elapsed_ms", message->reasoningElapsedMs()); - insert.bindValue(":content_elapsed_ms", message->contentElapsedMs()); - if (!insert.exec()) { + messageInsert.bindValue(":timestamp", message->timestamp()); + if (!messageInsert.exec()) { ok = false; - qWarning() << "ChatStore: saveSession" << id << "insert failed:" - << insert.lastError().text(); + qWarning() << "ChatStore: saveSession" << id + << "message insert failed:" + << messageInsert.lastError().text(); break; } + const int messageId = messageInsert.lastInsertId().toInt(); + for (int i = 0; ok && i < message->generationCount(); ++i) { + const auto* generation = message->generation(i); + generationInsert.bindValue(":mid", messageId); + generationInsert.bindValue(":content", generation->content()); + generationInsert.bindValue( + ":reasoning", generation->reasoning()); + generationInsert.bindValue(":timestamp", generation->timestamp()); + generationInsert.bindValue( + ":reasoning_elapsed_ms", generation->reasoningElapsedMs()); + generationInsert.bindValue( + ":content_elapsed_ms", generation->contentElapsedMs()); + generationInsert.bindValue( + ":is_active", + i == message->activeGenerationIndex() ? 1 : 0); + if (!generationInsert.exec()) { + ok = false; + qWarning() << "ChatStore: saveSession" << id + << "generation insert failed:" + << generationInsert.lastError().text(); + break; + } + } } } if (!ok || !handle.commit()) { @@ -280,28 +311,52 @@ bool ChatStore::saveSession(ChatSession* session) { } void ChatStore::loadMessagesInto(ChatSession* session) { + // Newest first so the model receives rows in display order. QSqlQuery query(db()); query.prepare( - "SELECT role, content, reasoning, timestamp, reasoning_elapsed_ms, " - "content_elapsed_ms FROM messages WHERE session_id = :id ORDER BY rowid"); + "SELECT id, role, timestamp FROM messages WHERE session_id = :id " + "ORDER BY rowid DESC"); query.bindValue(":id", session->id()); if (!query.exec()) { qWarning() << "ChatStore: failed to load messages for" << session->id() << ":" << query.lastError().text(); return; } + auto* model = session->messagesModel(); QList messages; while (query.next()) { - auto* message = new ChatMessage( - query.value(0).toString() == QLatin1String("user") + const int messageId = query.value(0).toInt(); + auto* message = model->createMessage( + query.value(1).toString() == QLatin1String("user") ? ChatMessage::Role::User : ChatMessage::Role::Assistant, - query.value(1).toString(), - query.value(3).toLongLong(), - session); - message->setReasoning(query.value(2).toString()); - message->setElapsedMs( - query.value(4).toLongLong(), query.value(5).toLongLong()); + query.value(2).toLongLong()); + QSqlQuery generationQuery(db()); + generationQuery.prepare( + "SELECT content, reasoning, timestamp, reasoning_elapsed_ms, " + "content_elapsed_ms, is_active FROM generations " + "WHERE message_id = :mid ORDER BY rowid"); + generationQuery.bindValue(":mid", messageId); + int activeIndex = 0; + if (generationQuery.exec()) { + int index = 0; + while (generationQuery.next()) { + message->addGeneration( + generationQuery.value(2).toLongLong(), + generationQuery.value(0).toString(), + generationQuery.value(1).toString(), + generationQuery.value(3).toLongLong(), + generationQuery.value(4).toLongLong()); + if (generationQuery.value(5).toInt() != 0) + activeIndex = index; + ++index; + } + } else { + qWarning() << "ChatStore: failed to load generations for message" + << messageId << ":" + << generationQuery.lastError().text(); + } + message->setActiveGeneration(activeIndex); messages.append(message); } session->adoptMessages(messages); @@ -351,4 +406,4 @@ void ChatStore::notify(const QList& before) { Q_EMIT valuesChanged(); } -} // namespace ZShell +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chatstore.hpp b/Plugins/ZShell/Llm/chatstore.hpp index 8526216..472f4a4 100644 --- a/Plugins/ZShell/Llm/chatstore.hpp +++ b/Plugins/ZShell/Llm/chatstore.hpp @@ -7,9 +7,9 @@ #include #include -namespace ZShell { +namespace ZShell::llm { -class Chat; +class LlmClient; class ChatStore : public QObject { Q_OBJECT @@ -20,19 +20,22 @@ class ChatStore : public QObject { public: explicit ChatStore(QObject* parent = nullptr); - ~ChatStore(); + ~ChatStore() override; [[nodiscard]] int count() const; [[nodiscard]] QVariantList values() const; [[nodiscard]] ChatSession* at(int index) const; - Q_INVOKABLE ChatSession* insert(int index = -1); + Q_INVOKABLE ZShell::llm::ChatSession* insert(int index = -1); Q_INVOKABLE void remove(int index); - Q_INVOKABLE void remove(ChatSession* chat); + Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat); Q_INVOKABLE void move(int from, int to); Q_INVOKABLE void clear(); [[nodiscard]] ChatSession* sessionById(const QString& id); + [[nodiscard]] LlmClient* llmClient() const { return m_llmClient; } + void setLlmClient(LlmClient* client); + void persist(ChatSession* session); void saveMeta(ChatSession* session); void loadMessagesInto(ChatSession* session); @@ -40,6 +43,7 @@ class ChatStore : public QObject { Q_SIGNALS: void countChanged(); void valuesChanged(); + void sessionRemoved(ZShell::llm::ChatSession* session); private: void openDb(); @@ -50,11 +54,10 @@ class ChatStore : public QObject { void notify(const QList& before); QList m_sessions; + LlmClient* m_llmClient = nullptr; QString m_connectionName; [[nodiscard]] QSqlDatabase db() const; - - friend class Chat; }; -} // namespace ZShell +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/generation.cpp b/Plugins/ZShell/Llm/generation.cpp new file mode 100644 index 0000000..08203d9 --- /dev/null +++ b/Plugins/ZShell/Llm/generation.cpp @@ -0,0 +1,126 @@ +#include "generation.hpp" + +#include + +namespace ZShell::llm { + +ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent) +: QObject(parent), m_timestamp(timestamp) { + m_timer.setParent(this); + m_timer.setInterval(500); + m_timer.setTimerType(Qt::CoarseTimer); + connect(&m_timer, &QTimer::timeout, this, [this]() { + if (!reasoningInFlight() && !contentInFlight()) { + m_timer.stop(); + return; + } + Q_EMIT elapsedMsChanged(); + }); +} + +qint64 ChatGeneration::reasoningElapsedMs() const { + if (m_reasoningStartedAt <= 0) + return 0; + const qint64 end = m_reasoningEndedAt > 0 + ? m_reasoningEndedAt + : QDateTime::currentMSecsSinceEpoch(); + return end - m_reasoningStartedAt; +} + +qint64 ChatGeneration::contentElapsedMs() const { + if (m_contentStartedAt <= 0) + return 0; + const qint64 end = m_contentEndedAt > 0 + ? m_contentEndedAt + : QDateTime::currentMSecsSinceEpoch(); + return end - m_contentStartedAt; +} + +void ChatGeneration::updateReasoningActive() { + const bool active = m_streaming && m_content.isEmpty(); + if (m_reasoningActive == active) + return; + m_reasoningActive = active; + Q_EMIT reasoningActiveChanged(); +} + +void ChatGeneration::setContent(const QString& value) { + if (m_content == value) + return; + m_content = value; + Q_EMIT contentChanged(); +} + +void ChatGeneration::setReasoning(const QString& value) { + if (m_reasoning == value) + return; + m_reasoning = value; + Q_EMIT reasoningChanged(); +} + +void ChatGeneration::appendContent(const QString& piece) { + if (piece.isEmpty()) + return; + if (m_content.isEmpty()) { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (reasoningInFlight()) + m_reasoningEndedAt = now; + m_contentStartedAt = now; + if (!m_timer.isActive()) + m_timer.start(); + } + m_content += piece; + Q_EMIT contentChanged(); + updateReasoningActive(); + Q_EMIT elapsedMsChanged(); +} + +void ChatGeneration::appendReasoning(const QString& piece) { + if (piece.isEmpty()) + return; + if (m_reasoning.isEmpty()) { + if (m_reasoningStartedAt <= 0) + m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); + if (!m_timer.isActive()) + m_timer.start(); + } + m_reasoning += piece; + Q_EMIT reasoningChanged(); + updateReasoningActive(); + Q_EMIT elapsedMsChanged(); +} + +void ChatGeneration::setElapsedMs(qint64 reasoningMs, qint64 contentMs) { + if (reasoningMs > 0) { + m_reasoningStartedAt = m_timestamp; + m_reasoningEndedAt = m_timestamp + reasoningMs; + } + if (contentMs > 0) { + m_contentStartedAt = m_timestamp; + m_contentEndedAt = m_timestamp + contentMs; + } +} + +void ChatGeneration::setStreaming(bool value) { + if (m_streaming == value) + return; + m_streaming = value; + Q_EMIT streamingChanged(); + if (value) { + if (m_reasoningStartedAt <= 0) + m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); + if (!m_timer.isActive()) + m_timer.start(); + } else { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (reasoningInFlight()) + m_reasoningEndedAt = now; + if (contentInFlight()) + m_contentEndedAt = now; + m_timer.stop(); + Q_EMIT elapsedMsChanged(); + } + updateReasoningActive(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/generation.hpp b/Plugins/ZShell/Llm/generation.hpp new file mode 100644 index 0000000..0ec1a83 --- /dev/null +++ b/Plugins/ZShell/Llm/generation.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include + +namespace ZShell::llm { + +class ChatGeneration : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat generations are managed by ChatMessage") + + Q_PROPERTY(QString content READ content WRITE setContent NOTIFY contentChanged) + Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) + Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) + Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) + Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + + public: + explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); + + [[nodiscard]] QString content() const { return m_content; } + [[nodiscard]] QString reasoning() const { return m_reasoning; } + [[nodiscard]] bool reasoningActive() const { return m_reasoningActive; } + [[nodiscard]] qint64 reasoningElapsedMs() const; + [[nodiscard]] qint64 contentElapsedMs() const; + [[nodiscard]] bool streaming() const { return m_streaming; } + [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + + void setContent(const QString& value); + void setReasoning(const QString& value); + void appendContent(const QString& piece); + void appendReasoning(const QString& piece); + void setElapsedMs(qint64 reasoningMs, qint64 contentMs); + void setStreaming(bool value); + + Q_SIGNALS: + void contentChanged(); + void reasoningChanged(); + void reasoningActiveChanged(); + void elapsedMsChanged(); + void streamingChanged(); + + private: + void updateReasoningActive(); + + [[nodiscard]] bool reasoningInFlight() const { + return m_reasoningStartedAt > 0 && m_reasoningEndedAt <= 0; + } + [[nodiscard]] bool contentInFlight() const { + return m_contentStartedAt > 0 && m_contentEndedAt <= 0; + } + + QTimer m_timer; + QString m_content; + QString m_reasoning; + bool m_reasoningActive = false; + bool m_streaming = false; + qint64 m_timestamp; + qint64 m_reasoningStartedAt = 0; + qint64 m_reasoningEndedAt = 0; + qint64 m_contentStartedAt = 0; + qint64 m_contentEndedAt = 0; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp new file mode 100644 index 0000000..5f4e1a5 --- /dev/null +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -0,0 +1,568 @@ +#include "llmclient.hpp" + +#include "message.hpp" +#include "messagemodel.hpp" +#include "session.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +QString LlmClient::completionsPath( + const QString& endpoint, const QString& subpath) { + QString base = endpoint.trimmed(); + while (base.endsWith('/')) + base.chop(1); + if (!base.endsWith("/v1")) + base += "/v1"; + return base + subpath; +} + +QString LlmClient::serverErrorMessage( + const QByteArray& body, const QString& fallback) { + const QJsonDocument doc = QJsonDocument::fromJson(body); + if (doc.isObject()) { + const QJsonObject obj = doc.object(); + if (obj.contains("error")) { + const QJsonValue errorValue = obj["error"]; + if (errorValue.isObject()) { + const QString message = + errorValue.toObject()["message"].toString(); + if (!message.isEmpty()) + return message; + } else if (!errorValue.toString().isEmpty()) { + return errorValue.toString(); + } + } + } + return fallback.isEmpty() + ? QStringLiteral("Request to LLM server failed") + : fallback; +} + +void LlmClient::setBusy(bool value) { + if (m_busy == value) + return; + m_busy = value; + Q_EMIT busyChanged(); +} + +void LlmClient::setStreamingChatId(const QString& id) { + if (m_streamingChatId == id) + return; + m_streamingChatId = id; + Q_EMIT streamingChatIdChanged(); +} + +LlmClient::LlmClient(QObject* parent) : QObject(parent) { + probeContextSize(); +} + +LlmClient::~LlmClient() { + if (m_reply) + m_reply->abort(); + endStream(); +} + +void LlmClient::setEndpoint(const QString& value) { + if (m_endpoint == value) + return; + m_endpoint = value; + Q_EMIT endpointChanged(); + probeContextSize(); + if (m_model.isEmpty()) + refreshModels(); +} + +void LlmClient::setModel(const QString& value) { + if (m_model == value) + return; + m_model = value; + Q_EMIT modelChanged(); + if (m_model.isEmpty()) + refreshModels(); +} + +void LlmClient::setTemperature(double value) { + m_temperature = value; +} + +void LlmClient::startGeneration( + ChatSession* session, ChatGeneration* target) { + if (m_busy || !session || !target) + return; + m_active = session; + m_streaming = target; + m_streaming->setStreaming(true); + setBusy(true); + setStreamingChatId(session->id()); + + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); + if (!url.isValid() || url.host().isEmpty()) { + fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); + return; + } + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "text/event-stream"); + + const auto* model = session->messagesModel(); + const int targetRow = + model->rowOf(qobject_cast(target->parent())); + if (targetRow < 0) { + fail(QStringLiteral("Internal error: generation target is not in the " + "session")); + return; + } + + // Context: every message older than the target, oldest first. + QJsonArray messages; + for (int row = model->rowCount() - 1; row > targetRow; --row) { + const auto* message = model->at(row); + const auto* generation = message->activeGeneration(); + if (!generation) + continue; + QJsonObject messageObj; + messageObj[QStringLiteral("role")] = + message->role() == ChatMessage::Role::User + ? QStringLiteral("user") + : QStringLiteral("assistant"); + messageObj[QStringLiteral("content")] = generation->content(); + if (!generation->reasoning().isEmpty()) + messageObj[QStringLiteral("reasoning_content")] = + generation->reasoning(); + messages.append(messageObj); + } + + QJsonObject body; + QJsonObject streamOptions; + streamOptions[QStringLiteral("include_usage")] = true; + body[QStringLiteral("stream_options")] = streamOptions; + body[QStringLiteral("messages")] = messages; + body[QStringLiteral("stream")] = true; + body[QStringLiteral("temperature")] = m_temperature; + if (!m_model.isEmpty()) + body[QStringLiteral("model")] = m_model; + + m_buffer.clear(); + m_reply = m_manager.post(request, QJsonDocument(body).toJson()); + + connect(m_reply, &QNetworkReply::readyRead, this, [this]() { + if (m_reply) + m_buffer.append(m_reply->readAll()); + drainBuffer(); + }); + connect(m_reply, &QNetworkReply::finished, this, [this]() { + QNetworkReply* reply = m_reply; + if (!reply) + return; + m_reply = nullptr; + + const QNetworkReply::NetworkError error = reply->error(); + const QString errorString = reply->errorString(); + // Data not already consumed by readyRead is only reachable here. + const QByteArray responseBody = reply->readAll(); + m_buffer.append(responseBody); + reply->deleteLater(); + + drainBuffer(); + if (!m_streaming) + return; + + if (error == QNetworkReply::NoError || + error == QNetworkReply::OperationCanceledError) + finalize(); + else + fail(serverErrorMessage(responseBody, errorString)); + }); +} + +void LlmClient::shortRequest( + const QString& tag, + const QString& systemPrompt, + const QString& userText, + std::function onResult) { + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); + if (!url.isValid() || url.host().isEmpty()) + return; + + qInfo() << "LlmClient:" << tag << "request POST" << url.toString() + << "model=" << m_model; + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "application/json"); + + QJsonArray messages; + QJsonObject system; + system[QStringLiteral("role")] = QStringLiteral("system"); + system[QStringLiteral("content")] = systemPrompt; + messages.append(system); + QJsonObject user; + user[QStringLiteral("role")] = QStringLiteral("user"); + user[QStringLiteral("content")] = userText.simplified().mid(0, 512); + messages.append(user); + + QJsonObject body; + if (!m_model.isEmpty()) + body[QStringLiteral("model")] = m_model; + body[QStringLiteral("stream")] = false; + body[QStringLiteral("temperature")] = 0.3; + body[QStringLiteral("max_tokens")] = 128; + QJsonObject templateKwargs; + templateKwargs[QStringLiteral("enable_thinking")] = false; + body[QStringLiteral("chat_template_kwargs")] = templateKwargs; + body[QStringLiteral("messages")] = messages; + + auto* reply = m_manager.post(request, QJsonDocument(body).toJson()); + connect( + reply, + &QNetworkReply::finished, + this, + [this, reply, tag, onResult = std::move(onResult)]() { + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + qInfo() << "LlmClient:" << tag << "request finished" + << "error=" << reply->error() << reply->errorString() + << "http=" << reply->attribute( + QNetworkRequest::HttpStatusCodeAttribute) + .toInt() + << "response=" + << QString::fromUtf8(data.left(400)).simplified(); + + if (reply->error() != QNetworkReply::NoError) + return; + + const QJsonDocument doc = QJsonDocument::fromJson(data); + const QJsonArray choices = + doc.object()[QStringLiteral("choices")].toArray(); + if (choices.isEmpty()) + return; + const QString result = + choices.at(0).toObject()[QStringLiteral("message")].toObject() + [QStringLiteral("content")].toString() + .trimmed(); + qInfo() << "LlmClient:" << tag << "raw result" << result; + onResult(result); + }); +} + +void LlmClient::requestTitle(ChatSession* session, const QString& userText) { + shortRequest( + QStringLiteral("title"), + QStringLiteral( + "Write a short, concise title for a chat conversation starting " + "with the user's message below. At most six words, no quotation " + "marks, no trailing punctuation. Reply with the title only."), + userText, + [this, session = QPointer(session)](QString title) { + if (!session) { + qWarning() << "LlmClient: title request: session gone"; + return; + } + const auto isQuote = [](QChar c) { + return c == QLatin1Char('"') || c == QLatin1Char('\'') || + c == QChar(u'\u201C') || c == QChar(u'\u201D') || + c == QChar(u'\u2018') || c == QChar(u'\u2019'); + }; + while (title.size() >= 2 && isQuote(title.at(0)) && + isQuote(title.at(title.size() - 1))) + title = title.mid(1, title.size() - 2).simplified(); + while (!title.isEmpty() && + (title.endsWith(QLatin1Char('.')) || + title.endsWith(QLatin1Char('!')) || + title.endsWith(QLatin1Char('?')))) + title.chop(1); + if (title.size() < 2) { + qWarning() << "LlmClient: title rejected (too short)" + << title; + return; + } + if (title.size() > 48) + title = title.left(47) + QStringLiteral("…"); + qInfo() << "LlmClient: suggesting title" << title; + Q_EMIT titleSuggested(session, title); + }); +} + +void LlmClient::requestIcon(ChatSession* session, const QString& userText) { + const QStringList icons = { + "chat", "lightbulb", "code", + "description", "article", "school", + "work", "build", "science", + "palette", "music_note", "sports_esports", + "takeout_dining", "flight", "photo_camera", + "psychology_alt", "favorite", "savings", + "gamepad", "auto_awesome", + }; + const QString prompt = + QStringLiteral( + "Pick the single icon name from this list that best matches the " + "topic of the user's message below: %1. Reply with only the icon " + "name, exactly as written in the list, and nothing else.") + .arg(icons.join(QStringLiteral(", "))); + shortRequest( + QStringLiteral("icon"), + prompt, + userText, + [this, session = QPointer(session), icons]( + QString name) { + if (!session) { + qWarning() << "LlmClient: icon request: session gone"; + return; + } + name = name.simplified().toLower(); + const auto isQuote = [](QChar c) { + return c == QLatin1Char('"') || c == QLatin1Char('\''); + }; + while (name.size() >= 2 && isQuote(name.at(0)) && + isQuote(name.at(name.size() - 1))) + name = name.mid(1, name.size() - 2).simplified(); + name.replace(QLatin1Char(' '), QLatin1Char('_')); + if (!icons.contains(name)) { + qWarning() << "LlmClient: icon not in list, using default" + << name; + name = QStringLiteral("chat"); + } + qInfo() << "LlmClient: suggesting icon" << name; + Q_EMIT iconSuggested(session, name); + }); +} + +void LlmClient::stop() { + if (!m_busy) + return; + if (m_reply) + m_reply->abort(); +} + +void LlmClient::endStream() { + if (!m_streaming) + return; + auto* generation = m_streaming; + auto* session = m_active; + m_streaming = nullptr; + m_active = nullptr; + generation->setStreaming(false); + if (generation->content().isEmpty() && + generation->reasoning().isEmpty()) { + if (auto* message = qobject_cast(generation->parent())) { + if (message->generationCount() <= 1) { + if (session) + session->removeMessage(message); + } else { + message->removeGeneration(generation); + } + } + } + setBusy(false); + setStreamingChatId(QString()); +} + +void LlmClient::finalize() { + if (!m_streaming) + return; + ChatSession* session = m_active; + endStream(); + if (session && m_pendingClear == session) { + session->clearMessages(); + m_pendingClear.clear(); + } + if (session) + session->persist(); +} + +void LlmClient::clearOnFinish(ChatSession* session) { + m_pendingClear = session; +} + +void LlmClient::sessionRemoved(ChatSession* session) { + if (m_pendingClear == session) + m_pendingClear.clear(); + if (m_active == session) { + stop(); + endStream(); + } +} + +void LlmClient::fail(const QString& message) { + qWarning() << "LlmClient:" << message; + + if (m_streaming) { + ChatSession* session = m_active; + endStream(); + if (session && m_pendingClear == session) { + session->clearMessages(); + m_pendingClear.clear(); + } + if (session) + session->persist(); + } + Q_EMIT errorOccurred(message); +} + +void LlmClient::drainBuffer() { + while (true) { + const qsizetype newline = m_buffer.indexOf('\n'); + if (newline < 0) + break; + const QByteArray line = m_buffer.left(newline).trimmed(); + m_buffer.remove(0, newline + 1); + handleLine(line); + } +} + +void LlmClient::handleLine(const QByteArray& line) { + if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) + return; + + const QByteArray data = line.mid(5).trimmed(); + if (data == "[DONE]") { + finalize(); + return; + } + + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (!doc.isObject()) + return; + const QJsonObject obj = doc.object(); + updateTokenUsage(obj); + + if (obj.contains("error")) { + const QJsonObject error = obj["error"].toObject(); + const QString message = error["message"].toString(); + fail(message.isEmpty() + ? QStringLiteral("LLM server returned an error") + : message); + return; + } + + for (const QJsonValue& choiceValue : obj["choices"].toArray()) { + if (!m_streaming) + continue; + const QJsonObject delta = + choiceValue.toObject()["delta"].toObject(); + m_streaming->appendContent(delta["content"].toString()); + QString reasoning = delta["reasoning_content"].toString(); + if (reasoning.isEmpty()) + reasoning = delta["reasoning"].toString(); + m_streaming->appendReasoning(reasoning); + } +} + +void LlmClient::refreshModels() { + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); + if (!url.isValid() || url.host().isEmpty()) + return; + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + if (error != QNetworkReply::NoError) { + qWarning() << "LlmClient: failed to fetch models:" << error; + return; + } + const QJsonArray dataArr = + QJsonDocument::fromJson(data).object()["data"].toArray(); + + QStringList models; + QSet seen; + for (const QJsonValue& value : dataArr) { + const QString id = value.toObject()["id"].toString(); + if (!id.isEmpty() && !seen.contains(id)) { + seen.insert(id); + models.append(id); + } + } + if (models.isEmpty()) + return; + + m_availableModels = models; + Q_EMIT availableModelsChanged(); + + if (m_model.isEmpty()) { + m_model = models.first(); + Q_EMIT modelChanged(); + } + }); +} + +void LlmClient::setContextSize(int size) { + if (size <= 0 || m_contextSize == size) + return; + m_contextSize = size; + Q_EMIT contextSizeChanged(); +} + +void LlmClient::probeContextSize() { + // llama.cpp-specific endpoint; other servers fall back to 4096. + QString base = m_endpoint.trimmed(); + while (base.endsWith('/')) + base.chop(1); + const QUrl url = QUrl::fromUserInput(base + "/props"); + if (!url.isValid() || url.host().isEmpty()) + return; + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect( + reply, + &QNetworkReply::finished, + this, + [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + int size = 0; + if (error == QNetworkReply::NoError) { + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (doc.isArray()) { + for (const auto& value : doc.array()) { + const QJsonObject slot = value.toObject(); + if (slot.contains("n_ctx")) { + size = slot["n_ctx"].toInt(0); + if (size > 0) + break; + } + } + } else if (doc.isObject()) { + const QJsonObject obj = doc.object(); + size = obj["n_ctx"].toInt(0); + if (size <= 0) + size = obj["default_generation_settings"].toObject() + ["n_ctx"].toInt(0); + } + } + setContextSize(size > 0 ? size : 4096); + }); +} + +void LlmClient::updateTokenUsage(const QJsonObject& data) { + if (!m_active || m_contextSize <= 0) + return; + const QJsonObject usage = data["usage"].toObject(); + if (usage.isEmpty()) + return; + const double used = + usage.value("prompt_tokens").toDouble() + + usage.value("completion_tokens").toDouble(); + if (used > 0) + m_active->setLastTokenCount(static_cast(used)); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp new file mode 100644 index 0000000..4f3f339 --- /dev/null +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -0,0 +1,103 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class QJsonObject; +class QNetworkReply; + +namespace ZShell::llm { + +class ChatGeneration; +class ChatSession; + +// The only component that talks to the LLM server: owns the network +// manager, the in-flight streaming state and the SSE parsing. +class LlmClient : public QObject { + Q_OBJECT + + public: + explicit LlmClient(QObject* parent = nullptr); + ~LlmClient() override; + + [[nodiscard]] QString endpoint() const { return m_endpoint; } + [[nodiscard]] QString model() const { return m_model; } + [[nodiscard]] double temperature() const { return m_temperature; } + [[nodiscard]] QStringList availableModels() const { + return m_availableModels; + } + [[nodiscard]] int contextSize() const { return m_contextSize; } + void setEndpoint(const QString& value); + void setModel(const QString& value); + void setTemperature(double value); + void setContextSize(int size); + void probeContextSize(); + + [[nodiscard]] bool busy() const { return m_busy; } + [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } + [[nodiscard]] ChatSession* streamingSession() const { return m_active; } + + // Streams a new assistant reply into `target` (the active generation of + // a message of `session`). The context sent to the model is every + // message of the session that is older than the target message. + void startGeneration(ChatSession* session, ChatGeneration* target); + void stop(); + void endStream(); + // Clears the session's conversation once the current stream ends. + void clearOnFinish(ChatSession* session); + // A session is about to be destroyed; drop any state pointing at it. + void sessionRemoved(ChatSession* session); + + void refreshModels(); + void requestTitle(ChatSession* session, const QString& userText); + void requestIcon(ChatSession* session, const QString& userText); + + Q_SIGNALS: + void busyChanged(); + void endpointChanged(); + void modelChanged(); + void availableModelsChanged(); + void contextSizeChanged(); + void streamingChatIdChanged(); + void errorOccurred(const QString& message); + void titleSuggested(ZShell::llm::ChatSession* session, const QString& title); + void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon); + + private: + void finalize(); + void fail(const QString& message); + void handleLine(const QByteArray& line); + void drainBuffer(); + void updateTokenUsage(const QJsonObject& data); + void setBusy(bool value); + void setStreamingChatId(const QString& id); + void shortRequest( + const QString& tag, + const QString& systemPrompt, + const QString& userText, + std::function onResult); + static QString completionsPath(const QString& endpoint, const QString& subpath); + static QString serverErrorMessage( + const QByteArray& body, const QString& fallback); + + QNetworkAccessManager m_manager; + QNetworkReply* m_reply = nullptr; + QByteArray m_buffer; + ChatSession* m_active = nullptr; + ChatGeneration* m_streaming = nullptr; + QPointer m_pendingClear; + bool m_busy = false; + QString m_streamingChatId; + QString m_endpoint; + QString m_model; + QStringList m_availableModels; + double m_temperature = 0.7; + int m_contextSize = 0; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp index 4f41aee..b0cde7c 100644 --- a/Plugins/ZShell/Llm/message.cpp +++ b/Plugins/ZShell/Llm/message.cpp @@ -1,123 +1,90 @@ #include "message.hpp" -#include +#include "messagemodel.hpp" +#include "session.hpp" -namespace ZShell { +namespace ZShell::llm { -ChatMessage::ChatMessage( - Role role, - const QString& content, +namespace { + +ChatSession* sessionOf(const ChatMessage* message) { + if (auto* model = qobject_cast(message->parent())) + return model->session(); + return nullptr; +} + +} // namespace + +ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent) +: QObject(parent), m_role(role), m_timestamp(timestamp) {} + +ChatGeneration* ChatMessage::addGeneration( qint64 timestamp, - QObject* parent) -: QObject(parent), m_role(role), m_content(content), m_timestamp(timestamp) { - m_timer.setParent(this); - m_timer.setInterval(500); - m_timer.setTimerType(Qt::CoarseTimer); - connect(&m_timer, &QTimer::timeout, this, [this]() { - if (!reasoningInFlight() && !contentInFlight()) { - m_timer.stop(); - return; - } - Q_EMIT elapsedMsChanged(); - }); + const QString& content, + const QString& reasoning, + qint64 reasoningElapsedMs, + qint64 contentElapsedMs) { + auto* generation = new ChatGeneration(timestamp, this); + generation->setContent(content); + generation->setReasoning(reasoning); + generation->setElapsedMs(reasoningElapsedMs, contentElapsedMs); + m_generations.append(generation); + if (m_active < 0) + m_active = static_cast(m_generations.size() - 1); + Q_EMIT generationsChanged(); + return generation; } -qint64 ChatMessage::reasoningElapsedMs() const { - if (m_reasoningStartedAt <= 0) - return 0; - const qint64 end = m_reasoningEndedAt > 0 - ? m_reasoningEndedAt - : QDateTime::currentMSecsSinceEpoch(); - return end - m_reasoningStartedAt; +ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) { + auto* generation = + addGeneration(timestamp, QString(), QString(), 0, 0); + setActiveInternal(static_cast(m_generations.size() - 1)); + return generation; } -qint64 ChatMessage::contentElapsedMs() const { - if (m_contentStartedAt <= 0) - return 0; - const qint64 end = m_contentEndedAt > 0 - ? m_contentEndedAt - : QDateTime::currentMSecsSinceEpoch(); - return end - m_contentStartedAt; -} - -void ChatMessage::updateReasoningActive() { - const bool active = m_streaming && m_content.isEmpty(); - if (m_reasoningActive == active) +void ChatMessage::removeGeneration(ChatGeneration* generation) { + const int index = static_cast(m_generations.indexOf(generation)); + if (index < 0) return; - m_reasoningActive = active; - Q_EMIT reasoningActiveChanged(); + const bool wasActive = index == m_active; + m_generations.removeAt(index); + delete generation; + if (m_generations.isEmpty()) { + m_active = -1; + } else if (m_active >= m_generations.size()) { + m_active = static_cast(m_generations.size() - 1); + } + Q_EMIT generationsChanged(); + if (wasActive) + Q_EMIT activeGenerationChanged(); } -void ChatMessage::appendContent(const QString& piece) { - if (piece.isEmpty()) +void ChatMessage::setActiveInternal(int index) { + if (index < 0 || index >= m_generations.size() || index == m_active) return; - if (m_content.isEmpty()) { - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (reasoningInFlight()) - m_reasoningEndedAt = now; - m_contentStartedAt = now; - if (!m_timer.isActive()) - m_timer.start(); - } - m_content += piece; - Q_EMIT contentChanged(); - updateReasoningActive(); - Q_EMIT elapsedMsChanged(); + m_active = index; + Q_EMIT activeGenerationChanged(); } -void ChatMessage::appendReasoning(const QString& piece) { - if (piece.isEmpty()) - return; - if (m_reasoning.isEmpty()) { - if (m_reasoningStartedAt <= 0) - m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); - if (!m_timer.isActive()) - m_timer.start(); - } - m_reasoning += piece; - Q_EMIT reasoningChanged(); - updateReasoningActive(); - Q_EMIT elapsedMsChanged(); +void ChatMessage::setActiveGeneration(int index) { + setActiveInternal(index); } -void ChatMessage::setReasoning(const QString& value) { - if (m_reasoning == value) - return; - m_reasoning = value; - Q_EMIT reasoningChanged(); +void ChatMessage::edit(const QString& newContent) { + if (auto* generation = activeGeneration()) + generation->setContent(newContent); + if (auto* session = sessionOf(this)) + session->persist(); } -void ChatMessage::setElapsedMs(qint64 reasoningMs, qint64 contentMs) { - if (reasoningMs > 0) { - m_reasoningStartedAt = m_timestamp; - m_reasoningEndedAt = m_timestamp + reasoningMs; - } - if (contentMs > 0) { - m_contentStartedAt = m_timestamp; - m_contentEndedAt = m_timestamp + contentMs; - } +void ChatMessage::retry() { + if (auto* session = sessionOf(this)) + session->retry(this); } -void ChatMessage::setStreaming(bool value) { - if (m_streaming == value) - return; - m_streaming = value; - Q_EMIT streamingChanged(); - if (value) { - if (m_reasoningStartedAt <= 0) - m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); - if (!m_timer.isActive()) - m_timer.start(); - } else { - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (reasoningInFlight()) - m_reasoningEndedAt = now; - if (contentInFlight()) - m_contentEndedAt = now; - m_timer.stop(); - Q_EMIT elapsedMsChanged(); - } - updateReasoningActive(); +void ChatMessage::generate() { + if (auto* session = sessionOf(this)) + session->continueFrom(this); } -} // namespace ZShell +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/message.hpp b/Plugins/ZShell/Llm/message.hpp index 66202d4..19abc4a 100644 --- a/Plugins/ZShell/Llm/message.hpp +++ b/Plugins/ZShell/Llm/message.hpp @@ -1,27 +1,29 @@ #pragma once +#include "generation.hpp" + +#include #include #include -#include #include -namespace ZShell { - -class Chat; +namespace ZShell::llm { class ChatMessage : public QObject { Q_OBJECT QML_ELEMENT QML_UNCREATABLE("Chat messages are created by the Chat singleton") - Q_PROPERTY(Role role READ role NOTIFY roleChanged) - Q_PROPERTY(QString content READ content NOTIFY contentChanged) - Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) - Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) - Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) - Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) - Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) + Q_PROPERTY(Role role READ role CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + Q_PROPERTY(int generationCount READ generationCount NOTIFY generationsChanged) + Q_PROPERTY( + QList generations READ generations + NOTIFY generationsChanged) + Q_PROPERTY( + ZShell::llm::ChatGeneration* activeGeneration READ activeGeneration + NOTIFY activeGenerationChanged) + Q_PROPERTY(int activeGenerationIndex READ activeGenerationIndex NOTIFY activeGenerationChanged) public: enum class Role : int { @@ -31,57 +33,51 @@ class ChatMessage : public QObject { Q_ENUM(Role) explicit ChatMessage( - Role role, - const QString& content, - qint64 timestamp, - QObject* parent = nullptr); + Role role, qint64 timestamp, QObject* parent = nullptr); [[nodiscard]] Role role() const { return m_role; } - [[nodiscard]] QString content() const { return m_content; } - [[nodiscard]] QString reasoning() const { return m_reasoning; } - [[nodiscard]] bool reasoningActive() const { return m_reasoningActive; } - [[nodiscard]] qint64 reasoningElapsedMs() const; - [[nodiscard]] qint64 contentElapsedMs() const; - [[nodiscard]] bool streaming() const { return m_streaming; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + [[nodiscard]] int generationCount() const { + return static_cast(m_generations.size()); + } + [[nodiscard]] QList generations() const { + return m_generations; + } + [[nodiscard]] ChatGeneration* activeGeneration() const { + return generation(m_active); + } + [[nodiscard]] int activeGenerationIndex() const { return m_active; } + [[nodiscard]] ChatGeneration* generation(int index) const { + if (index < 0 || index >= m_generations.size()) + return nullptr; + return m_generations.at(index); + } - void appendContent(const QString& piece); - void appendReasoning(const QString& piece); - void setReasoning(const QString& value); - void setElapsedMs(qint64 reasoningMs, qint64 contentMs); - void setStreaming(bool value); + Q_INVOKABLE void setActiveGeneration(int index); + Q_INVOKABLE void edit(const QString& newContent); + Q_INVOKABLE void retry(); + Q_INVOKABLE void generate(); + + ChatGeneration* addGeneration( + qint64 timestamp, + const QString& content, + const QString& reasoning, + qint64 reasoningElapsedMs, + qint64 contentElapsedMs); + ChatGeneration* appendGeneration(qint64 timestamp); + void removeGeneration(ChatGeneration* generation); Q_SIGNALS: - void roleChanged(); - void contentChanged(); - void reasoningChanged(); - void reasoningActiveChanged(); - void elapsedMsChanged(); - void streamingChanged(); + void generationsChanged(); + void activeGenerationChanged(); private: - void updateReasoningActive(); + void setActiveInternal(int index); - [[nodiscard]] bool reasoningInFlight() const { - return m_reasoningStartedAt > 0 && m_reasoningEndedAt <= 0; - } - [[nodiscard]] bool contentInFlight() const { - return m_contentStartedAt > 0 && m_contentEndedAt <= 0; - } - - QTimer m_timer; Role m_role; - QString m_content; - QString m_reasoning; - bool m_reasoningActive = false; - bool m_streaming = false; qint64 m_timestamp; - qint64 m_reasoningStartedAt = 0; - qint64 m_reasoningEndedAt = 0; - qint64 m_contentStartedAt = 0; - qint64 m_contentEndedAt = 0; - - friend class Chat; + QList m_generations; + int m_active = -1; }; -} // namespace ZShell +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/messagemodel.cpp b/Plugins/ZShell/Llm/messagemodel.cpp new file mode 100644 index 0000000..03f40b8 --- /dev/null +++ b/Plugins/ZShell/Llm/messagemodel.cpp @@ -0,0 +1,107 @@ +#include "messagemodel.hpp" + +#include "session.hpp" + +namespace ZShell::llm { + +ChatMessageModel::ChatMessageModel(ChatSession* session, QObject* parent) + : QAbstractListModel(parent), m_session(session) {} + +ChatMessageModel::~ChatMessageModel() = default; + +int ChatMessageModel::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) return 0; + return static_cast(m_messages.size()); +} + +QVariant ChatMessageModel::data(const QModelIndex& index, int role) const { + if (role != Qt::UserRole || !index.isValid() || index.row() < 0 || + index.row() >= m_messages.size()) + return {}; + return QVariant::fromValue(m_messages.at(index.row())); +} + +QHash ChatMessageModel::roleNames() const { + return {{Qt::UserRole, "modelData"}}; +} + +ChatMessage* ChatMessageModel::at(int row) const { + if (row < 0 || row >= m_messages.size()) return nullptr; + return m_messages.at(row); +} + +int ChatMessageModel::rowOf(const ChatMessage* message) const { + for (int i = 0; i < m_messages.size(); ++i) + if (m_messages.at(i) == message) return i; + return -1; +} + +ChatMessage* ChatMessageModel::createMessage( + ChatMessage::Role role, qint64 timestamp) { + return new ChatMessage(role, timestamp, this); +} + +ChatMessage* ChatMessageModel::appendNewest( + ChatMessage::Role role, const QString& content, qint64 timestamp) { + auto* message = new ChatMessage(role, timestamp, this); + message->addGeneration(timestamp, content, QString(), 0, 0); + + beginInsertRows(QModelIndex(), 0, 0); + m_messages.prepend(message); + endInsertRows(); + + emit lastMessageChanged(); + + return message; +} + +void ChatMessageModel::removeMessage(ChatMessage* message) { + const int row = rowOf(message); + if (row < 0) return; + + const bool wasLastMessage = row == 0; + + beginRemoveRows(QModelIndex(), row, row); + m_messages.removeAt(row); + endRemoveRows(); + + delete message; + + if (wasLastMessage) emit lastMessageChanged(); +} + +void ChatMessageModel::removeRange(int firstRow, int lastRow) { + if (firstRow < 0 || firstRow > lastRow || lastRow >= m_messages.size()) + return; + + const bool changesLastMessage = firstRow == 0; + + beginRemoveRows(QModelIndex(), firstRow, lastRow); + for (int row = lastRow; row >= firstRow; --row) + delete m_messages.takeAt(row); + endRemoveRows(); + + if (changesLastMessage) emit lastMessageChanged(); +} + +void ChatMessageModel::clear() { + if (m_messages.isEmpty()) return; + + beginRemoveRows(QModelIndex(), 0, static_cast(m_messages.size() - 1)); + qDeleteAll(m_messages); + m_messages.clear(); + endRemoveRows(); + + emit lastMessageChanged(); +} + +void ChatMessageModel::loadMessages(QList messages) { + beginResetModel(); + qDeleteAll(m_messages); + m_messages = std::move(messages); + endResetModel(); + + emit lastMessageChanged(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/messagemodel.hpp b/Plugins/ZShell/Llm/messagemodel.hpp new file mode 100644 index 0000000..3c7f44b --- /dev/null +++ b/Plugins/ZShell/Llm/messagemodel.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include "message.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +class ChatSession; + +// Owns a session's messages, most recent first: row 0 is always the newest +// message. Items are exposed through a role named "modelData" (like +// FileSystemModel), so delegates receive each ChatMessage as `modelData`. +class ChatMessageModel : public QAbstractListModel { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Chat message models are owned by ChatSession") + + Q_PROPERTY( + ZShell::llm::ChatMessage* lastMessage READ lastMessage + NOTIFY lastMessageChanged) + + public: + explicit ChatMessageModel(ChatSession* session, QObject* parent = nullptr); + ~ChatMessageModel() override; + + [[nodiscard]] int rowCount( + const QModelIndex& parent = QModelIndex()) const override; + [[nodiscard]] QVariant data( + const QModelIndex& index, int role = Qt::DisplayRole) const override; + [[nodiscard]] QHash roleNames() const override; + + [[nodiscard]] ChatSession* session() const { return m_session; } + // Most recent message first. + [[nodiscard]] QList messages() const { return m_messages; } + [[nodiscard]] ChatMessage* at(int row) const; + [[nodiscard]] int rowOf(const ChatMessage* message) const; + + [[nodiscard]] ChatMessage* lastMessage() const { + return m_messages.isEmpty() ? nullptr : m_messages.first(); + } + + // Creates a message owned by this model without inserting it. + ChatMessage* createMessage(ChatMessage::Role role, qint64 timestamp); + // Appends a new message as the newest one (row 0) with a single + // generation holding `content`. + ChatMessage* appendNewest( + ChatMessage::Role role, const QString& content, qint64 timestamp); + void removeMessage(ChatMessage* message); + void removeRange(int firstRow, int lastRow); + void clear(); + // Replaces every row; takes ownership of the given messages, most recent + // first. + void loadMessages(QList messages); + + signals: + void lastMessageChanged(); + + private: + ChatSession* m_session; + QList m_messages; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp index 7986108..de12a0a 100644 --- a/Plugins/ZShell/Llm/session.cpp +++ b/Plugins/ZShell/Llm/session.cpp @@ -1,102 +1,216 @@ #include "session.hpp" #include "chatstore.hpp" +#include "llmclient.hpp" #include +#include #include -namespace ZShell { +namespace ZShell::llm { + +namespace { + +QString titleFrom(const QString& content) { + const QString flat = content.simplified(); + if (flat.isEmpty()) return QString(); + if (flat.size() <= 48) return flat; + return flat.left(47) + QStringLiteral("…"); +} + +} // namespace ChatSession::ChatSession(const QString& id, QObject* parent) -: QObject(parent), m_id(id) {} + : QObject(parent), m_id(id) { + m_model = new ChatMessageModel(this, this); + connect( + m_model, + &QAbstractListModel::rowsInserted, + this, + &ChatSession::onModelRowsChanged); + connect( + m_model, + &QAbstractListModel::rowsRemoved, + this, + &ChatSession::onModelRowsChanged); + connect( + m_model, + &QAbstractListModel::modelReset, + this, + &ChatSession::onModelRowsChanged); +} + +void ChatSession::onModelRowsChanged() { + setCount(m_model->rowCount()); +} void ChatSession::setTitle(const QString& value) { - if (m_title == value) - return; + if (m_title == value) return; m_title = value; Q_EMIT titleChanged(); + persist(); } void ChatSession::setIcon(const QString& value) { - if (m_icon == value) - return; + if (m_icon == value) return; m_icon = value; Q_EMIT iconChanged(); } void ChatSession::setUpdatedAt(qint64 value) { - if (m_updatedAt == value) - return; + if (m_updatedAt == value) return; m_updatedAt = value; Q_EMIT updatedAtChanged(); } void ChatSession::setPinned(bool value) { - if (m_pinned == value) - return; + if (m_pinned == value) return; m_pinned = value; Q_EMIT pinnedChanged(); } void ChatSession::setCount(int value) { - if (m_messageCount == value) - return; + if (m_messageCount == value) return; m_messageCount = value; Q_EMIT messageCountChanged(); } void ChatSession::setMeta( - const QString& title, - qint64 createdAt, - qint64 updatedAt, - int messageCount) { + const QString& title, qint64 createdAt, qint64 updatedAt, int messageCount) { m_title = title; m_createdAt = createdAt; m_updatedAt = updatedAt; m_messageCount = messageCount; } +void ChatSession::setLastTokenCount(int value) { + m_lastTokenCount = value; +} + +LlmClient* ChatSession::client() const { + if (auto* store = qobject_cast(parent())) + return store->llmClient(); + return nullptr; +} + void ChatSession::ensureLoaded() { - if (m_loaded) - return; + if (m_loaded) return; m_loaded = true; if (auto* store = qobject_cast(parent())) store->loadMessagesInto(this); } -void ChatSession::adoptMessages(QList messages) { - qDeleteAll(m_messages); - m_messages = messages; - setCount(m_messages.size()); - Q_EMIT messagesChanged(); +ChatMessageModel* ChatSession::messagesModel() { + ensureLoaded(); + return m_model; } -ChatMessage* ChatSession::appendMessage( +void ChatSession::adoptMessages(QList messages) { + m_model->loadMessages(std::move(messages)); +} + +void ChatSession::persist() { + if (auto* store = qobject_cast(parent())) store->persist(this); +} + +ChatMessage* ChatSession::appendNewest( ChatMessage::Role role, const QString& content, qint64 timestamp) { - ensureLoaded(); - auto* message = new ChatMessage(role, content, timestamp, this); - m_messages.append(message); - setCount(m_messages.size()); - Q_EMIT messagesChanged(); - return message; + return m_model->appendNewest(role, content, timestamp); } void ChatSession::removeMessage(ChatMessage* message) { - if (!message || !m_messages.removeOne(message)) - return; - delete message; - setCount(m_messages.size()); - Q_EMIT messagesChanged(); + m_model->removeMessage(message); } void ChatSession::clearMessages() { - ensureLoaded(); - if (m_messages.isEmpty()) - return; - qDeleteAll(m_messages); - m_messages.clear(); - setCount(0); - Q_EMIT messagesChanged(); + m_model->clear(); } -} // namespace ZShell +void ChatSession::startGeneration(ChatMessage* target) { + if (auto* generation = target->activeGeneration()) { + if (auto* clientObject = client()) + clientObject->startGeneration(this, generation); + } + persist(); +} + +void ChatSession::sendMessage(const QString& text) { + const QString trimmed = text.trimmed(); + if (trimmed.isEmpty()) return; + if (auto* clientObject = client()) { + if (clientObject->busy()) return; + } + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + appendNewest(ChatMessage::Role::User, trimmed, now); + while (m_model->rowCount() > 200) + m_model->removeMessage(m_model->at(m_model->rowCount() - 1)); + if (m_title.isEmpty()) { + m_title = titleFrom(trimmed); + Q_EMIT titleChanged(); + qInfo() << "ChatSession:" << m_id << "new conversation," + << "fallback title" << m_title + << "- requesting generated title and icon"; + if (auto* clientObject = client()) { + clientObject->requestTitle(this, trimmed); + clientObject->requestIcon(this, trimmed); + } + } + auto* assistant = + appendNewest(ChatMessage::Role::Assistant, QString(), now); + startGeneration(assistant); +} + +void ChatSession::retry(ChatMessage* target) { + if (!target || target->role() != ChatMessage::Role::Assistant) return; + if (auto* clientObject = client()) { + if (clientObject->busy()) return; + } + const int row = m_model->rowOf(target); + if (row < 0) return; + + // Drop everything newer than the target, then regenerate from the + // context ending at the user message before it. + m_model->removeRange(0, row - 1); + if (m_model->rowCount() < 2) return; + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + target->appendGeneration(now); + startGeneration(target); +} + +void ChatSession::continueFrom(ChatMessage* message) { + if (!message) return; + if (auto* clientObject = client()) { + if (clientObject->busy()) return; + } + const int row = m_model->rowOf(message); + if (row < 0) return; + + m_model->removeRange(0, row - 1); + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (message->role() == ChatMessage::Role::Assistant) { + if (m_model->rowCount() < 2) return; + message->appendGeneration(now); + startGeneration(message); + } else { + auto* assistant = + appendNewest(ChatMessage::Role::Assistant, QString(), now); + startGeneration(assistant); + } +} + +void ChatSession::clear() { + if (auto* clientObject = client()) { + if (clientObject->busy() && clientObject->streamingSession() == this) { + clientObject->clearOnFinish(this); + clientObject->stop(); + return; + } + } + m_model->clear(); + persist(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp index 1180374..d15d5e1 100644 --- a/Plugins/ZShell/Llm/session.hpp +++ b/Plugins/ZShell/Llm/session.hpp @@ -1,14 +1,16 @@ #pragma once +#include "messagemodel.hpp" #include "message.hpp" #include -#include #include #include #include -namespace ZShell { +namespace ZShell::llm { + +class LlmClient; class ChatSession : public QObject { Q_OBJECT @@ -16,13 +18,14 @@ class ChatSession : public QObject { QML_UNCREATABLE("Chat sessions are managed by Chat.chats") Q_PROPERTY(QString id READ id CONSTANT) - Q_PROPERTY(QString title READ title NOTIFY titleChanged) + Q_PROPERTY(QString title READ title WRITE setTitle NOTIFY titleChanged) Q_PROPERTY(QString icon READ icon NOTIFY iconChanged) Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT) Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged) Q_PROPERTY(bool pinned READ pinned NOTIFY pinnedChanged) Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) - Q_PROPERTY(QList messages READ messages NOTIFY messagesChanged) + Q_PROPERTY( + ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT) public: explicit ChatSession(const QString& id, QObject* parent = nullptr); @@ -40,10 +43,14 @@ class ChatSession : public QObject { [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } [[nodiscard]] bool pinned() const { return m_pinned; } [[nodiscard]] int messageCount() const { return m_messageCount; } - [[nodiscard]] QList messages() { - ensureLoaded(); - return m_messages; - } + // Loads the messages from the store on first access. + [[nodiscard]] ChatMessageModel* messagesModel(); + void ensureLoaded(); + [[nodiscard]] bool isLoaded() const { return m_loaded; } + + [[nodiscard]] LlmClient* client() const; + [[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; } + void setLastTokenCount(int value); void setTitle(const QString& value); void setIcon(const QString& value); @@ -56,23 +63,29 @@ class ChatSession : public QObject { qint64 updatedAt, int messageCount); - void ensureLoaded(); - [[nodiscard]] bool isLoaded() const { return m_loaded; } + // Replaces the model's rows with `messages` (most recent first). void adoptMessages(QList messages); - ChatMessage* appendMessage( - ChatMessage::Role role, const QString& content, qint64 timestamp); + void persist(); void removeMessage(ChatMessage* message); void clearMessages(); + Q_INVOKABLE void sendMessage(const QString& text); + Q_INVOKABLE void retry(ZShell::llm::ChatMessage* target); + Q_INVOKABLE void continueFrom(ZShell::llm::ChatMessage* message); + Q_INVOKABLE void clear(); + Q_SIGNALS: void titleChanged(); void iconChanged(); void updatedAtChanged(); void pinnedChanged(); void messageCountChanged(); - void messagesChanged(); private: + void onModelRowsChanged(); + ChatMessage* appendNewest( + ChatMessage::Role role, const QString& content, qint64 timestamp); + void startGeneration(ChatMessage* target); void setCount(int value); QString m_id; @@ -82,8 +95,9 @@ class ChatSession : public QObject { qint64 m_updatedAt = 0; bool m_pinned = false; int m_messageCount = 0; - QList m_messages; + ChatMessageModel* m_model = nullptr; bool m_loaded = false; + int m_lastTokenCount = 0; }; -} // namespace ZShell +} // namespace ZShell::llm From e18875a75250799de795c35754e08c504651d85a Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 24 Aug 2026 23:55:51 +0200 Subject: [PATCH 05/90] add markdown parsing + latex + tree-sitter highlighting for codeblocks in llm responses --- Helpers/Picker.qml | 26 +- .../Sidebar/Chat/ChatContent.qml | 34 +- .../Sidebar/Chat/Content/Actions.qml | 19 +- .../Sidebar/Chat/Content/CodeBlockView.qml | 157 ++++ .../Sidebar/Chat/Content/ContentBubble.qml | 120 +++ .../Sidebar/Chat/Content/MarkdownBlocks.qml | 88 ++ .../Sidebar/Chat/Content/MathBlockView.qml | 50 ++ .../Sidebar/Chat/Content/MessageDelegate.qml | 162 ++-- .../Sidebar/Chat/Content/ProcessBlock.qml | 149 ++++ .../Sidebar/Chat/Content/Reasoning.qml | 107 --- Plugins/ZShell/Config/llm.hpp | 3 + Plugins/ZShell/Llm/CMakeLists.txt | 59 ++ Plugins/ZShell/Llm/chat.cpp | 21 + Plugins/ZShell/Llm/chat.hpp | 4 + Plugins/ZShell/Llm/chatstore.cpp | 164 +++- Plugins/ZShell/Llm/codehighlighter.cpp | 384 +++++++++ Plugins/ZShell/Llm/codehighlighter.hpp | 69 ++ .../fonts/latinmodern/GUST-FONT-LICENSE.txt | 40 + .../fonts/latinmodern/latinmodern-math.otf | Bin 0 -> 733736 bytes .../Llm/fonts/latinmodern/lmroman10-bold.otf | Bin 0 -> 96536 bytes .../latinmodern/lmroman10-bolditalic.otf | Bin 0 -> 108172 bytes .../fonts/latinmodern/lmroman10-italic.otf | Bin 0 -> 107896 bytes .../fonts/latinmodern/lmroman10-regular.otf | Bin 0 -> 95432 bytes Plugins/ZShell/Llm/generation.cpp | 258 ++++-- Plugins/ZShell/Llm/generation.hpp | 60 +- Plugins/ZShell/Llm/highlight-queries/bash.scm | 59 ++ Plugins/ZShell/Llm/highlight-queries/c.scm | 84 ++ Plugins/ZShell/Llm/highlight-queries/cpp.scm | 73 ++ Plugins/ZShell/Llm/highlight-queries/go.scm | 126 +++ .../Llm/highlight-queries/javascript.scm | 207 +++++ Plugins/ZShell/Llm/highlight-queries/json.scm | 19 + .../ZShell/Llm/highlight-queries/python.scm | 140 ++++ Plugins/ZShell/Llm/highlight-queries/rust.scm | 164 ++++ Plugins/ZShell/Llm/highlight-queries/sql.scm | 463 ++++++++++ Plugins/ZShell/Llm/highlight-queries/toml.scm | 36 + .../Llm/highlight-queries/typescript.scm | 38 + Plugins/ZShell/Llm/highlight-queries/yaml.scm | 82 ++ Plugins/ZShell/Llm/llmclient.cpp | 790 ++++++++++++------ Plugins/ZShell/Llm/llmclient.hpp | 65 +- Plugins/ZShell/Llm/markdownblock.hpp | 28 + Plugins/ZShell/Llm/markdownparser.cpp | 144 ++++ Plugins/ZShell/Llm/markdownparser.hpp | 25 + Plugins/ZShell/Llm/mathtext.cpp | 176 ++++ Plugins/ZShell/Llm/mathtext.hpp | 72 ++ Plugins/ZShell/Llm/message.cpp | 13 +- Plugins/ZShell/Llm/message.hpp | 8 +- Plugins/ZShell/Llm/messagemodel.cpp | 3 +- Plugins/ZShell/Llm/segment.cpp | 138 +++ Plugins/ZShell/Llm/segment.hpp | 113 +++ Plugins/ZShell/Llm/tool.cpp | 59 ++ Plugins/ZShell/Llm/tool.hpp | 70 ++ Plugins/ZShell/Llm/webfetchtool.cpp | 428 ++++++++++ Plugins/ZShell/Llm/webfetchtool.hpp | 64 ++ 53 files changed, 5043 insertions(+), 618 deletions(-) create mode 100644 Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml create mode 100644 Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml create mode 100644 Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml create mode 100644 Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml create mode 100644 Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml delete mode 100644 Modules/Notifications/Sidebar/Chat/Content/Reasoning.qml create mode 100644 Plugins/ZShell/Llm/codehighlighter.cpp create mode 100644 Plugins/ZShell/Llm/codehighlighter.hpp create mode 100644 Plugins/ZShell/Llm/fonts/latinmodern/GUST-FONT-LICENSE.txt create mode 100644 Plugins/ZShell/Llm/fonts/latinmodern/latinmodern-math.otf create mode 100644 Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bold.otf create mode 100644 Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bolditalic.otf create mode 100644 Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-italic.otf create mode 100644 Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-regular.otf create mode 100644 Plugins/ZShell/Llm/highlight-queries/bash.scm create mode 100644 Plugins/ZShell/Llm/highlight-queries/c.scm create mode 100644 Plugins/ZShell/Llm/highlight-queries/cpp.scm create mode 100644 Plugins/ZShell/Llm/highlight-queries/go.scm create mode 100644 Plugins/ZShell/Llm/highlight-queries/javascript.scm create mode 100644 Plugins/ZShell/Llm/highlight-queries/json.scm create mode 100644 Plugins/ZShell/Llm/highlight-queries/python.scm create mode 100644 Plugins/ZShell/Llm/highlight-queries/rust.scm create mode 100644 Plugins/ZShell/Llm/highlight-queries/sql.scm create mode 100644 Plugins/ZShell/Llm/highlight-queries/toml.scm create mode 100644 Plugins/ZShell/Llm/highlight-queries/typescript.scm create mode 100644 Plugins/ZShell/Llm/highlight-queries/yaml.scm create mode 100644 Plugins/ZShell/Llm/markdownblock.hpp create mode 100644 Plugins/ZShell/Llm/markdownparser.cpp create mode 100644 Plugins/ZShell/Llm/markdownparser.hpp create mode 100644 Plugins/ZShell/Llm/mathtext.cpp create mode 100644 Plugins/ZShell/Llm/mathtext.hpp create mode 100644 Plugins/ZShell/Llm/segment.cpp create mode 100644 Plugins/ZShell/Llm/segment.hpp create mode 100644 Plugins/ZShell/Llm/tool.cpp create mode 100644 Plugins/ZShell/Llm/tool.hpp create mode 100644 Plugins/ZShell/Llm/webfetchtool.cpp create mode 100644 Plugins/ZShell/Llm/webfetchtool.hpp diff --git a/Helpers/Picker.qml b/Helpers/Picker.qml index 05b4146..b87f53e 100755 --- a/Helpers/Picker.qml +++ b/Helpers/Picker.qml @@ -126,33 +126,29 @@ Item { focus: true opacity: 0 - Behavior on opacity { - Anim { - duration: 300 - } - } Behavior on rsx { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { - } + ExAnim {} } Behavior on rsy { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { - } + ExAnim {} } Behavior on sh { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { - } + ExAnim {} } Behavior on sw { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { + ExAnim {} + } + Behavior on opacity { + Anim { + duration: 300 } } @@ -352,8 +348,7 @@ Item { y: selectionRect.y - root.realBorderWidth Behavior on border.color { - Anim { - } + Anim {} } } @@ -402,6 +397,5 @@ Item { onUndoRequested: annotations.undo() } - component ExAnim: Anim { - } + component ExAnim: Anim {} } diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 4a1220d..2908013 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -70,7 +70,7 @@ Item { fadeAmount: 0.05 fadeThreshold: Tokens.padding.medium model: root.chatData.messagesModel - spacing: Tokens.spacing.medium + spacing: 0 verticalLayoutDirection: VerticalFadeListView.BottomToTop add: Transition { @@ -127,26 +127,38 @@ Item { visible: !root.chatData } - IconButton { - id: scrollToBottom - + Item { anchors.bottom: input.top anchors.bottomMargin: Tokens.spacing.extraLarge anchors.horizontalCenter: parent.horizontalCenter - font.pointSize: Tokens.font.size.large - icon: "arrow_downward" - isRound: true - padding: Tokens.padding.extraSmall + implicitHeight: scrollToBottom.implicitHeight + implicitWidth: scrollToBottom.implicitWidth scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 && list.contentHeight > list.height ? 1 : 0 - type: IconButton.Filled visible: scale > 0 Behavior on scale { Anim {} } - onClicked: { - list.positionViewAtBeginning(); + Elevation { + anchors.fill: parent + level: 2 + radius: scrollToBottom.radius + } + + IconButton { + id: scrollToBottom + + anchors.centerIn: parent + font.pointSize: Tokens.font.size.large + icon: "arrow_downward" + isRound: true + padding: Tokens.padding.extraSmall + type: IconButton.Tonal + + onClicked: { + list.positionViewAtBeginning(); + } } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/Actions.qml b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml index 2b0bade..1d4efdf 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/Actions.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml @@ -12,10 +12,11 @@ Item { required property ChatGeneration current required property TextEdit edit required property bool hovered - readonly property bool isUser: message.role === ChatMessage.Role.User + required property bool isUser required property ChatMessage message + required property LlmSegment segment - implicitHeight: root.current.content !== "" ? editButton.implicitHeight : 0 + implicitHeight: editButton.implicitHeight implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small ButtonRow { @@ -66,8 +67,7 @@ Item { visible: opacity > 0 Behavior on opacity { - Anim { - } + Anim {} } IconButton { @@ -78,8 +78,7 @@ Item { visible: !root.isUser Behavior on scale { - Anim { - } + Anim {} } onClicked: { @@ -94,8 +93,7 @@ Item { scale: root.edit.readOnly ? 1 : 0 Behavior on scale { - Anim { - } + Anim {} } onClicked: Quickshell.clipboardText = root.current.content @@ -107,8 +105,11 @@ Item { icon: root.edit.readOnly ? "edit" : "check" inactiveColor: Colors.palette.m3tertiary inactiveOnColor: Colors.palette.m3onTertiary + visible: root.isUser - onClicked: root.edit.readOnly = !root.edit.readOnly + onClicked: { + root.edit.readOnly = !root.edit.readOnly; + } } } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml new file mode 100644 index 0000000..617c0a1 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml @@ -0,0 +1,157 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +CustomClippingRect { + id: root + + required property string language + required property string code + property bool copied: false + property color codeColor: Colors.palette.m3onSurfaceVariant + property color codeBackgroundColor: Colors.palette.m3surfaceContainerLow + property color codeHeaderColor: Colors.palette.m3outline + property color codeAccentColor: Colors.palette.m3primary + + // Highlighter spans for the current code; refreshed when the code or + // its language changes. + property var codeSpans: [] + + function refresh() { + codeSpans = CodeHighlighter.highlight(root.code, root.language); + } + + function roleColor(kind) { + switch (kind) { + case "comment": + return Colors.palette.m3outline; + case "string": + return Colors.palette.m3tertiary; + case "string.key": + return Colors.palette.m3secondary; + case "number": + case "constant": + return Colors.palette.m3tertiaryFixed; + case "keyword": + return root.codeAccentColor; + case "type": + return Colors.palette.m3secondary; + case "function": + case "method": + return Colors.palette.m3onSurface; + case "macro": + case "preproc": + return Colors.palette.m3secondaryContainer; + case "operator": + case "property": + return root.codeColor; + case "label": + case "attribute": + return Colors.palette.m3tertiaryFixedDim; + default: + return root.codeColor; + } + } + + function escapeHtml(text) { + return text.replace(/&/g, "&").replace(//g, ">"); + } + + // Wraps the code in tags following the highlighter spans. + function highlightedHtml(code, spans) { + let out; + if (!spans.length) { + out = escapeHtml(code); + } else { + out = ""; + let pos = 0; + for (let i = 0; i < spans.length; i++) { + const span = spans[i]; + if (span.start > pos) + out += escapeHtml(code.slice(pos, span.start)); + out += `` + escapeHtml(code.slice(span.start, span.start + span.length)) + ""; + pos = span.start + span.length; + } + if (pos < code.length) + out += escapeHtml(code.slice(pos)); + } + // RichText collapses HTML whitespace: break newlines, and protect + // line-leading indentation with nbsp (internal spaces stay normal + // so long lines can still wrap). + return out + .replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, " ")) + .replace(/\n/g, "
"); + } + + implicitWidth: headerRow.implicitWidth + headerRow.anchors.leftMargin + headerRow.anchors.rightMargin + implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeText.implicitHeight + codeText.anchors.margins * 2 + color: root.codeBackgroundColor + radius: Tokens.rounding.medium + + onLanguageChanged: refresh() + onCodeChanged: refresh() + + CustomText { + id: codeText + + anchors.left: parent.left + anchors.top: headerRow.bottom + anchors.right: parent.right + anchors.margins: Tokens.padding.small + color: root.codeColor + font.family: Config.appearance.font.family.mono + font.pointSize: Tokens.font.size.small + textFormat: Text.RichText + text: root.highlightedHtml(root.code, root.codeSpans) + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + } + + RowLayout { + id: headerRow + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Tokens.padding.small + anchors.leftMargin: Tokens.padding.small + anchors.rightMargin: Tokens.padding.small + spacing: Tokens.spacing.small + + CustomText { + Layout.alignment: Qt.AlignVCenter + color: root.codeHeaderColor + font.pointSize: Tokens.font.size.small + text: root.language + visible: root.language.length > 0 + } + + Item { + Layout.fillWidth: true + } + + IconButton { + icon: root.copied ? "check" : "content_copy" + inactiveColor: "transparent" + inactiveOnColor: root.codeHeaderColor + type: IconButton.Text + + onClicked: { + Quickshell.clipboardText = root.code; + root.copied = true; + copyResetTimer.restart(); + } + + Timer { + id: copyResetTimer + + interval: 1500 + + onTriggered: root.copied = false + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml new file mode 100644 index 0000000..c937931 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -0,0 +1,120 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Llm +import ZShell.Config +import qs.Components +import qs.Services + +Item { + id: root + + required property bool isUser + required property LlmSegment segment + required property bool hovered + required property Repeater repeater + required property int index + required property ChatMessage message + required property ChatGeneration current + + // Widest the bubble may grow to; assistant bubbles always use it so + // the markdown blocks have a deterministic width to wrap against. + readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge + + signal edit(text: string) + + implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin + + CustomRect { + id: bubble + + radius: Tokens.rounding.medium + color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer + implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth + implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + Tokens.padding.medium * 2 + anchors.right: root.isUser ? parent.right : undefined + + // User messages stay a plain editable text field. + TextEditBase { + id: msgText + + visible: root.isUser + anchors.left: parent.left + anchors.margins: Tokens.padding.medium + anchors.right: parent.right + anchors.top: parent.top + animateCursor: false + color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary + font.pointSize: Tokens.font.size.smaller + readOnly: true + selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4) + text: root.isUser ? root.segment.text : "" + textFormat: CustomText.MarkdownText + wrapMode: Text.WordWrap + + Keys.onPressed: event => { + if (event.key == Qt.Key_Return) { + if (!(event.modifiers & Qt.ShiftModifier)) { + readOnly = true; + event.accepted = true; + } + } else if (event.key == Qt.Key_Escape) { + text = root.segment.text; + readOnly = true; + event.accepted = true; + } + } + onEditingFinished: { + const old = root.segment.text; + if (old !== text) + root.edit(text); + + readOnly = true; + } + onReadOnlyChanged: { + if (readOnly) { + animateCursor = false; + root.forceActiveFocus(); + textFormat = CustomText.MarkdownText; + } else { + var raw = root.segment.text; + textFormat = CustomText.PlainText; + text = raw; + forceActiveFocus(); + cursorPosition = text.length; + animateCursor = true; + } + } + } + + // Assistant content: parsed markdown blocks. + MarkdownBlocks { + id: blocks + + visible: !root.isUser + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Tokens.padding.medium + blocks: root.segment.markdown + } + } + + Actions { + id: actionsRow + + anchors.left: parent.left + anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right + anchors.top: bubble.bottom + visible: { + return (root.segment.type === LlmSegment.Type.Content) && root.segment.status !== LlmSegment.Status.Running && root.repeater.count === (root.index + 1); + } + anchors.topMargin: Tokens.spacing.medium + edit: msgText + hovered: root.hovered + isUser: root.isUser + segment: root.segment + current: root.current + message: root.message + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml new file mode 100644 index 0000000..f9654e7 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml @@ -0,0 +1,88 @@ +import QtQuick +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +Column { + id: root + + // Top-level markdown blocks (see MarkdownParser / LlmSegment.markdown). + required property var blocks + + spacing: Tokens.spacing.small + + Repeater { + id: blockRep + + model: root.blocks + + delegate: DelegateChooser { + role: "type" + + DelegateChoice { + roleValue: LlmMarkdown.Type.Code + + delegate: CodeBlockView { + required property var modelData + + language: modelData.language + code: modelData.code + anchors.right: parent.right + anchors.left: parent.left + } + } + + DelegateChoice { + roleValue: LlmMarkdown.Type.Math + + delegate: MathBlockView { + required property var modelData + + latex: modelData.latex + anchors.right: parent.right + anchors.left: parent.left + } + } + + DelegateChoice { + roleValue: LlmMarkdown.Type.Heading + + delegate: CustomText { + required property var modelData + + color: Colors.palette.m3onSurface + anchors.right: parent.right + anchors.left: parent.left + text: modelData.text + textFormat: Text.MarkdownText + font.bold: true + font.pointSize: { + if (modelData.level <= 1) + return Tokens.font.size.larger; + if (modelData.level === 2) + return Tokens.font.size.normal; + return Tokens.font.size.smaller; + } + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + } + } + + DelegateChoice { + roleValue: LlmMarkdown.Type.Text + + delegate: CustomText { + required property var modelData + + color: Colors.palette.m3onSurface + anchors.right: parent.right + anchors.left: parent.left + text: modelData.text + textFormat: Text.MarkdownText + font.pointSize: Tokens.font.size.smaller + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml new file mode 100644 index 0000000..856005d --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml @@ -0,0 +1,50 @@ +import QtQuick +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +Item { + id: root + + required property string latex + property color mathColor: Colors.palette.m3onSurface + property real fontSize: Tokens.font.size.large + + implicitWidth: Math.max(fallbackText.implicitWidth, root.width) + implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2 + + LlmMathText { + id: math + + latex: root.latex + color: root.mathColor + fontPointSize: root.fontSize + devicePixelRatio: Screen.devicePixelRatio + } + + Image { + id: equationImage + + anchors.centerIn: parent + source: math.imageUrl + asynchronous: true + visible: math.ok + fillMode: Image.PreserveAspectFit + smooth: true + width: Math.min(math.width, root.width) + height: math.width > 0 ? width * (math.height / math.width) : 0 + } + + CustomText { + id: fallbackText + + anchors.centerIn: parent + color: root.mathColor + font.family: Config.appearance.font.family.mono + font.pointSize: Tokens.font.size.small + text: root.latex + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + visible: !math.ok + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index 73e758d..0133a1e 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -1,6 +1,8 @@ pragma ComponentBehavior: Bound import QtQuick +import QtQuick.Layouts +import Quickshell import ZShell.Config import ZShell.Llm import qs.Components @@ -14,6 +16,7 @@ MouseArea { readonly property bool isUser: modelData.role === ChatMessage.Role.User required property ChatMessage modelData property bool reasoningExpanded: false + readonly property var blocks: blockify(current.segments) property real savedContentY: -1 function handleReasoningToggle(expanded: bool): void { @@ -34,14 +37,36 @@ MouseArea { } } - anchors.left: parent.left - anchors.leftMargin: root.isUser ? 0 : Tokens.spacing.extraSmall - anchors.right: parent.right - anchors.rightMargin: root.isUser ? Tokens.spacing.extraSmall : 0 + function blockify(segs) { + const blocks = []; + for (let i = 0; i < segs.length; i++) { + const seg = segs[i]; + if (seg.type === LlmSegment.Type.Content) { + blocks.push({ + kind: "content", + id: i, + segments: [seg] + }); + } else { + const last = blocks.length ? blocks[blocks.length - 1] : null; + if (last && last.kind === "process") { + last.segments.push(seg); + } else { + blocks.push({ + kind: "process", + id: i, + segments: [seg] + }); + } + } + } + return blocks; + } + + implicitWidth: ListView.view.width hoverEnabled: true - // Explicit, synchronous height — sum of children, no Layout polish involved. - implicitHeight: reasoningLoader.implicitHeight + (reasoningLoader.active ? Tokens.spacing.medium : 0) + bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin preventStealing: true + implicitHeight: layout.implicitHeight + Tokens.padding.medium Binding { property: "contentY" @@ -59,106 +84,61 @@ MouseArea { type: Anim.DefaultEffects } - Loader { - id: reasoningLoader + Column { + id: layout - readonly property bool shouldBeActive: root.current.reasoningActive || root.current.content === "" - - active: !root.isUser + anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right - anchors.rightMargin: Tokens.spacing.extraLarge - anchors.top: parent.top - sourceComponent: Reasoning { - current: root.current + Repeater { + id: segmentRep - onExpandedChanged: { - root.handleReasoningToggle(expanded); + model: ScriptModel { + values: root.blocks + objectProp: "id" } - } - } + delegate: DelegateChooser { + role: "kind" - CustomRect { - id: bubble + DelegateChoice { + roleValue: "process" - anchors.left: root.isUser ? undefined : parent.left - anchors.right: root.isUser ? parent.right : undefined - anchors.top: reasoningLoader.active ? reasoningLoader.bottom : parent.top - anchors.topMargin: reasoningLoader.active ? Tokens.spacing.medium : 0 - color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer - implicitHeight: root.current.content !== "" ? msgText.contentHeight + Tokens.padding.medium * 2 : 0 - implicitWidth: Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge) - radius: Tokens.rounding.medium - visible: !root.current.reasoningActive + delegate: ProcessBlock { + required property int index + required property var modelData - TextEditBase { - id: msgText + segments: modelData.segments + isActive: index === root.blocks.length - 1 + width: root.width - anchors.left: parent.left - anchors.margins: Tokens.padding.medium - anchors.right: parent.right - anchors.top: parent.top - animateCursor: false - color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary - font.pointSize: Tokens.font.size.smaller - readOnly: true - selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4) - text: root.current.content - textFormat: CustomText.MarkdownText - wrapMode: Text.WordWrap - - Keys.onPressed: event => { - if (event.key == Qt.Key_Return) { - if (!(event.modifiers & Qt.ShiftModifier)) { - readOnly = true; - event.accepted = true; + onExpandedChanged: root.handleReasoningToggle(expanded) } - } else if (event.key == Qt.Key_Escape) { - text = root.current.content; - readOnly = true; - event.accepted = true; - } - } - onEditingFinished: { - const old = root.current.content; - if (old !== text) { - root.modelData.edit(text); - - if (root.isUser) - root.modelData.generate(); } - readOnly = true; - } - onReadOnlyChanged: { - if (readOnly) { - animateCursor = false; - root.forceActiveFocus(); - textFormat = CustomText.MarkdownText; - } else { - var raw = root.current.content; - textFormat = CustomText.PlainText; - text = raw; - forceActiveFocus(); - cursorPosition = text.length; - animateCursor = true; + DelegateChoice { + roleValue: "content" + + delegate: ContentBubble { + required property var modelData + + isUser: root.isUser + segment: modelData.segments[0] + width: root.width + repeater: segmentRep + message: root.modelData + current: root.current + hovered: root.containsMouse + + onEdit: text => { + root.modelData.edit(text); + + if (root.isUser) + root.modelData.generate(); + } + } } } } } - - Actions { - id: actionsRow - - anchors.left: parent.left - anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right - anchors.top: bubble.bottom - anchors.topMargin: Tokens.spacing.medium - current: root.current - edit: msgText - hovered: root.containsMouse - message: root.modelData - } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml new file mode 100644 index 0000000..18f7c85 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -0,0 +1,149 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import ZShell.Llm +import qs.Components +import qs.Services + +Item { + id: root + + required property var segments + required property bool isActive + property bool expanded: false + readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1] + readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0) + + implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin + + LoadingIndicator { + id: spinnerReasoning + + anchors.centerIn: expandBtn + implicitSize: collapsedText.implicitHeight + opacity: root.isActive ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + } + + IconButton { + id: expandBtn + + anchors.left: parent.left + anchors.verticalCenter: collapsedText.verticalCenter + font.pointSize: Tokens.font.size.large + anchors.topMargin: 0 + icon: "keyboard_arrow_down" + inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline + opacity: root.isActive ? 0 : 1 + rotation: root.expanded ? 180 : 0 + type: IconButton.Text + + Behavior on rotation { + Anim {} + } + + onClicked: root.expanded = !root.expanded + } + + CustomText { + id: collapsedText + + anchors.left: expandBtn.right + anchors.margins: Tokens.padding.medium + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: 0 + color: Colors.palette.m3outline + font.pointSize: Tokens.font.size.small + text: { + if (root.isActive) { + if (root.lastSegment.type === LlmSegment.Type.ToolCall) + return qsTr("Using %1...").arg(root.lastSegment.name); + return qsTr("Thinking..."); + } + return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1)); + } + + Behavior on opacity { + Anim {} + } + } + + CustomClippingRect { + id: expandedRect + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: collapsedText.bottom + anchors.topMargin: Tokens.spacing.medium + color: Colors.palette.m3surfaceContainerLow + implicitHeight: root.expanded ? expandedContent.contentHeight + expandedContent.anchors.margins * 2 : 0 + opacity: root.expanded ? 1 : 0 + radius: Tokens.rounding.medium + visible: opacity > 0 + + Behavior on implicitHeight { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on opacity { + Anim {} + } + + ListView { + id: expandedContent + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + model: root.segments + spacing: Tokens.spacing.medium + + delegate: Item { + id: segment + + required property LlmSegment modelData + required property int index + + implicitWidth: ListView.view.width + implicitHeight: header.implicitHeight + content.implicitHeight + content.anchors.topMargin + + RowLayout { + id: header + + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.small + + MaterialIcon { + text: segment.modelData.type === LlmSegment.Type.Reasoning ? "cognition" : "build" + fill: 1 + } + + CustomText { + Layout.fillWidth: true + text: segment.modelData.type === LlmSegment.Type.Reasoning ? qsTr("Thought for %1s").arg((segment.modelData.elapsedMs / 1000).toFixed(1)) : qsTr("Used %1 for %2s").arg(segment.modelData.name).arg((segment.modelData.elapsedMs / 1000).toFixed(1)) + } + } + + CustomText { + id: content + + anchors.left: parent.left + anchors.leftMargin: Tokens.padding.small + anchors.top: header.bottom + anchors.topMargin: Tokens.spacing.small + anchors.right: parent.right + color: Colors.palette.m3outline + font.pointSize: Tokens.font.size.small + wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere + text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("Fetched %1").arg(JSON.parse(segment.modelData.arguments)?.url ?? "website") + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/Reasoning.qml b/Modules/Notifications/Sidebar/Chat/Content/Reasoning.qml deleted file mode 100644 index 74bad30..0000000 --- a/Modules/Notifications/Sidebar/Chat/Content/Reasoning.qml +++ /dev/null @@ -1,107 +0,0 @@ -import QtQuick -import ZShell.Config -import ZShell.Llm -import qs.Components -import qs.Services - -Item { - id: root - - required property ChatGeneration current - property bool expanded: false - readonly property bool isReasoning: root.current.reasoningActive || root.current.content === "" - - implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin - - LoadingIndicator { - id: spinnerReasoning - - anchors.left: parent.left - anchors.margins: Tokens.padding.medium - anchors.verticalCenter: parent.verticalCenter - implicitSize: collapsedText.implicitHeight - opacity: root.isReasoning ? 1 : 0 - visible: opacity > 0 - - Behavior on opacity { - Anim { - } - } - } - - IconButton { - id: expandBtn - - anchors.left: parent.left - anchors.margins: Tokens.padding.medium - anchors.verticalCenter: collapsedText.verticalCenter - font.pointSize: Tokens.font.size.large - icon: "keyboard_arrow_down" - inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline - opacity: root.isReasoning ? 0 : 1 - rotation: root.expanded ? 180 : 0 - type: IconButton.Text - - Behavior on rotation { - Anim { - } - } - - onClicked: root.expanded = !root.expanded - } - - CustomText { - id: collapsedText - - anchors.left: root.current.reasoningActive ? spinnerReasoning.right : expandBtn.right - anchors.margins: Tokens.padding.medium - anchors.right: parent.right - anchors.top: parent.top - anchors.topMargin: 0 - color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small - text: root.current.reasoningActive ? qsTr("Thinking...") : qsTr("Thought for %1s").arg((root.current.reasoningElapsedMs / 1000).toFixed(1)) - - Behavior on opacity { - Anim { - } - } - } - - CustomClippingRect { - id: expandedRect - - anchors.left: parent.left - anchors.right: parent.right - anchors.top: collapsedText.bottom - anchors.topMargin: Tokens.spacing.medium - color: Colors.palette.m3surfaceContainerLow - implicitHeight: root.expanded ? expandedText.implicitHeight + expandedText.anchors.margins * 2 : -anchors.topMargin - implicitWidth: expandedText.implicitWidth + expandedText.anchors.margins * 2 - opacity: root.expanded ? 1 : 0 - radius: Tokens.rounding.medium - visible: opacity > 0 - - Behavior on implicitHeight { - Anim { - type: Anim.DefaultEffects - } - } - Behavior on opacity { - Anim { - } - } - - CustomText { - id: expandedText - - anchors.fill: parent - anchors.margins: Tokens.padding.medium - color: Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.small - text: root.current.reasoning - textFormat: CustomText.MarkdownText - wrapMode: Text.WordWrap - } - } -} diff --git a/Plugins/ZShell/Config/llm.hpp b/Plugins/ZShell/Config/llm.hpp index e68584d..e7859f0 100644 --- a/Plugins/ZShell/Config/llm.hpp +++ b/Plugins/ZShell/Config/llm.hpp @@ -11,6 +11,9 @@ class Llm : public ConfigObject { CFG_PROPERTY(QString, endpoint, "http://localhost:8080") CFG_PROPERTY(QString, model, "") CFG_PROPERTY(double, temperature, 0.7) + // Whether tools are offered to the model. Models without a tool-calling + // template should run with this off. + CFG_PROPERTY(bool, tools, true) public: explicit Llm(QObject* parent = nullptr) : ConfigObject(parent) {} diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt index 05aca8c..c3a358e 100644 --- a/Plugins/ZShell/Llm/CMakeLists.txt +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -1,17 +1,76 @@ +# cmark-gfm: markdown -> block structure (pkg-config; no CMake config +# package is installed). +pkg_check_modules(CMARK_GFM REQUIRED IMPORTED_TARGET libcmark-gfm) + +# tree-sitter runtime for code block highlighting (grammars are +# dlopen()'d at runtime and optional). +pkg_check_modules(TREE_SITTER REQUIRED IMPORTED_TARGET tree-sitter) + +# JKQTMathText (JKQtPlotter) for LaTeX rendering. The config files live +# in a shared JKQTPlotter6 directory, not one named after the package. +find_path(JKQTPlotter6_CMAKE_DIR + NAMES JKQTMathText6Config.cmake + HINTS /usr/lib/cmake/JKQTPlotter6 /usr/local/lib/cmake/JKQTPlotter6 + DOC "Directory containing the JKQtPlotter cmake package files") +find_package(JKQTMathText6 REQUIRED PATHS "${JKQTPlotter6_CMAKE_DIR}") + +# Embed the vendored highlight queries as C++ string literals. +set(HIGHLIGHT_QUERY_LANGS c cpp python javascript typescript bash json rust go yaml toml sql) +set(HIGHLIGHT_QUERIES_HPP "${CMAKE_CURRENT_BINARY_DIR}/highlight-queries.hpp") +set(_hl_header "#pragma once\n\n// Vendored tree-sitter highlight queries (MIT; see the per-file\n// source headers in the highlight-queries/ directory).\nnamespace ZShell::llm::hq {\n") +foreach(_hl_lang IN LISTS HIGHLIGHT_QUERY_LANGS) + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/${_hl_lang}.scm" _hl_src) + string(APPEND _hl_header "inline constexpr const char* ${_hl_lang} = R\"ZSQUERY(${_hl_src})ZSQUERY\";\n") +endforeach() +string(APPEND _hl_header "}\n") +file(WRITE "${HIGHLIGHT_QUERIES_HPP}" "${_hl_header}") + +# Embed the vendored Latin Modern fonts (GUST Font License; provenance +# in fonts/latinmodern/GUST-FONT-LICENSE.txt) as byte arrays. +set(LM_FONTS + lmroman10-regular + lmroman10-italic + lmroman10-bold + lmroman10-bolditalic + latinmodern-math) +set(LM_FONTS_HPP "${CMAKE_CURRENT_BINARY_DIR}/latinmodern-fonts.hpp") +set(_lm_header "#pragma once\n\n// Vendored Latin Modern fonts (GUST Font License; see\n// fonts/latinmodern/GUST-FONT-LICENSE.txt).\nnamespace ZShell::llm::lmfont {\n") +foreach(_lm_font IN LISTS LM_FONTS) + string(REPLACE "-" "_" _lm_sym "${_lm_font}") + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/fonts/latinmodern/${_lm_font}.otf" _lm_hex HEX) + string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _lm_bytes "${_lm_hex}") + string(APPEND _lm_header "inline const unsigned char ${_lm_sym}[] = { ${_lm_bytes} };\n") +endforeach() +string(APPEND _lm_header "}\n") +file(WRITE "${LM_FONTS_HPP}" "${_lm_header}") + qml_module(ZShell-llm URI ZShell.Llm SOURCES chat.hpp chat.cpp chatstore.hpp chatstore.cpp + codehighlighter.hpp codehighlighter.cpp generation.hpp generation.cpp llmclient.hpp llmclient.cpp + markdownblock.hpp + markdownparser.hpp markdownparser.cpp + mathtext.hpp mathtext.cpp message.hpp message.cpp messagemodel.hpp messagemodel.cpp + segment.hpp segment.cpp session.hpp session.cpp + tool.hpp tool.cpp + webfetchtool.hpp webfetchtool.cpp LIBRARIES Qt::Network Qt::Sql + Qt::Gui + Qt::Widgets ZShell-config + JKQTPlotter::JKQTMathText + PkgConfig::CMARK_GFM + PkgConfig::TREE_SITTER ) +target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../Config) diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index 08ae399..6387699 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -3,6 +3,7 @@ #include "config.hpp" #include "llm.hpp" #include "llmclient.hpp" +#include "webfetchtool.hpp" #include @@ -14,11 +15,13 @@ Chat::Chat(QObject* parent) new config::Config(); m_store->setLlmClient(m_client); + m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); const auto* llm = config::Config::instance()->llm(); m_client->setEndpoint(llm->endpoint()); m_client->setModel(llm->model()); m_client->setTemperature(llm->temperature()); + m_client->setToolsEnabled(llm->tools()); connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { m_client->setEndpoint(llm->endpoint()); @@ -29,6 +32,9 @@ Chat::Chat(QObject* parent) connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() { m_client->setTemperature(llm->temperature()); }); + connect(llm, &config::Llm::toolsChanged, this, [this, llm]() { + m_client->setToolsEnabled(llm->tools()); + }); connect(m_client, &LlmClient::busyChanged, this, [this]() { // A fresh run supersedes the previous error. @@ -57,6 +63,11 @@ Chat::Chat(QObject* parent) &LlmClient::streamingChatIdChanged, this, &Chat::streamingChatIdChanged); + connect( + m_client, + &LlmClient::toolsEnabledChanged, + this, + &Chat::toolsEnabledChanged); connect( m_client, &LlmClient::errorOccurred, @@ -113,6 +124,16 @@ int Chat::contextSize() const { return m_client->contextSize(); } +bool Chat::toolsEnabled() const { + return m_client->toolsEnabled(); +} + +void Chat::setToolsEnabled(bool value) { + m_client->setToolsEnabled(value); + if (auto* config = config::Config::instance()) + config->llm()->set_tools(value); +} + QString Chat::streamingChatId() const { return m_client->streamingChatId(); } diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp index c9e98ac..bbafabe 100644 --- a/Plugins/ZShell/Llm/chat.hpp +++ b/Plugins/ZShell/Llm/chat.hpp @@ -27,6 +27,7 @@ class Chat : public QObject { Q_PROPERTY(QString model READ model NOTIFY modelChanged) Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged) Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged) + Q_PROPERTY(bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY toolsEnabledChanged) Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged) @@ -39,6 +40,8 @@ class Chat : public QObject { [[nodiscard]] QString model() const; [[nodiscard]] QStringList availableModels() const; [[nodiscard]] int contextSize() const; + [[nodiscard]] bool toolsEnabled() const; + void setToolsEnabled(bool value); [[nodiscard]] QString lastError() const { return m_lastError; } [[nodiscard]] ChatStore* chats() const { return m_store; } [[nodiscard]] QString streamingChatId() const; @@ -56,6 +59,7 @@ class Chat : public QObject { void modelChanged(); void availableModelsChanged(); void contextSizeChanged(); + void toolsEnabledChanged(); void errorOccurred(const QString& message); void lastErrorChanged(); void streamingChatIdChanged(); diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp index 3d4b286..e8aff1f 100644 --- a/Plugins/ZShell/Llm/chatstore.cpp +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -2,6 +2,7 @@ #include "llmclient.hpp" #include "message.hpp" +#include "segment.hpp" #include #include @@ -17,6 +18,38 @@ namespace ZShell::llm { +namespace { + +QString segmentTypeName(LlmSegment::Type type) { + switch (type) { + case LlmSegment::Type::Reasoning: + return QStringLiteral("reasoning"); + case LlmSegment::Type::ToolCall: + return QStringLiteral("tool_call"); + case LlmSegment::Type::Content: + return QStringLiteral("content"); + } + return QStringLiteral("reasoning"); +} + +LlmSegment::Type segmentTypeFromName(const QString& name) { + if (name == QLatin1String("tool_call")) + return LlmSegment::Type::ToolCall; + if (name == QLatin1String("content")) + return LlmSegment::Type::Content; + return LlmSegment::Type::Reasoning; +} + +// A null QString binds as SQL NULL, which violates the NOT NULL columns; +// DEFAULT only applies to omitted columns, not explicit NULLs. +QString sqlText(const QString& value) { + if (value.isNull()) + return QStringLiteral(""); + return value; +} + +} // namespace + ChatStore::ChatStore(QObject* parent) : QObject(parent), m_connectionName(QUuid::createUuid().toString()) { openDb(); @@ -86,14 +119,29 @@ void ChatStore::openDb() { " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " message_id INTEGER NOT NULL REFERENCES messages " "(id) ON DELETE CASCADE,\n" - " content TEXT,\n" - " reasoning TEXT,\n" " timestamp INTEGER NOT NULL,\n" - " reasoning_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" - " content_elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" " is_active INTEGER NOT NULL DEFAULT 1\n" ")")); } + { + QSqlQuery query(db); + query.exec( + QStringLiteral( + "CREATE TABLE IF NOT EXISTS segments (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " generation_id INTEGER NOT NULL REFERENCES generations " + "(id) ON DELETE CASCADE,\n" + " type TEXT NOT NULL,\n" + " text TEXT NOT NULL DEFAULT '',\n" + " name TEXT NOT NULL DEFAULT '',\n" + " tool_call_id TEXT NOT NULL DEFAULT '',\n" + " arguments TEXT NOT NULL DEFAULT '',\n" + " result TEXT NOT NULL DEFAULT '',\n" + " status INTEGER NOT NULL DEFAULT 0,\n" + " elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" + " timestamp INTEGER NOT NULL\n" + ")")); + } { QSqlQuery query(db); query.exec(QStringLiteral( @@ -102,6 +150,9 @@ void ChatStore::openDb() { query.exec(QStringLiteral( "CREATE INDEX IF NOT EXISTS idx_generations_message " "ON generations (message_id)")); + query.exec(QStringLiteral( + "CREATE INDEX IF NOT EXISTS idx_segments_generation " + "ON segments (generation_id)")); } } @@ -212,8 +263,8 @@ void ChatStore::saveMeta(ChatSession* session) { QSqlQuery query(db()); query.prepare("UPDATE sessions SET title = :title, icon = :icon " "WHERE id = :id"); - query.bindValue(":title", session->title()); - query.bindValue(":icon", session->icon()); + query.bindValue(":title", sqlText(session->title())); + query.bindValue(":icon", sqlText(session->icon())); query.bindValue(":id", session->id()); if (!query.exec()) qWarning() << "ChatStore: failed to save meta for" << session->id() @@ -234,7 +285,7 @@ bool ChatStore::saveSession(ChatSession* session) { query.prepare( "UPDATE sessions SET title = :title, updated_at = :updated_at " "WHERE id = :id"); - query.bindValue(":title", session->title()); + query.bindValue(":title", sqlText(session->title())); query.bindValue(":updated_at", session->updatedAtMs()); query.bindValue(":id", session->id()); ok = query.exec(); @@ -252,10 +303,15 @@ bool ChatStore::saveSession(ChatSession* session) { "VALUES (:id, :role, :timestamp)"); QSqlQuery generationInsert(handle); ok = ok && generationInsert.prepare( - "INSERT INTO generations (message_id, content, reasoning, " - "timestamp, reasoning_elapsed_ms, content_elapsed_ms, " - "is_active) VALUES (:mid, :content, :reasoning, :timestamp, " - ":reasoning_elapsed_ms, :content_elapsed_ms, :is_active)"); + "INSERT INTO generations (message_id, timestamp, is_active) " + "VALUES (:mid, :timestamp, :is_active)"); + QSqlQuery segmentInsert(handle); + ok = ok && segmentInsert.prepare( + "INSERT INTO segments (generation_id, type, text, name, " + "tool_call_id, arguments, result, status, elapsed_ms, " + "timestamp) VALUES (:gid, :type, :text, :name, " + ":tool_call_id, :arguments, :result, :status, :elapsed_ms, " + ":timestamp)"); // The model holds messages most recent first; the database keeps // natural rowid order, so iterate from the oldest row up. const auto* model = session->messagesModel(); @@ -279,14 +335,8 @@ bool ChatStore::saveSession(ChatSession* session) { for (int i = 0; ok && i < message->generationCount(); ++i) { const auto* generation = message->generation(i); generationInsert.bindValue(":mid", messageId); - generationInsert.bindValue(":content", generation->content()); generationInsert.bindValue( - ":reasoning", generation->reasoning()); - generationInsert.bindValue(":timestamp", generation->timestamp()); - generationInsert.bindValue( - ":reasoning_elapsed_ms", generation->reasoningElapsedMs()); - generationInsert.bindValue( - ":content_elapsed_ms", generation->contentElapsedMs()); + ":timestamp", generation->timestamp()); generationInsert.bindValue( ":is_active", i == message->activeGenerationIndex() ? 1 : 0); @@ -297,6 +347,33 @@ bool ChatStore::saveSession(ChatSession* session) { << generationInsert.lastError().text(); break; } + const int generationId = + generationInsert.lastInsertId().toInt(); + for (const auto* segment : generation->segments()) { + segmentInsert.bindValue(":gid", generationId); + segmentInsert.bindValue( + ":type", segmentTypeName(segment->type())); + segmentInsert.bindValue(":text", sqlText(segment->text())); + segmentInsert.bindValue(":name", sqlText(segment->name())); + segmentInsert.bindValue( + ":tool_call_id", sqlText(segment->toolCallId())); + segmentInsert.bindValue( + ":arguments", sqlText(segment->arguments())); + segmentInsert.bindValue(":result", sqlText(segment->result())); + segmentInsert.bindValue( + ":status", static_cast(segment->status())); + segmentInsert.bindValue( + ":elapsed_ms", segment->elapsedMs()); + segmentInsert.bindValue( + ":timestamp", segment->timestamp()); + if (!segmentInsert.exec()) { + ok = false; + qWarning() << "ChatStore: saveSession" << id + << "segment insert failed:" + << segmentInsert.lastError().text(); + break; + } + } } } } @@ -333,21 +410,54 @@ void ChatStore::loadMessagesInto(ChatSession* session) { query.value(2).toLongLong()); QSqlQuery generationQuery(db()); generationQuery.prepare( - "SELECT content, reasoning, timestamp, reasoning_elapsed_ms, " - "content_elapsed_ms, is_active FROM generations " + "SELECT id, timestamp, is_active FROM generations " "WHERE message_id = :mid ORDER BY rowid"); generationQuery.bindValue(":mid", messageId); int activeIndex = 0; if (generationQuery.exec()) { int index = 0; while (generationQuery.next()) { - message->addGeneration( - generationQuery.value(2).toLongLong(), - generationQuery.value(0).toString(), - generationQuery.value(1).toString(), - generationQuery.value(3).toLongLong(), - generationQuery.value(4).toLongLong()); - if (generationQuery.value(5).toInt() != 0) + auto* generation = message->addGeneration( + generationQuery.value(1).toLongLong()); + QSqlQuery segmentQuery(db()); + segmentQuery.prepare( + "SELECT type, text, name, tool_call_id, arguments, " + "result, status, elapsed_ms, timestamp FROM segments " + "WHERE generation_id = :gid ORDER BY rowid"); + segmentQuery.bindValue( + ":gid", generationQuery.value(0).toInt()); + if (segmentQuery.exec()) { + while (segmentQuery.next()) { + auto* segment = new LlmSegment( + segmentTypeFromName( + segmentQuery.value(0).toString()), + segmentQuery.value(8).toLongLong(), + generation); + segment->setText( + segmentQuery.value(1).toString()); + segment->setName( + segmentQuery.value(2).toString()); + segment->setToolCallId( + segmentQuery.value(3).toString()); + segment->appendArguments( + segmentQuery.value(4).toString()); + segment->setResult( + segmentQuery.value(5).toString()); + segment->setStatus( + static_cast( + segmentQuery.value(6).toInt())); + segment->restore( + segmentQuery.value(7).toLongLong()); + generation->addSegment(segment); + } + } else { + qWarning() << "ChatStore: failed to load segments for " + << "generation" + << generationQuery.value(0).toInt() + << ":" + << segmentQuery.lastError().text(); + } + if (generationQuery.value(2).toInt() != 0) activeIndex = index; ++index; } diff --git a/Plugins/ZShell/Llm/codehighlighter.cpp b/Plugins/ZShell/Llm/codehighlighter.cpp new file mode 100644 index 0000000..7f31413 --- /dev/null +++ b/Plugins/ZShell/Llm/codehighlighter.cpp @@ -0,0 +1,384 @@ +#include "codehighlighter.hpp" + +#include "highlight-queries.hpp" + +#include + +#include +#include +#include + +#include + +namespace ZShell::llm { + +namespace hl { + +// Role ids; 0 means "no color". +enum Role : uint8_t { + None = 0, + Comment, + String, + StringKey, + Number, + Constant, + Keyword, + Type, + Function, + Method, + Macro, + Preproc, + Operator, + Property, + Label, + Attribute, +}; + +const char* roleName(Role role) { + switch (role) { + case Comment: return "comment"; + case String: return "string"; + case StringKey: return "string.key"; + case Number: return "number"; + case Constant: return "constant"; + case Keyword: return "keyword"; + case Type: return "type"; + case Function: return "function"; + case Method: return "method"; + case Macro: return "macro"; + case Preproc: return "preproc"; + case Operator: return "operator"; + case Property: return "property"; + case Label: return "label"; + case Attribute: return "attribute"; + default: return ""; + } +} + +using LanguageFn = const TSLanguage* (*)(); + +// Query sources, indexed by Grammar::queries. +struct QuerySources { + std::vector sources; + int c, cpp, python, javascript, typescriptExtended, typescript, bash, json, rust, go, yaml, toml, sql, cppExtended; +}; + +const QuerySources& querySources() { + static const QuerySources sources = [] { + QuerySources qs; + // javascript + typescript concatenated: the TS grammar reuses the + // JS node names, so the JS query usually compiles against it and + // gives full coverage; the TS-only query is the fallback. + const std::string tsExtended = + std::string(hq::javascript) + "\n" + std::string(hq::typescript); + // Same for cpp: the C++ grammar is a superset of C, and the C++ + // query only covers the C++-specific delta. Base C coverage + // (keywords, types, calls, strings, comments) comes from the C + // query. + const std::string cppExtended = + std::string(hq::c) + "\n" + std::string(hq::cpp); + qs.sources.reserve(14); + qs.sources.push_back(hq::c); + qs.sources.push_back(hq::cpp); + qs.sources.push_back(hq::python); + qs.sources.push_back(hq::javascript); + qs.sources.push_back(tsExtended); + qs.sources.push_back(hq::typescript); + qs.sources.push_back(hq::bash); + qs.sources.push_back(hq::json); + qs.sources.push_back(hq::rust); + qs.sources.push_back(hq::go); + qs.sources.push_back(hq::yaml); + qs.sources.push_back(hq::toml); + qs.sources.push_back(hq::sql); + qs.sources.push_back(cppExtended); + qs.c = 0; + qs.cpp = 1; + qs.python = 2; + qs.javascript = 3; + qs.typescriptExtended = 4; + qs.typescript = 5; + qs.bash = 6; + qs.json = 7; + qs.rust = 8; + qs.go = 9; + qs.yaml = 10; + qs.toml = 11; + qs.sql = 12; + qs.cppExtended = 13; + return qs; + }(); + return sources; +} + +const QHash& grammars() { + static const QHash grammars = [] { + const auto& qs = querySources(); + QHash map; + map.insert("c", {"libtree-sitter-c.so", "tree_sitter_c", {qs.c}}); + map.insert("cpp", {"libtree-sitter-cpp.so", "tree_sitter_cpp", {qs.cppExtended, qs.cpp}}); + map.insert("python", {"libtree-sitter-python.so", "tree_sitter_python", {qs.python}}); + map.insert("javascript", {"libtree-sitter-javascript.so", "tree_sitter_javascript", {qs.javascript}}); + map.insert("typescript", {"libtree-sitter-typescript.so", "tree_sitter_typescript", {qs.typescriptExtended, qs.typescript}}); + map.insert("tsx", {"libtree-sitter-tsx.so", "tree_sitter_tsx", {qs.typescriptExtended, qs.typescript}}); + map.insert("bash", {"libtree-sitter-bash.so", "tree_sitter_bash", {qs.bash}}); + map.insert("json", {"libtree-sitter-json.so", "tree_sitter_json", {qs.json}}); + map.insert("rust", {"libtree-sitter-rust.so", "tree_sitter_rust", {qs.rust}}); + map.insert("go", {"libtree-sitter-go.so", "tree_sitter_go", {qs.go}}); + map.insert("yaml", {"libtree-sitter-yaml.so", "tree_sitter_yaml", {qs.yaml}}); + map.insert("toml", {"libtree-sitter-toml.so", "tree_sitter_toml", {qs.toml}}); + map.insert("sql", {"libtree-sitter-sql.so", "tree_sitter_sql", {qs.sql}}); + return map; + }(); + return grammars; +} + +} // namespace hl + +CodeHighlighter* CodeHighlighter::s_instance = nullptr; + +const QHash& CodeHighlighter::aliases() { + // Language tags as written in code fences (and common variants) to + // grammar id. + static const QHash aliases = [] { + QHash map; + map.insert("c", "c"); + map.insert("h", "c"); + map.insert("cpp", "cpp"); + map.insert("c++", "cpp"); + map.insert("cc", "cpp"); + map.insert("cxx", "cpp"); + map.insert("h++", "cpp"); + map.insert("hpp", "cpp"); + map.insert("hh", "cpp"); + map.insert("python", "python"); + map.insert("py", "python"); + map.insert("javascript", "javascript"); + map.insert("js", "javascript"); + map.insert("jsx", "javascript"); + map.insert("mjs", "javascript"); + map.insert("cjs", "javascript"); + map.insert("typescript", "typescript"); + map.insert("ts", "typescript"); + map.insert("mts", "typescript"); + map.insert("cts", "typescript"); + map.insert("tsx", "tsx"); + map.insert("bash", "bash"); + map.insert("sh", "bash"); + map.insert("shell", "bash"); + map.insert("shellscript", "bash"); + map.insert("shell-session", "bash"); + map.insert("zsh", "bash"); + map.insert("console", "bash"); + map.insert("json", "json"); + map.insert("jsonc", "json"); + map.insert("rust", "rust"); + map.insert("rs", "rust"); + map.insert("go", "go"); + map.insert("golang", "go"); + map.insert("yaml", "yaml"); + map.insert("yml", "yaml"); + map.insert("toml", "toml"); + map.insert("sql", "sql"); + map.insert("mysql", "sql"); + map.insert("postgres", "sql"); + map.insert("postgresql", "sql"); + map.insert("sqlite", "sql"); + map.insert("sqlite3", "sql"); + return map; + }(); + return aliases; +} + +const std::vector& CodeHighlighter::querySources() const { + return hl::querySources().sources; +} + +uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) { + const QString n = QString::fromUtf8(name, length); + if (n == "comment") + return hl::Role::Comment; + if (n.startsWith("string")) + return n == "string.special.key" ? hl::Role::StringKey : hl::Role::String; + if (n == "escape" || n == "regexp") + return hl::Role::String; + if (n.startsWith("number")) + return hl::Role::Number; + if (n.startsWith("constant")) + return hl::Role::Constant; + if (n.startsWith("keyword")) + return hl::Role::Keyword; + if (n == "type" || n.startsWith("type.")) + return hl::Role::Type; + if (n == "namespace" || n == "module") + return hl::Role::Type; + if (n.startsWith("function") || n == "constructor") + return hl::Role::Function; + if (n == "method" || n == "method.builtin") + return hl::Role::Method; + if (n.startsWith("macro")) + return hl::Role::Macro; + if (n.startsWith("preproc")) + return hl::Role::Preproc; + if (n == "operator" || n == "punctuation.operator" || n.startsWith("operator.")) + return hl::Role::Operator; + if (n == "property" || n == "field" || n.startsWith("property.")) + return hl::Role::Property; + if (n == "label") + return hl::Role::Label; + if (n.startsWith("attribute") || n == "annotation") + return hl::Role::Attribute; + return hl::Role::None; +} + +const char* CodeHighlighter::roleName(uint8_t role) { + return hl::roleName(static_cast(role)); +} + +QVariantList CodeHighlighter::highlight(const QString& code, const QString& language) const { + QVariantList spans; + if (code.isEmpty()) + return spans; + + const QString id = aliases().value(language.trimmed().toLower()); + if (id.isEmpty()) + return spans; + const Grammar& grammar = hl::grammars().value(id); + + // Guard against pathological blocks; highlighting is best-effort. + static constexpr size_t kMaxBytes = 512 * 1024; + const QByteArray utf8 = code.toUtf8(); + if (static_cast(utf8.size()) > kMaxBytes) + return spans; + + State& state = m_states[id]; + if (state.bad) + return spans; + + if (!state.lang) { + // Missing library is retriable (it may be installed while the + // shell runs); ABI/query failures below are not. + state.lib = dlopen(grammar.lib.toUtf8().constData(), RTLD_NOW | RTLD_LOCAL); + if (!state.lib) + return spans; + auto* symbol = reinterpret_cast( + dlsym(state.lib, grammar.symbol.toUtf8().constData())); + if (!symbol) { + dlclose(state.lib); + state.lib = nullptr; + return spans; + } + const TSLanguage* lang = symbol(); + const uint32_t version = ts_language_abi_version(lang); + if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION || + version > TREE_SITTER_LANGUAGE_VERSION) { + state.bad = true; + return spans; + } + state.lang = lang; + } + + if (!state.query) { + // Candidates in priority order; first that compiles wins. + for (const int candidate : grammar.queries) { + const std::string& source = + querySources()[static_cast(candidate)]; + TSQueryError errorType = TSQueryErrorNone; + uint32_t errorOffset = 0; + TSQuery* query = ts_query_new( + static_cast(state.lang), + source.data(), + static_cast(source.size()), + &errorOffset, + &errorType); + if (!query) + continue; + state.query = query; + break; + } + if (!state.query) { + state.bad = true; + return spans; + } + } + + TSParser* parser = ts_parser_new(); + ts_parser_set_language(parser, static_cast(state.lang)); + TSTree* tree = ts_parser_parse_string( + parser, nullptr, utf8.constData(), static_cast(utf8.size())); + if (!tree) { + ts_parser_delete(parser); + return spans; + } + + TSQuery* query = static_cast(state.query); + TSQueryCursor* cursor = ts_query_cursor_new(); + ts_query_cursor_exec(cursor, query, ts_tree_root_node(tree)); + + // Per-byte winner table: captures arrive in document order and later + // captures overwrite earlier ones (tree-sitter highlight convention). + const uint32_t size = static_cast(utf8.size()); + std::vector kinds(size, 0); + + // QML slices the code by UTF-16 code unit, so spans must be in code + // units, not bytes. cu[b] = code units before byte b. + std::vector cu(size + 1, 0); + for (uint32_t b = 0; b < size; ++b) { + cu[b + 1] = cu[b]; + const unsigned char c = static_cast(utf8[b]); + if (c < 0x80) + cu[b + 1] += 1; + else if (c < 0xC0) + ; // continuation byte + else if (c < 0xF0) + cu[b + 1] += 1; // 2/3-byte lead -> BMP -> one unit + else + cu[b + 1] += 2; // 4-byte lead -> surrogate pair + } + + TSQueryMatch match; + uint32_t captureIndex = 0; + while (ts_query_cursor_next_capture(cursor, &match, &captureIndex)) { + const TSQueryCapture& capture = match.captures[captureIndex]; + uint32_t nameLength = 0; + const char* name = ts_query_capture_name_for_id(query, capture.index, &nameLength); + const uint8_t role = roleFor(name, nameLength); + if (role == 0) + continue; + const uint32_t start = ts_node_start_byte(capture.node); + const uint32_t end = ts_node_end_byte(capture.node); + if (end <= start || end > size) + continue; + std::fill(kinds.begin() + start, kinds.begin() + end, role); + } + + uint32_t position = 0; + while (position < size) { + if (kinds[position] == 0) { + ++position; + continue; + } + const uint8_t role = kinds[position]; + const uint32_t start = position; + while (position < size && kinds[position] == role) + ++position; + QVariantMap span; + span.insert("start", static_cast(cu[start])); + span.insert("length", static_cast(cu[position] - cu[start])); + span.insert("kind", roleName(role)); + spans.append(span); + } + + ts_query_cursor_delete(cursor); + ts_tree_delete(tree); + ts_parser_delete(parser); + return spans; +} + +CodeHighlighter* CodeHighlighter::create(QQmlEngine*, QJSEngine*) { + if (!s_instance) + s_instance = new CodeHighlighter(); + return s_instance; +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/codehighlighter.hpp b/Plugins/ZShell/Llm/codehighlighter.hpp new file mode 100644 index 0000000..659d764 --- /dev/null +++ b/Plugins/ZShell/Llm/codehighlighter.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +class QQmlEngine; +class QJSEngine; + +namespace ZShell::llm { + +// Syntax highlighting for LLM code blocks via tree-sitter. +// +// The tree-sitter runtime is linked. Grammar libraries +// (libtree-sitter-.so) are dlopen()'d lazily, so a missing +// grammar package degrades that language to plain text instead of +// breaking the build or the app. Highlight queries are embedded at +// build time (highlight-queries/*.scm, vendored from the grammar +// repos, MIT). +// +// The fence language the LLM wrote (```cpp, ```python, ...) is mapped +// to a grammar through an alias table. +// +// highlight() returns a list of span maps: +// { "start": int, "length": int, "kind": QString } +// where kind is a semantic role (keyword, string, comment, number, +// function, type, ...) that QML maps to theme colors. An empty list +// means "no highlighting" (unknown language or grammar not installed). +class CodeHighlighter : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + public: + Q_INVOKABLE QVariantList highlight(const QString& code, const QString& language) const; + + static CodeHighlighter* create(QQmlEngine*, QJSEngine*); + + struct Grammar { + QString lib; // soname to dlopen + QString symbol; // tree_sitter_() entry point + // Candidate query sources, first wins. Lets typescript reuse + // the javascript query when it compiles against the grammar. + std::vector queries; // indices into querySources() + }; + + private: + struct State { + bool bad = false; // permanent failure, do not retry + void* lib = nullptr; + const void* lang = nullptr; // const TSLanguage* + void* query = nullptr; // TSQuery* + }; + + [[nodiscard]] static const QHash& aliases(); + [[nodiscard]] const std::vector& querySources() const; + // Maps a tree-sitter capture name to a role index (0 = unstyled). + [[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length); + [[nodiscard]] static const char* roleName(uint8_t role); + + mutable QHash m_states; + static CodeHighlighter* s_instance; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/GUST-FONT-LICENSE.txt b/Plugins/ZShell/Llm/fonts/latinmodern/GUST-FONT-LICENSE.txt new file mode 100644 index 0000000..d656332 --- /dev/null +++ b/Plugins/ZShell/Llm/fonts/latinmodern/GUST-FONT-LICENSE.txt @@ -0,0 +1,40 @@ +% This is a preliminary version (2006-09-30), barring acceptance from +% the LaTeX Project Team and other feedback, of the GUST Font License. +% (GUST is the Polish TeX Users Group, http://www.gust.org.pl) +% +% For the most recent version of this license see +% http://www.gust.org.pl/fonts/licenses/GUST-FONT-LICENSE.txt +% or +% http://tug.org/fonts/licenses/GUST-FONT-LICENSE.txt +% +% This work may be distributed and/or modified under the conditions +% of the LaTeX Project Public License, either version 1.3c of this +% license or (at your option) any later version. +% +% Please also observe the following clause: +% 1) it is requested, but not legally required, that derived works be +% distributed only after changing the names of the fonts comprising this +% work and given in an accompanying "manifest", and that the +% files comprising the Work, as listed in the manifest, also be given +% new names. Any exceptions to this request are also given in the +% manifest. +% +% We recommend the manifest be given in a separate file named +% MANIFEST-.txt, where is some unique identification +% of the font family. If a separate "readme" file accompanies the Work, +% we recommend a name of the form README-.txt. +% +% The latest version of the LaTeX Project Public License is in +% http://www.latex-project.org/lppl.txt and version 1.3c or later +% is part of all distributions of LaTeX version 2006/05/20 or later. + + +--- + +Provenance: + lmroman10-*.otf: Latin Modern v2.007 (GUST, 31-03-2026) + https://www.gust.org.pl/projects/e-foundry/latin-modern/download + (Latin_Modern-otf-2_007-31_03_2026.zip) + latinmodern-math.otf: Latin Modern Math v1.959 (GUST) + https://www.gust.org.pl/projects/e-foundry/lm-math/download + (latinmodern-math-1959.zip; same release as CTAN fonts/lm-math) diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/latinmodern-math.otf b/Plugins/ZShell/Llm/fonts/latinmodern/latinmodern-math.otf new file mode 100644 index 0000000000000000000000000000000000000000..0e4642e916c3e8206c8bf9c490c90eb3b20d9365 GIT binary patch literal 733736 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

1WqRB+)`&4rkF^#c#(W}7`xx-=!VMBU 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%0A+cVsA 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
Er`u0lNSZ%dE!ryx%)Q2w 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

KHd%&|8S^QlTavNt-kJ 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 OGPjg*S?asglm7=Ph_8_V literal 0 HcmV?d00001 diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bold.otf b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-bold.otf new file mode 100644 index 0000000000000000000000000000000000000000..e54ad3614c538215e7928f8bcd6b8afb083a727b GIT binary patch literal 96536 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ⅈ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 literal 0 HcmV?d00001 diff --git a/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-regular.otf b/Plugins/ZShell/Llm/fonts/latinmodern/lmroman10-regular.otf new file mode 100644 index 0000000000000000000000000000000000000000..2d10885aa0c647fc67bf6b71e7ee7f26c172687c GIT binary patch literal 95432 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 + continue; + anyRunning = true; + segment->elapsedMsChanged(); } - Q_EMIT elapsedMsChanged(); + if (anyRunning) + Q_EMIT elapsedMsChanged(); + if (!anyRunning && !m_streaming) + m_timer.stop(); }); } +QString ChatGeneration::content() const { + QStringList parts; + for (const auto* segment : m_segments) { + if (segment->type() != LlmSegment::Type::Content || + segment->text().isEmpty()) + continue; + parts.append(segment->text()); + } + return parts.join(QStringLiteral("\n\n")); +} + +QString ChatGeneration::reasoning() const { + QStringList parts; + for (const auto* segment : m_segments) { + if (segment->type() != LlmSegment::Type::Reasoning || + segment->text().isEmpty()) + continue; + parts.append(segment->text()); + } + return parts.join(QStringLiteral("\n\n")); +} + +bool ChatGeneration::reasoningActive() const { + if (!m_streaming) + return false; + if (!content().isEmpty()) + return false; + return !hasRunningTool(); +} + qint64 ChatGeneration::reasoningElapsedMs() const { - if (m_reasoningStartedAt <= 0) - return 0; - const qint64 end = m_reasoningEndedAt > 0 - ? m_reasoningEndedAt - : QDateTime::currentMSecsSinceEpoch(); - return end - m_reasoningStartedAt; + qint64 total = 0; + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::Reasoning) + total += segment->elapsedMs(); + return total; } qint64 ChatGeneration::contentElapsedMs() const { - if (m_contentStartedAt <= 0) - return 0; - const qint64 end = m_contentEndedAt > 0 - ? m_contentEndedAt - : QDateTime::currentMSecsSinceEpoch(); - return end - m_contentStartedAt; + qint64 total = 0; + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::Content) + total += segment->elapsedMs(); + return total; +} + +qint64 ChatGeneration::toolsElapsedMs() const { + qint64 total = 0; + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::ToolCall) + total += segment->elapsedMs(); + return total; +} + +int ChatGeneration::toolCallCount() const { + int count = 0; + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::ToolCall) + ++count; + return count; +} + +bool ChatGeneration::hasRunningTool() const { + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::ToolCall && + segment->running()) + return true; + return false; } void ChatGeneration::updateReasoningActive() { - const bool active = m_streaming && m_content.isEmpty(); + const bool active = reasoningActive(); if (m_reasoningActive == active) return; m_reasoningActive = active; @@ -45,60 +103,51 @@ void ChatGeneration::updateReasoningActive() { } void ChatGeneration::setContent(const QString& value) { - if (m_content == value) - return; - m_content = value; - Q_EMIT contentChanged(); -} - -void ChatGeneration::setReasoning(const QString& value) { - if (m_reasoning == value) - return; - m_reasoning = value; - Q_EMIT reasoningChanged(); + // Replaces the entire answer: the first content segment takes the + // new text, any later content bursts are cleared. + LlmSegment* first = nullptr; + for (auto* segment : m_segments) { + if (segment->type() != LlmSegment::Type::Content) + continue; + if (!first) + first = segment; + else + segment->setText(QString()); + } + if (!first) { + // Do not materialize an empty content segment (e.g. the assistant + // placeholder created before the stream starts). + if (value.isEmpty()) + return; + first = new LlmSegment( + LlmSegment::Type::Content, + QDateTime::currentMSecsSinceEpoch(), + this); + addSegment(first); + } + first->setText(value); } void ChatGeneration::appendContent(const QString& piece) { if (piece.isEmpty()) return; - if (m_content.isEmpty()) { - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (reasoningInFlight()) - m_reasoningEndedAt = now; - m_contentStartedAt = now; - if (!m_timer.isActive()) - m_timer.start(); + for (auto* segment : m_segments) { + if (segment->type() == LlmSegment::Type::Reasoning && + segment->running()) + segment->close(); } - m_content += piece; - Q_EMIT contentChanged(); - updateReasoningActive(); - Q_EMIT elapsedMsChanged(); + openContentSegment()->appendText(piece); } void ChatGeneration::appendReasoning(const QString& piece) { if (piece.isEmpty()) return; - if (m_reasoning.isEmpty()) { - if (m_reasoningStartedAt <= 0) - m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); - if (!m_timer.isActive()) - m_timer.start(); - } - m_reasoning += piece; - Q_EMIT reasoningChanged(); - updateReasoningActive(); - Q_EMIT elapsedMsChanged(); -} - -void ChatGeneration::setElapsedMs(qint64 reasoningMs, qint64 contentMs) { - if (reasoningMs > 0) { - m_reasoningStartedAt = m_timestamp; - m_reasoningEndedAt = m_timestamp + reasoningMs; - } - if (contentMs > 0) { - m_contentStartedAt = m_timestamp; - m_contentEndedAt = m_timestamp + contentMs; + for (auto* segment : m_segments) { + if (segment->type() == LlmSegment::Type::Content && + segment->running()) + segment->close(); } + openReasoningSegment()->appendText(piece); } void ChatGeneration::setStreaming(bool value) { @@ -107,19 +156,94 @@ void ChatGeneration::setStreaming(bool value) { m_streaming = value; Q_EMIT streamingChanged(); if (value) { - if (m_reasoningStartedAt <= 0) - m_reasoningStartedAt = QDateTime::currentMSecsSinceEpoch(); if (!m_timer.isActive()) m_timer.start(); } else { - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - if (reasoningInFlight()) - m_reasoningEndedAt = now; - if (contentInFlight()) - m_contentEndedAt = now; + closeOpenSegments(); m_timer.stop(); - Q_EMIT elapsedMsChanged(); } + Q_EMIT elapsedMsChanged(); + updateReasoningActive(); +} + +LlmSegment* ChatGeneration::openContentSegment() { + for (auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::Content && + segment->running()) + return segment; + auto* segment = new LlmSegment( + LlmSegment::Type::Content, + QDateTime::currentMSecsSinceEpoch(), + this); + segment->begin(); + addSegment(segment); + return segment; +} + +LlmSegment* ChatGeneration::openReasoningSegment() { + for (auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::Reasoning && + segment->running()) + return segment; + auto* segment = new LlmSegment( + LlmSegment::Type::Reasoning, + QDateTime::currentMSecsSinceEpoch(), + this); + segment->begin(); + addSegment(segment); + return segment; +} + +LlmSegment* ChatGeneration::beginToolCall( + const QString& name, const QString& toolCallId) { + closeOpenSegments(); + auto* segment = new LlmSegment( + LlmSegment::Type::ToolCall, + QDateTime::currentMSecsSinceEpoch(), + this); + segment->setName(name); + segment->setToolCallId(toolCallId); + segment->setStatus(LlmSegment::Status::Running); + segment->begin(); + addSegment(segment); + Q_EMIT toolStateChanged(); + return segment; +} + +void ChatGeneration::addSegment(LlmSegment* segment) { + if (!segment || m_segments.contains(segment)) + return; + segment->setParent(this); + connect( + segment, &LlmSegment::textChanged, this, [this, segment]() { + if (segment->type() == LlmSegment::Type::Reasoning) + Q_EMIT reasoningChanged(); + else if (segment->type() == LlmSegment::Type::Content) + Q_EMIT contentChanged(); + updateReasoningActive(); + }); + connect( + segment, &LlmSegment::statusChanged, this, [this]() { + Q_EMIT toolStateChanged(); + }); + connect( + segment, &LlmSegment::resultChanged, this, [this]() { + Q_EMIT toolStateChanged(); + }); + connect( + segment, &LlmSegment::runningChanged, this, [this]() { + Q_EMIT elapsedMsChanged(); + Q_EMIT toolStateChanged(); + updateReasoningActive(); + }); + m_segments.append(segment); + Q_EMIT segmentsChanged(); +} + +void ChatGeneration::closeOpenSegments() { + for (auto* segment : m_segments) + if (segment->running()) + segment->close(); updateReasoningActive(); } diff --git a/Plugins/ZShell/Llm/generation.hpp b/Plugins/ZShell/Llm/generation.hpp index 0ec1a83..c6e093e 100644 --- a/Plugins/ZShell/Llm/generation.hpp +++ b/Plugins/ZShell/Llm/generation.hpp @@ -1,5 +1,8 @@ #pragma once +#include "segment.hpp" + +#include #include #include #include @@ -7,6 +10,11 @@ namespace ZShell::llm { +// One attempt at answering a message: a chronologically ordered list +// of segments. Content bursts, reasoning bursts and tool calls all +// appear in the order the model produced them; a new content (or +// reasoning) segment starts whenever the model switches between them. +// Together with the attempt's aggregate state. class ChatGeneration : public QObject { Q_OBJECT QML_ELEMENT @@ -16,55 +24,71 @@ class ChatGeneration : public QObject { Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged) Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) + Q_PROPERTY(bool hasRunningTool READ hasRunningTool NOTIFY toolStateChanged) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + Q_PROPERTY( + QList segments READ segments + NOTIFY segmentsChanged) + Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged) public: explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); - [[nodiscard]] QString content() const { return m_content; } - [[nodiscard]] QString reasoning() const { return m_reasoning; } - [[nodiscard]] bool reasoningActive() const { return m_reasoningActive; } + // All content bursts, joined (the model's full answer). + [[nodiscard]] QString content() const; + // Every reasoning burst, joined. + [[nodiscard]] QString reasoning() const; + // True while the model is thinking: streaming, no content yet, and + // no tool call in flight. + [[nodiscard]] bool reasoningActive() const; [[nodiscard]] qint64 reasoningElapsedMs() const; [[nodiscard]] qint64 contentElapsedMs() const; + [[nodiscard]] qint64 toolsElapsedMs() const; [[nodiscard]] bool streaming() const { return m_streaming; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + [[nodiscard]] QList segments() const { return m_segments; } + [[nodiscard]] int toolCallCount() const; + [[nodiscard]] bool hasRunningTool() const; void setContent(const QString& value); - void setReasoning(const QString& value); void appendContent(const QString& piece); void appendReasoning(const QString& piece); - void setElapsedMs(qint64 reasoningMs, qint64 contentMs); void setStreaming(bool value); + // The in-flight content segment, or a fresh one. A new content + // segment starts whenever the model resumes writing after reasoning + // or a tool call. + [[nodiscard]] LlmSegment* openContentSegment(); + // The in-flight reasoning segment, or a fresh one. + [[nodiscard]] LlmSegment* openReasoningSegment(); + // Creates and appends a running tool-call segment. + [[nodiscard]] LlmSegment* beginToolCall( + const QString& name, const QString& toolCallId); + // Appends a segment created by the persistence layer. + void addSegment(LlmSegment* segment); + // Stops the clocks of every in-flight segment. + void closeOpenSegments(); + Q_SIGNALS: void contentChanged(); void reasoningChanged(); void reasoningActiveChanged(); void elapsedMsChanged(); void streamingChanged(); + void toolStateChanged(); + void segmentsChanged(); private: void updateReasoningActive(); - [[nodiscard]] bool reasoningInFlight() const { - return m_reasoningStartedAt > 0 && m_reasoningEndedAt <= 0; - } - [[nodiscard]] bool contentInFlight() const { - return m_contentStartedAt > 0 && m_contentEndedAt <= 0; - } - QTimer m_timer; - QString m_content; - QString m_reasoning; + QList m_segments; bool m_reasoningActive = false; bool m_streaming = false; qint64 m_timestamp; - qint64 m_reasoningStartedAt = 0; - qint64 m_reasoningEndedAt = 0; - qint64 m_contentStartedAt = 0; - qint64 m_contentEndedAt = 0; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/highlight-queries/bash.scm b/Plugins/ZShell/Llm/highlight-queries/bash.scm new file mode 100644 index 0000000..a45327d --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/bash.scm @@ -0,0 +1,59 @@ +; Vendored from bash (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-bash + +[ + (string) + (raw_string) + (heredoc_body) + (heredoc_start) +] @string + +(command_name) @function + +(variable_name) @property + +[ + "case" + "do" + "done" + "elif" + "else" + "esac" + "export" + "fi" + "for" + "function" + "if" + "in" + "select" + "then" + "unset" + "until" + "while" +] @keyword + +(comment) @comment + +(function_definition name: (word) @function) + +(file_descriptor) @number + +[ + (command_substitution) + (process_substitution) + (expansion) +]@embedded + +[ + "$" + "&&" + ">" + ">>" + "<" + "|" +] @operator + +( + (command (_) @constant) + (#match? @constant "^-") +) diff --git a/Plugins/ZShell/Llm/highlight-queries/c.scm b/Plugins/ZShell/Llm/highlight-queries/c.scm new file mode 100644 index 0000000..c9652e5 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/c.scm @@ -0,0 +1,84 @@ +; Vendored from c (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-c + +(identifier) @variable + +((identifier) @constant + (#match? @constant "^[A-Z][A-Z\\d_]*$")) + +"break" @keyword +"case" @keyword +"const" @keyword +"continue" @keyword +"default" @keyword +"do" @keyword +"else" @keyword +"enum" @keyword +"extern" @keyword +"for" @keyword +"if" @keyword +"inline" @keyword +"return" @keyword +"sizeof" @keyword +"static" @keyword +"struct" @keyword +"switch" @keyword +"typedef" @keyword +"union" @keyword +"volatile" @keyword +"while" @keyword + +"#define" @keyword +"#elif" @keyword +"#else" @keyword +"#endif" @keyword +"#if" @keyword +"#ifdef" @keyword +"#ifndef" @keyword +"#include" @keyword +(preproc_directive) @keyword + +"--" @operator +"-" @operator +"-=" @operator +"->" @operator +"=" @operator +"!=" @operator +"*" @operator +"&" @operator +"&&" @operator +"+" @operator +"++" @operator +"+=" @operator +"<" @operator +"==" @operator +">" @operator +"||" @operator + +"." @delimiter +";" @delimiter + +(string_literal) @string +(system_lib_string) @string + +(null) @constant +(number_literal) @number +(char_literal) @number + +(field_identifier) @property +(statement_identifier) @label +(type_identifier) @type +(primitive_type) @type +(sized_type_specifier) @type + +(call_expression + function: (identifier) @function) +(call_expression + function: (field_expression + field: (field_identifier) @function)) +(function_declarator + declarator: (identifier) @function) +(preproc_function_def + name: (identifier) @function.special) + +(comment) @comment diff --git a/Plugins/ZShell/Llm/highlight-queries/cpp.scm b/Plugins/ZShell/Llm/highlight-queries/cpp.scm new file mode 100644 index 0000000..127c6a9 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/cpp.scm @@ -0,0 +1,73 @@ +; Vendored from cpp (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-cpp (tag v0.23.4, matching the distro grammar) + +; Functions + +(call_expression + function: (qualified_identifier + name: (identifier) @function)) + +(template_function + name: (identifier) @function) + +(template_method + name: (field_identifier) @function) + +(template_function + name: (identifier) @function) + +(function_declarator + declarator: (qualified_identifier + name: (identifier) @function)) + +(function_declarator + declarator: (field_identifier) @function) + +; Types + +((namespace_identifier) @type + (#match? @type "^[A-Z]")) + +(auto) @type + +; Constants + +(this) @variable.builtin +(null "nullptr" @constant) + +; Keywords + +[ + "catch" + "class" + "co_await" + "co_return" + "co_yield" + "constexpr" + "constinit" + "consteval" + "delete" + "explicit" + "final" + "friend" + "mutable" + "namespace" + "noexcept" + "new" + "override" + "private" + "protected" + "public" + "template" + "throw" + "try" + "typename" + "using" + "concept" + "requires" + "virtual" +] @keyword + +; Strings + +(raw_string_literal) @string diff --git a/Plugins/ZShell/Llm/highlight-queries/go.scm b/Plugins/ZShell/Llm/highlight-queries/go.scm new file mode 100644 index 0000000..481d5cc --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/go.scm @@ -0,0 +1,126 @@ +; Vendored from go (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-go + +; Function calls + +(call_expression + function: (identifier) @function) + +(call_expression + function: (identifier) @function.builtin + (#match? @function.builtin "^(append|cap|close|complex|copy|delete|imag|len|make|new|panic|print|println|real|recover)$")) + +(call_expression + function: (selector_expression + field: (field_identifier) @function.method)) + +; Function definitions + +(function_declaration + name: (identifier) @function) + +(method_declaration + name: (field_identifier) @function.method) + +; Identifiers + +(type_identifier) @type +(field_identifier) @property +(identifier) @variable + +; Operators + +[ + "--" + "-" + "-=" + ":=" + "!" + "!=" + "..." + "*" + "*" + "*=" + "/" + "/=" + "&" + "&&" + "&=" + "%" + "%=" + "^" + "^=" + "+" + "++" + "+=" + "<-" + "<" + "<<" + "<<=" + "<=" + "=" + "==" + ">" + ">=" + ">>" + ">>=" + "|" + "|=" + "||" + "~" +] @operator + +; Keywords + +[ + "break" + "case" + "chan" + "const" + "continue" + "default" + "defer" + "else" + "fallthrough" + "for" + "func" + "go" + "goto" + "if" + "import" + "interface" + "map" + "package" + "range" + "return" + "select" + "struct" + "switch" + "type" + "var" +] @keyword + +; Literals + +[ + (interpreted_string_literal) + (raw_string_literal) + (rune_literal) +] @string + +(escape_sequence) @escape + +[ + (int_literal) + (float_literal) + (imaginary_literal) +] @number + +[ + (true) + (false) + (nil) + (iota) +] @constant.builtin + +(comment) @comment diff --git a/Plugins/ZShell/Llm/highlight-queries/javascript.scm b/Plugins/ZShell/Llm/highlight-queries/javascript.scm new file mode 100644 index 0000000..92b1a52 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/javascript.scm @@ -0,0 +1,207 @@ +; Vendored from javascript (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-javascript + +; Variables +;---------- + +(identifier) @variable + +; Properties +;----------- + +(property_identifier) @property + +; Function and method definitions +;-------------------------------- + +(function_expression + name: (identifier) @function) +(function_declaration + name: (identifier) @function) +(method_definition + name: (property_identifier) @function.method) + +(pair + key: (property_identifier) @function.method + value: [(function_expression) (arrow_function)]) + +(assignment_expression + left: (member_expression + property: (property_identifier) @function.method) + right: [(function_expression) (arrow_function)]) + +(variable_declarator + name: (identifier) @function + value: [(function_expression) (arrow_function)]) + +(assignment_expression + left: (identifier) @function + right: [(function_expression) (arrow_function)]) + +; Function and method calls +;-------------------------- + +(call_expression + function: (identifier) @function) + +(call_expression + function: (member_expression + property: (property_identifier) @function.method)) + +; Special identifiers +;-------------------- + +((identifier) @constructor + (#match? @constructor "^[A-Z]")) + +([ + (identifier) + (shorthand_property_identifier) + (shorthand_property_identifier_pattern) + ] @constant + (#match? @constant "^[A-Z_][A-Z\\d_]+$")) + +((identifier) @variable.builtin + (#match? @variable.builtin "^(arguments|module|console|window|document)$") + (#is-not? local)) + +((identifier) @function.builtin + (#eq? @function.builtin "require") + (#is-not? local)) + +; Literals +;--------- + +(this) @variable.builtin +(super) @variable.builtin + +[ + (true) + (false) + (null) + (undefined) +] @constant.builtin + +(comment) @comment + +[ + (string) + (template_string) +] @string + +(regex) @string.special +(number) @number + +; Tokens +;------- + +[ + ";" + (optional_chain) + "." + "," +] @punctuation.delimiter + +[ + "-" + "--" + "-=" + "+" + "++" + "+=" + "*" + "*=" + "**" + "**=" + "/" + "/=" + "%" + "%=" + "<" + "<=" + "<<" + "<<=" + "=" + "==" + "===" + "!" + "!=" + "!==" + "=>" + ">" + ">=" + ">>" + ">>=" + ">>>" + ">>>=" + "~" + "^" + "&" + "|" + "^=" + "&=" + "|=" + "&&" + "||" + "??" + "&&=" + "||=" + "??=" +] @operator + +[ + "(" + ")" + "[" + "]" + "{" + "}" +] @punctuation.bracket + +(template_substitution + "${" @punctuation.special + "}" @punctuation.special) @embedded + +[ + "as" + "async" + "await" + "break" + "case" + "catch" + "class" + "const" + "continue" + "debugger" + "default" + "delete" + "do" + "else" + "export" + "extends" + "finally" + "for" + "from" + "function" + "get" + "if" + "import" + "in" + "instanceof" + "let" + "new" + "of" + "return" + "set" + "static" + "switch" + "target" + "throw" + "try" + "typeof" + "var" + "void" + "while" + "with" + "yield" +] @keyword diff --git a/Plugins/ZShell/Llm/highlight-queries/json.scm b/Plugins/ZShell/Llm/highlight-queries/json.scm new file mode 100644 index 0000000..ede99aa --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/json.scm @@ -0,0 +1,19 @@ +; Vendored from json (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-json + +(pair + key: (_) @string.special.key) + +(string) @string + +(number) @number + +[ + (null) + (true) + (false) +] @constant.builtin + +(escape_sequence) @escape + +(comment) @comment diff --git a/Plugins/ZShell/Llm/highlight-queries/python.scm b/Plugins/ZShell/Llm/highlight-queries/python.scm new file mode 100644 index 0000000..fd315a9 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/python.scm @@ -0,0 +1,140 @@ +; Vendored from python (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-python + +; Identifier naming conventions + +(identifier) @variable + +((identifier) @constructor + (#match? @constructor "^[A-Z]")) + +((identifier) @constant + (#match? @constant "^[A-Z][A-Z_]*$")) + +; Function calls + +(decorator) @function +(decorator + (identifier) @function) + +(call + function: (attribute attribute: (identifier) @function.method)) +(call + function: (identifier) @function) + +; Builtin functions + +((call + function: (identifier) @function.builtin) + (#match? + @function.builtin + "^(abs|all|any|ascii|bin|bool|breakpoint|bytearray|bytes|callable|chr|classmethod|compile|complex|delattr|dict|dir|divmod|enumerate|eval|exec|filter|float|format|frozenset|getattr|globals|hasattr|hash|help|hex|id|input|int|isinstance|issubclass|iter|len|list|locals|map|max|memoryview|min|next|object|oct|open|ord|pow|print|property|range|repr|reversed|round|set|setattr|slice|sorted|staticmethod|str|sum|super|tuple|type|vars|zip|__import__)$")) + +; Function definitions + +(function_definition + name: (identifier) @function) + +(attribute attribute: (identifier) @property) +(type (identifier) @type) + +; Literals + +[ + (none) + (true) + (false) +] @constant.builtin + +[ + (integer) + (float) +] @number + +(comment) @comment +(string) @string +(escape_sequence) @escape + +(interpolation + "{" @punctuation.special + "}" @punctuation.special) @embedded + +[ + "-" + "-=" + "!=" + "*" + "**" + "**=" + "*=" + "/" + "//" + "//=" + "/=" + "&" + "&=" + "%" + "%=" + "^" + "^=" + "+" + "->" + "+=" + "<" + "<<" + "<<=" + "<=" + "<>" + "=" + ":=" + "==" + ">" + ">=" + ">>" + ">>=" + "|" + "|=" + "~" + "@=" + "and" + "in" + "is" + "not" + "or" + "is not" + "not in" +] @operator + +[ + "as" + "assert" + "async" + "await" + "break" + "class" + "continue" + "def" + "del" + "elif" + "else" + "except" + "exec" + "finally" + "for" + "from" + "global" + "if" + "import" + "lambda" + "nonlocal" + "pass" + "print" + "raise" + "return" + "try" + "while" + "with" + "yield" + "match" + "case" +] @keyword diff --git a/Plugins/ZShell/Llm/highlight-queries/rust.scm b/Plugins/ZShell/Llm/highlight-queries/rust.scm new file mode 100644 index 0000000..c1acc9e --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/rust.scm @@ -0,0 +1,164 @@ +; Vendored from rust (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-rust + +; Identifiers + +(type_identifier) @type +(primitive_type) @type.builtin +(field_identifier) @property + +; Identifier conventions + +; Assume all-caps names are constants +((identifier) @constant + (#match? @constant "^[A-Z][A-Z\\d_]+$'")) + +; Assume uppercase names are enum constructors +((identifier) @constructor + (#match? @constructor "^[A-Z]")) + +; Assume that uppercase names in paths are types +((scoped_identifier + path: (identifier) @type) + (#match? @type "^[A-Z]")) +((scoped_identifier + path: (scoped_identifier + name: (identifier) @type)) + (#match? @type "^[A-Z]")) +((scoped_type_identifier + path: (identifier) @type) + (#match? @type "^[A-Z]")) +((scoped_type_identifier + path: (scoped_identifier + name: (identifier) @type)) + (#match? @type "^[A-Z]")) + +; Assume all qualified names in struct patterns are enum constructors. (They're +; either that, or struct names; highlighting both as constructors seems to be +; the less glaring choice of error, visually.) +(struct_pattern + type: (scoped_type_identifier + name: (type_identifier) @constructor)) + +; Function calls + +(call_expression + function: (identifier) @function) +(call_expression + function: (field_expression + field: (field_identifier) @function.method)) +(call_expression + function: (scoped_identifier + "::" + name: (identifier) @function)) + +(generic_function + function: (identifier) @function) +(generic_function + function: (scoped_identifier + name: (identifier) @function)) +(generic_function + function: (field_expression + field: (field_identifier) @function.method)) + +(macro_invocation + macro: (identifier) @function.macro + "!" @function.macro) + +; Function definitions + +(function_item (identifier) @function) +(function_signature_item (identifier) @function) + +(line_comment) @comment +(block_comment) @comment + +(line_comment (doc_comment)) @comment.documentation +(block_comment (doc_comment)) @comment.documentation + +"(" @punctuation.bracket +")" @punctuation.bracket +"[" @punctuation.bracket +"]" @punctuation.bracket +"{" @punctuation.bracket +"}" @punctuation.bracket + +(type_arguments + "<" @punctuation.bracket + ">" @punctuation.bracket) +(type_parameters + "<" @punctuation.bracket + ">" @punctuation.bracket) + +"::" @punctuation.delimiter +":" @punctuation.delimiter +"." @punctuation.delimiter +"," @punctuation.delimiter +";" @punctuation.delimiter + +(parameter (identifier) @variable.parameter) + +(lifetime (identifier) @label) + +"as" @keyword +"async" @keyword +"await" @keyword +"break" @keyword +"const" @keyword +"continue" @keyword +"default" @keyword +"dyn" @keyword +"else" @keyword +"enum" @keyword +"extern" @keyword +"fn" @keyword +"for" @keyword +"gen" @keyword +"if" @keyword +"impl" @keyword +"in" @keyword +"let" @keyword +"loop" @keyword +"macro_rules!" @keyword +"match" @keyword +"mod" @keyword +"move" @keyword +"pub" @keyword +"raw" @keyword +"ref" @keyword +"return" @keyword +"static" @keyword +"struct" @keyword +"trait" @keyword +"type" @keyword +"union" @keyword +"unsafe" @keyword +"use" @keyword +"where" @keyword +"while" @keyword +"yield" @keyword +(crate) @keyword +(mutable_specifier) @keyword +(use_list (self) @keyword) +(scoped_use_list (self) @keyword) +(scoped_identifier (self) @keyword) +(super) @keyword + +(self) @variable.builtin + +(char_literal) @string +(string_literal) @string +(raw_string_literal) @string + +(boolean_literal) @constant.builtin +(integer_literal) @constant.builtin +(float_literal) @constant.builtin + +(escape_sequence) @escape + +(attribute_item) @attribute +(inner_attribute_item) @attribute + +"*" @operator +"&" @operator +"'" @operator diff --git a/Plugins/ZShell/Llm/highlight-queries/sql.scm b/Plugins/ZShell/Llm/highlight-queries/sql.scm new file mode 100644 index 0000000..de1438a --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/sql.scm @@ -0,0 +1,463 @@ +; Vendored from sql (MIT License) +; Source: https://github.com/DerekStride/tree-sitter-sql + +(object_reference + name: (identifier) @type) + +(invocation + (object_reference + name: (identifier) @function.call)) + +[ + (keyword_gist) + (keyword_btree) + (keyword_hash) + (keyword_spgist) + (keyword_gin) + (keyword_brin) + (keyword_array) + (keyword_object_id) +] @function.call + +(relation + alias: (identifier) @variable) + +(field + name: (identifier) @field) + +(term + alias: (identifier) @variable) + +((term + value: (cast + name: (keyword_cast) @function.call + parameter: [(literal)]?))) + +(literal) @string +(comment) @comment @spell +(marginalia) @comment + +((literal) @number + (#match? @number "^[-+]?%d+$")) + +((literal) @float + (#match? @float "^[-+]?%d*\.%d*$")) + +(parameter) @parameter + +[ + (keyword_true) + (keyword_false) +] @boolean + +[ + (keyword_asc) + (keyword_desc) + (keyword_terminated) + (keyword_escaped) + (keyword_unsigned) + (keyword_nulls) + (keyword_last) + (keyword_delimited) + (keyword_replication) + (keyword_auto_increment) + (keyword_default) + (keyword_collate) + (keyword_concurrently) + (keyword_engine) + (keyword_always) + (keyword_generated) + (keyword_preceding) + (keyword_following) + (keyword_first) + (keyword_current_timestamp) + (keyword_immutable) + (keyword_atomic) + (keyword_parallel) + (keyword_leakproof) + (keyword_safe) + (keyword_cost) + (keyword_strict) +] @attribute + +[ + (keyword_materialized) + (keyword_recursive) + (keyword_temp) + (keyword_temporary) + (keyword_unlogged) + (keyword_external) + (keyword_parquet) + (keyword_csv) + (keyword_rcfile) + (keyword_textfile) + (keyword_orc) + (keyword_avro) + (keyword_jsonfile) + (keyword_sequencefile) + (keyword_volatile) +] @storageclass + +[ + (keyword_case) + (keyword_when) + (keyword_then) + (keyword_else) +] @conditional + +[ + (keyword_select) + (keyword_from) + (keyword_where) + (keyword_index) + (keyword_join) + (keyword_primary) + (keyword_delete) + (keyword_create) + (keyword_show) + (keyword_unload) + (keyword_insert) + (keyword_merge) + (keyword_distinct) + (keyword_replace) + (keyword_update) + (keyword_into) + (keyword_overwrite) + (keyword_matched) + (keyword_values) + (keyword_value) + (keyword_attribute) + (keyword_set) + (keyword_left) + (keyword_right) + (keyword_outer) + (keyword_inner) + (keyword_full) + (keyword_order) + (keyword_partition) + (keyword_group) + (keyword_with) + (keyword_without) + (keyword_as) + (keyword_having) + (keyword_limit) + (keyword_offset) + (keyword_table) + (keyword_tables) + (keyword_key) + (keyword_references) + (keyword_foreign) + (keyword_constraint) + (keyword_force) + (keyword_use) + (keyword_include) + (keyword_for) + (keyword_if) + (keyword_exists) + (keyword_column) + (keyword_columns) + (keyword_cross) + (keyword_lateral) + (keyword_natural) + (keyword_alter) + (keyword_drop) + (keyword_add) + (keyword_view) + (keyword_end) + (keyword_is) + (keyword_using) + (keyword_between) + (keyword_window) + (keyword_no) + (keyword_data) + (keyword_type) + (keyword_rename) + (keyword_refresh) + (keyword_to) + (keyword_schema) + (keyword_owner) + (keyword_authorization) + (keyword_all) + (keyword_any) + (keyword_some) + (keyword_returning) + (keyword_begin) + (keyword_commit) + (keyword_rollback) + (keyword_transaction) + (keyword_only) + (keyword_like) + (keyword_rlike) + (keyword_similar) + (keyword_over) + (keyword_change) + (keyword_modify) + (keyword_after) + (keyword_before) + (keyword_range) + (keyword_rows) + (keyword_groups) + (keyword_exclude) + (keyword_current) + (keyword_ties) + (keyword_others) + (keyword_zerofill) + (keyword_format) + (keyword_fields) + (keyword_row) + (keyword_sort) + (keyword_compute) + (keyword_comment) + (keyword_location) + (keyword_cached) + (keyword_uncached) + (keyword_lines) + (keyword_stored) + (keyword_virtual) + (keyword_partitioned) + (keyword_analyze) + (keyword_explain) + (keyword_verbose) + (keyword_truncate) + (keyword_rewrite) + (keyword_optimize) + (keyword_vacuum) + (keyword_cache) + (keyword_language) + (keyword_called) + (keyword_conflict) + (keyword_declare) + (keyword_filter) + (keyword_function) + (keyword_input) + (keyword_name) + (keyword_oid) + (keyword_oids) + (keyword_precision) + (keyword_regclass) + (keyword_regnamespace) + (keyword_regproc) + (keyword_regtype) + (keyword_restricted) + (keyword_return) + (keyword_returns) + (keyword_separator) + (keyword_setof) + (keyword_stable) + (keyword_support) + (keyword_tblproperties) + (keyword_trigger) + (keyword_unsafe) + (keyword_admin) + (keyword_connection) + (keyword_cycle) + (keyword_database) + (keyword_encrypted) + (keyword_increment) + (keyword_logged) + (keyword_none) + (keyword_owned) + (keyword_password) + (keyword_reset) + (keyword_role) + (keyword_current_role) + (keyword_sequence) + (keyword_start) + (keyword_restart) + (keyword_tablespace) + (keyword_split) + (keyword_tablets) + (keyword_until) + (keyword_user) + (keyword_current_user) + (keyword_session_user) + (keyword_valid) + (keyword_action) + (keyword_definer) + (keyword_invoker) + (keyword_enable) + (keyword_disable) + (keyword_security) + (keyword_policy) + (keyword_permissive) + (keyword_restrictive) + (keyword_public) + (keyword_extension) + (keyword_version) + (keyword_out) + (keyword_inout) + (keyword_variadic) + (keyword_ordinality) + (keyword_session) + (keyword_isolation) + (keyword_level) + (keyword_serializable) + (keyword_repeatable) + (keyword_read) + (keyword_write) + (keyword_committed) + (keyword_uncommitted) + (keyword_deferrable) + (keyword_names) + (keyword_zone) + (keyword_immediate) + (keyword_deferred) + (keyword_constraints) + (keyword_snapshot) + (keyword_characteristics) + (keyword_off) + (keyword_follows) + (keyword_precedes) + (keyword_each) + (keyword_instead) + (keyword_of) + (keyword_initially) + (keyword_old) + (keyword_new) + (keyword_referencing) + (keyword_statement) + (keyword_execute) + (keyword_procedure) + (keyword_copy) + (keyword_delimiter) + (keyword_encoding) + (keyword_escape) + (keyword_force_not_null) + (keyword_force_null) + (keyword_force_quote) + (keyword_freeze) + (keyword_header) + (keyword_match) + (keyword_program) + (keyword_quote) + (keyword_stdin) + (keyword_extended) + (keyword_main) + (keyword_plain) + (keyword_storage) + (keyword_compression) + (keyword_duplicate) + (keyword_while) +] @keyword + +[ + (keyword_restrict) + (keyword_unbounded) + (keyword_unique) + (keyword_cascade) + (keyword_delayed) + (keyword_high_priority) + (keyword_low_priority) + (keyword_ignore) + (keyword_nothing) + (keyword_check) + (keyword_option) + (keyword_local) + (keyword_cascaded) + (keyword_wait) + (keyword_nowait) + (keyword_metadata) + (keyword_incremental) + (keyword_bin_pack) + (keyword_noscan) + (keyword_stats) + (keyword_statistics) + (keyword_maxvalue) + (keyword_minvalue) +] @type.qualifier + +[ + (keyword_int) + (keyword_null) + (keyword_boolean) + (keyword_binary) + (keyword_varbinary) + (keyword_image) + (keyword_bit) + (keyword_inet) + (keyword_character) + (keyword_smallserial) + (keyword_serial) + (keyword_bigserial) + (keyword_smallint) + (keyword_mediumint) + (keyword_bigint) + (keyword_tinyint) + (keyword_decimal) + (keyword_float) + (keyword_double) + (keyword_numeric) + (keyword_real) + (double) + (keyword_money) + (keyword_smallmoney) + (keyword_char) + (keyword_nchar) + (keyword_varchar) + (keyword_nvarchar) + (keyword_varying) + (keyword_text) + (keyword_string) + (keyword_uuid) + (keyword_json) + (keyword_jsonb) + (keyword_xml) + (keyword_bytea) + (keyword_enum) + (keyword_date) + (keyword_datetime) + (keyword_time) + (keyword_datetime2) + (keyword_datetimeoffset) + (keyword_smalldatetime) + (keyword_timestamp) + (keyword_timestamptz) + (keyword_geometry) + (keyword_geography) + (keyword_box2d) + (keyword_box3d) + (keyword_interval) +] @type.builtin + +[ + (keyword_in) + (keyword_and) + (keyword_or) + (keyword_not) + (keyword_by) + (keyword_on) + (keyword_do) + (keyword_union) + (keyword_except) + (keyword_intersect) +] @keyword.operator + +[ + "+" + "-" + "*" + "/" + "%" + "^" + ":=" + "=" + "<" + "<=" + "!=" + ">=" + ">" + "<>" + (op_other) + (op_unary_other) +] @operator + +[ + "(" + ")" +] @punctuation.bracket + +[ + ";" + "," + "." +] @punctuation.delimiter diff --git a/Plugins/ZShell/Llm/highlight-queries/toml.scm b/Plugins/ZShell/Llm/highlight-queries/toml.scm new file mode 100644 index 0000000..b26f3a9 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/toml.scm @@ -0,0 +1,36 @@ +; Vendored from toml (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-toml + +; Properties +;----------- + +(bare_key) @property +(quoted_key) @string + +; Literals +;--------- + +(boolean) @constant.builtin +(comment) @comment +(string) @string +(integer) @number +(float) @number +(offset_date_time) @string.special +(local_date_time) @string.special +(local_date) @string.special +(local_time) @string.special + +; Punctuation +;------------ + +"." @punctuation.delimiter +"," @punctuation.delimiter + +"=" @operator + +"[" @punctuation.bracket +"]" @punctuation.bracket +"[[" @punctuation.bracket +"]]" @punctuation.bracket +"{" @punctuation.bracket +"}" @punctuation.bracket diff --git a/Plugins/ZShell/Llm/highlight-queries/typescript.scm b/Plugins/ZShell/Llm/highlight-queries/typescript.scm new file mode 100644 index 0000000..72500ee --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/typescript.scm @@ -0,0 +1,38 @@ +; Vendored from typescript (MIT License) +; Source: https://github.com/tree-sitter/tree-sitter-typescript + +; Types + +(type_identifier) @type +(predefined_type) @type.builtin + +((identifier) @type + (#match? @type "^[A-Z]")) + +(type_arguments + "<" @punctuation.bracket + ">" @punctuation.bracket) + +; Variables + +(required_parameter (identifier) @variable.parameter) +(optional_parameter (identifier) @variable.parameter) + +; Keywords + +[ "abstract" + "declare" + "enum" + "export" + "implements" + "interface" + "keyof" + "namespace" + "private" + "protected" + "public" + "type" + "readonly" + "override" + "satisfies" +] @keyword diff --git a/Plugins/ZShell/Llm/highlight-queries/yaml.scm b/Plugins/ZShell/Llm/highlight-queries/yaml.scm new file mode 100644 index 0000000..bbfa7e6 --- /dev/null +++ b/Plugins/ZShell/Llm/highlight-queries/yaml.scm @@ -0,0 +1,82 @@ +; Vendored from yaml (MIT License) +; Source: https://github.com/tree-sitter-grammars/tree-sitter-yaml + +(boolean_scalar) @boolean + +(null_scalar) @constant.builtin + +[ + (double_quote_scalar) + (single_quote_scalar) + (block_scalar) + (string_scalar) +] @string + +[ + (integer_scalar) + (float_scalar) +] @number + +(comment) @comment + +[ + (anchor_name) + (alias_name) +] @label + +(tag) @type + +[ + (yaml_directive) + (tag_directive) + (reserved_directive) +] @attribute + +(block_mapping_pair + key: (flow_node + [ + (double_quote_scalar) + (single_quote_scalar) + ] @property)) + +(block_mapping_pair + key: (flow_node + (plain_scalar + (string_scalar) @property))) + +(flow_mapping + (_ + key: (flow_node + [ + (double_quote_scalar) + (single_quote_scalar) + ] @property))) + +(flow_mapping + (_ + key: (flow_node + (plain_scalar + (string_scalar) @property)))) + +[ + "," + "-" + ":" + ">" + "?" + "|" +] @punctuation.delimiter + +[ + "[" + "]" + "{" + "}" +] @punctuation.bracket + +[ + "*" + "&" + "---" + "..." +] @punctuation.special diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp index 5f4e1a5..b898456 100644 --- a/Plugins/ZShell/Llm/llmclient.cpp +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -1,7 +1,9 @@ #include "llmclient.hpp" +#include "generation.hpp" #include "message.hpp" #include "messagemodel.hpp" +#include "segment.hpp" #include "session.hpp" #include @@ -62,12 +64,19 @@ void LlmClient::setStreamingChatId(const QString& id) { } LlmClient::LlmClient(QObject* parent) : QObject(parent) { + m_tools = new ToolRegistry(this); + connect( + m_tools, + &ToolRegistry::enabledChanged, + this, + &LlmClient::toolsEnabledChanged); probeContextSize(); } LlmClient::~LlmClient() { if (m_reply) m_reply->abort(); + m_tools->cancelAll(); endStream(); } @@ -111,10 +120,6 @@ void LlmClient::startGeneration( return; } - QNetworkRequest request(url); - request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - request.setRawHeader("Accept", "text/event-stream"); - const auto* model = session->messagesModel(); const int targetRow = model->rowOf(qobject_cast(target->parent())); @@ -124,25 +129,50 @@ void LlmClient::startGeneration( return; } - // Context: every message older than the target, oldest first. - QJsonArray messages; - for (int row = model->rowCount() - 1; row > targetRow; --row) { - const auto* message = model->at(row); - const auto* generation = message->activeGeneration(); - if (!generation) - continue; - QJsonObject messageObj; - messageObj[QStringLiteral("role")] = - message->role() == ChatMessage::Role::User - ? QStringLiteral("user") - : QStringLiteral("assistant"); - messageObj[QStringLiteral("content")] = generation->content(); - if (!generation->reasoning().isEmpty()) - messageObj[QStringLiteral("reasoning_content")] = - generation->reasoning(); - messages.append(messageObj); + m_transcript = QJsonArray(); + m_callBuilders.clear(); + m_callResults.clear(); + m_finishReason.clear(); + m_round = 0; + m_contentMark = 0; + m_reasoningMark = 0; + + sendRound(); +} + +void LlmClient::sendRound() { + if (!m_active || !m_streaming) + return; + m_finishReason.clear(); + m_callBuilders.clear(); + m_callResults.clear(); + m_roundDone = false; + m_toolPhase = false; + m_pendingCalls = 0; + m_buffer.clear(); + + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); + if (!url.isValid() || url.host().isEmpty()) { + fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); + return; } + const auto* model = m_active->messagesModel(); + const int targetRow = + model->rowOf(qobject_cast(m_streaming->parent())); + if (targetRow < 0) { + fail(QStringLiteral("Internal error: generation target is not in the " + "session")); + return; + } + + // Context: every message older than the target, oldest first, plus + // the tool exchanges of the current turn so far. + QJsonArray messages = buildContextMessages(m_active, targetRow); + for (const QJsonValue& value : m_transcript) + messages.append(value); + QJsonObject body; QJsonObject streamOptions; streamOptions[QStringLiteral("include_usage")] = true; @@ -152,8 +182,14 @@ void LlmClient::startGeneration( body[QStringLiteral("temperature")] = m_temperature; if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model; + const QJsonArray toolSpecs = m_tools->specifications(); + if (!toolSpecs.isEmpty()) + body[QStringLiteral("tools")] = toolSpecs; + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + request.setRawHeader("Accept", "text/event-stream"); - m_buffer.clear(); m_reply = m_manager.post(request, QJsonDocument(body).toJson()); connect(m_reply, &QNetworkReply::readyRead, this, [this]() { @@ -178,14 +214,493 @@ void LlmClient::startGeneration( if (!m_streaming) return; - if (error == QNetworkReply::NoError || - error == QNetworkReply::OperationCanceledError) - finalize(); + if (error == QNetworkReply::NoError) + roundFinished(); + else if (error == QNetworkReply::OperationCanceledError) + finishTurn(); else fail(serverErrorMessage(responseBody, errorString)); }); } +QJsonArray LlmClient::buildContextMessages( + ChatSession* session, int stopBeforeRow) const { + const auto* model = session->messagesModel(); + QJsonArray messages; + for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { + const auto* message = model->at(row); + const auto* generation = message->activeGeneration(); + if (!generation) + continue; + + if (message->role() == ChatMessage::Role::User) { + QJsonObject user; + user[QStringLiteral("role")] = QStringLiteral("user"); + user[QStringLiteral("content")] = generation->content(); + messages.append(user); + continue; + } + + // Assistant message: replay its tool calls (and their results) + // so the model keeps the full history of the turn. + QList toolSegments; + for (const auto* segment : generation->segments()) + if (segment->type() == LlmSegment::Type::ToolCall) + toolSegments.append(segment); + + QJsonObject assistant; + assistant[QStringLiteral("role")] = QStringLiteral("assistant"); + if (toolSegments.isEmpty()) + assistant[QStringLiteral("content")] = generation->content(); + else if (!generation->content().isEmpty()) + assistant[QStringLiteral("content")] = generation->content(); + if (!generation->reasoning().isEmpty()) + assistant[QStringLiteral("reasoning_content")] = + generation->reasoning(); + + if (toolSegments.isEmpty()) { + messages.append(assistant); + continue; + } + QJsonArray calls; + for (const auto* segment : toolSegments) { + QJsonObject function; + function[QStringLiteral("name")] = segment->name(); + function[QStringLiteral("arguments")] = segment->arguments(); + QJsonObject call; + call[QStringLiteral("id")] = segment->toolCallId(); + call[QStringLiteral("type")] = QStringLiteral("function"); + call[QStringLiteral("function")] = function; + calls.append(call); + } + assistant[QStringLiteral("tool_calls")] = calls; + messages.append(assistant); + for (const auto* segment : toolSegments) { + QJsonObject toolMessage; + toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); + toolMessage[QStringLiteral("tool_call_id")] = + segment->toolCallId(); + toolMessage[QStringLiteral("content")] = segment->result(); + messages.append(toolMessage); + } + } + return messages; +} + +void LlmClient::applyToolCallDelta(const QJsonObject& call) { + if (!m_streaming) + return; + const int index = call[QStringLiteral("index")].toInt(-1); + if (index < 0) + return; + while (m_callBuilders.size() <= index) + m_callBuilders.append(ToolCallBuilder{}); + ToolCallBuilder& builder = m_callBuilders[index]; + builder.seen = true; + const QString id = call[QStringLiteral("id")].toString(); + if (!id.isEmpty()) + builder.id = id; + const QJsonObject function = call[QStringLiteral("function")].toObject(); + const QString name = function[QStringLiteral("name")].toString(); + if (!name.isEmpty()) + builder.name = name; + const QString arguments = + function[QStringLiteral("arguments")].toString(); + if (!arguments.isEmpty()) + builder.arguments += arguments; + + if (!builder.segment) { + // A new call: close the in-flight text segments and open a + // running tool-call segment so the UI can track it live. + m_streaming->closeOpenSegments(); + builder.segment = + m_streaming->beginToolCall(builder.name, builder.id); + } + builder.segment->setName(builder.name); + builder.segment->setToolCallId(builder.id); + if (!arguments.isEmpty()) + builder.segment->appendArguments(arguments); +} + +void LlmClient::roundFinished() { + // [DONE] and the reply's finished signal both funnel here; only the + // first may act. + if (m_roundDone || !m_streaming) + return; + m_roundDone = true; + + bool hasCalls = false; + for (const auto& builder : m_callBuilders) + if (builder.seen) + hasCalls = true; + if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) { + finishTurn(); + return; + } + if (m_round >= kMaxToolRounds) { + qWarning() << "LlmClient: tool round limit reached, ending turn"; + finishTurn(); + return; + } + + m_streaming->closeOpenSegments(); + + // Record the assistant's tool-call message in the transcript so the + // next round (and the model) can see it. + const QString content = m_streaming->content(); + const QString reasoning = m_streaming->reasoning(); + QJsonObject assistant; + assistant[QStringLiteral("role")] = QStringLiteral("assistant"); + if (content.size() > m_contentMark) { + assistant[QStringLiteral("content")] = content.mid(m_contentMark); + m_contentMark = content.size(); + } + if (reasoning.size() > m_reasoningMark) { + assistant[QStringLiteral("reasoning_content")] = + reasoning.mid(m_reasoningMark); + m_reasoningMark = reasoning.size(); + } + QJsonArray calls; + for (const auto& builder : m_callBuilders) { + if (!builder.seen) + continue; + QJsonObject function; + function[QStringLiteral("name")] = builder.name; + function[QStringLiteral("arguments")] = builder.arguments; + QJsonObject call; + call[QStringLiteral("id")] = builder.id; + call[QStringLiteral("type")] = QStringLiteral("function"); + call[QStringLiteral("function")] = function; + calls.append(call); + } + assistant[QStringLiteral("tool_calls")] = calls; + m_transcript.append(assistant); + m_round++; + + executeAllCalls(); +} + +void LlmClient::executeAllCalls() { + m_toolPhase = true; + m_pendingCalls = 0; + m_callResults = QList(m_callBuilders.size()); + + for (int i = 0; i < m_callBuilders.size(); ++i) { + const auto& call = m_callBuilders.at(i); + if (!call.seen) + continue; + + LlmTool* tool = m_tools->tool(call.name); + QJsonObject args; + QString errorText; + if (!tool) { + errorText = QStringLiteral("Error: unknown tool '%1'") + .arg(call.name); + } else if (!call.arguments.isEmpty()) { + const QJsonDocument doc = + QJsonDocument::fromJson(call.arguments.toUtf8()); + if (!doc.isObject()) { + errorText = + QStringLiteral("Error: tool arguments are not valid " + "JSON: %1") + .arg(call.arguments); + } else { + args = doc.object(); + } + } + if (!errorText.isEmpty()) { + m_callResults[i] = { errorText, false }; + if (LlmSegment* segment = call.segment) + segment->finishTool(errorText, false); + continue; + } + + ++m_pendingCalls; + tool->execute( + args, + [this, i, call](const QJsonObject& result) { + if (!m_streaming) + return; + const bool success = + result.contains(QStringLiteral("output")); + const QString content = success + ? result[QStringLiteral("output")].toString() + : QStringLiteral("Error: ") + + result[QStringLiteral("error")].toString(); + m_callResults[i] = { content, success }; + if (LlmSegment* segment = call.segment) + segment->finishTool(content, success); + if (--m_pendingCalls == 0) + flushCallResults(); + }); + } + if (m_pendingCalls == 0) + flushCallResults(); +} + +void LlmClient::flushCallResults() { + if (!m_toolPhase) + return; + m_toolPhase = false; + if (!m_streaming) + return; + for (int i = 0; i < m_callBuilders.size(); ++i) { + const auto& call = m_callBuilders.at(i); + if (!call.seen) + continue; + QJsonObject toolMessage; + toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); + toolMessage[QStringLiteral("tool_call_id")] = call.id; + toolMessage[QStringLiteral("content")] = + m_callResults.at(i).content; + m_transcript.append(toolMessage); + } + sendRound(); +} + +void LlmClient::stop() { + if (!m_busy) + return; + if (m_toolPhase) { + m_tools->cancelAll(); + if (m_streaming) { + for (auto* segment : m_streaming->segments()) { + if (segment->type() == LlmSegment::Type::ToolCall && + segment->running()) + segment->finishTool( + QStringLiteral("Cancelled"), false); + } + } + finishTurn(); + return; + } + if (m_reply) + m_reply->abort(); +} + +void LlmClient::endStream() { + if (!m_streaming) + return; + auto* generation = m_streaming; + auto* session = m_active; + m_streaming = nullptr; + m_active = nullptr; + generation->setStreaming(false); + if (generation->content().isEmpty() && + generation->reasoning().isEmpty() && + generation->toolCallCount() == 0) { + if (auto* message = qobject_cast(generation->parent())) { + if (message->generationCount() <= 1) { + if (session) + session->removeMessage(message); + } else { + message->removeGeneration(generation); + } + } + } + setBusy(false); + setStreamingChatId(QString()); +} + +void LlmClient::finishTurn() { + if (!m_streaming) + return; + ChatSession* session = m_active; + endStream(); + if (session && m_pendingClear == session) { + session->clearMessages(); + m_pendingClear.clear(); + } + if (session) + session->persist(); +} + +void LlmClient::clearOnFinish(ChatSession* session) { + m_pendingClear = session; +} + +void LlmClient::sessionRemoved(ChatSession* session) { + if (m_pendingClear == session) + m_pendingClear.clear(); + if (m_active == session) { + stop(); + endStream(); + } +} + +void LlmClient::fail(const QString& message) { + qWarning() << "LlmClient:" << message; + finishTurn(); + Q_EMIT errorOccurred(message); +} + +void LlmClient::drainBuffer() { + while (true) { + const qsizetype newline = m_buffer.indexOf('\n'); + if (newline < 0) + break; + const QByteArray line = m_buffer.left(newline).trimmed(); + m_buffer.remove(0, newline + 1); + handleLine(line); + } +} + +void LlmClient::handleLine(const QByteArray& line) { + if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) + return; + + const QByteArray data = line.mid(5).trimmed(); + if (data == "[DONE]") { + roundFinished(); + return; + } + + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (!doc.isObject()) + return; + const QJsonObject obj = doc.object(); + updateTokenUsage(obj); + + if (obj.contains("error")) { + const QJsonObject error = obj["error"].toObject(); + const QString message = error["message"].toString(); + fail(message.isEmpty() + ? QStringLiteral("LLM server returned an error") + : message); + return; + } + + for (const QJsonValue& choiceValue : obj["choices"].toArray()) { + if (!m_streaming) + continue; + const QJsonObject choice = choiceValue.toObject(); + const QJsonObject delta = choice["delta"].toObject(); + + const QString finishReason = + choice[QStringLiteral("finish_reason")].toString(); + if (!finishReason.isEmpty()) + m_finishReason = finishReason; + + m_streaming->appendContent(delta["content"].toString()); + QString reasoning = + delta["reasoning_content"].toString(); + if (reasoning.isEmpty()) + reasoning = delta["reasoning"].toString(); + m_streaming->appendReasoning(reasoning); + + for (const QJsonValue& callValue : + delta["tool_calls"].toArray()) { + if (!m_streaming) + break; + applyToolCallDelta(callValue.toObject()); + } + } +} + +void LlmClient::refreshModels() { + const QUrl url = + QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); + if (!url.isValid() || url.host().isEmpty()) + return; + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + if (error != QNetworkReply::NoError) { + qWarning() << "LlmClient: failed to fetch models:" << error; + return; + } + const QJsonArray dataArr = + QJsonDocument::fromJson(data).object()["data"].toArray(); + + QStringList models; + QSet seen; + for (const QJsonValue& value : dataArr) { + const QString id = value.toObject()["id"].toString(); + if (!id.isEmpty() && !seen.contains(id)) { + seen.insert(id); + models.append(id); + } + } + if (models.isEmpty()) + return; + + m_availableModels = models; + Q_EMIT availableModelsChanged(); + + if (m_model.isEmpty()) { + m_model = models.first(); + Q_EMIT modelChanged(); + } + }); +} + +void LlmClient::setContextSize(int size) { + if (size <= 0 || m_contextSize == size) + return; + m_contextSize = size; + Q_EMIT contextSizeChanged(); +} + +void LlmClient::probeContextSize() { + // llama.cpp-specific endpoint; other servers fall back to 4096. + QString base = m_endpoint.trimmed(); + while (base.endsWith('/')) + base.chop(1); + const QUrl url = QUrl::fromUserInput(base + "/props"); + if (!url.isValid() || url.host().isEmpty()) + return; + + auto* reply = m_manager.get(QNetworkRequest(url)); + connect( + reply, + &QNetworkReply::finished, + this, + [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + int size = 0; + if (error == QNetworkReply::NoError) { + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (doc.isArray()) { + for (const auto& value : doc.array()) { + const QJsonObject slot = value.toObject(); + if (slot.contains("n_ctx")) { + size = slot["n_ctx"].toInt(0); + if (size > 0) + break; + } + } + } else if (doc.isObject()) { + const QJsonObject obj = doc.object(); + size = obj["n_ctx"].toInt(0); + if (size <= 0) + size = obj["default_generation_settings"].toObject() + ["n_ctx"].toInt(0); + } + } + setContextSize(size > 0 ? size : 4096); + }); +} + +void LlmClient::updateTokenUsage(const QJsonObject& data) { + if (!m_active || m_contextSize <= 0) + return; + const QJsonObject usage = data["usage"].toObject(); + if (usage.isEmpty()) + return; + const double used = + usage.value("prompt_tokens").toDouble() + + usage.value("completion_tokens").toDouble(); + if (used > 0) + m_active->setLastTokenCount(static_cast(used)); +} + void LlmClient::shortRequest( const QString& tag, const QString& systemPrompt, @@ -340,229 +855,4 @@ void LlmClient::requestIcon(ChatSession* session, const QString& userText) { }); } -void LlmClient::stop() { - if (!m_busy) - return; - if (m_reply) - m_reply->abort(); -} - -void LlmClient::endStream() { - if (!m_streaming) - return; - auto* generation = m_streaming; - auto* session = m_active; - m_streaming = nullptr; - m_active = nullptr; - generation->setStreaming(false); - if (generation->content().isEmpty() && - generation->reasoning().isEmpty()) { - if (auto* message = qobject_cast(generation->parent())) { - if (message->generationCount() <= 1) { - if (session) - session->removeMessage(message); - } else { - message->removeGeneration(generation); - } - } - } - setBusy(false); - setStreamingChatId(QString()); -} - -void LlmClient::finalize() { - if (!m_streaming) - return; - ChatSession* session = m_active; - endStream(); - if (session && m_pendingClear == session) { - session->clearMessages(); - m_pendingClear.clear(); - } - if (session) - session->persist(); -} - -void LlmClient::clearOnFinish(ChatSession* session) { - m_pendingClear = session; -} - -void LlmClient::sessionRemoved(ChatSession* session) { - if (m_pendingClear == session) - m_pendingClear.clear(); - if (m_active == session) { - stop(); - endStream(); - } -} - -void LlmClient::fail(const QString& message) { - qWarning() << "LlmClient:" << message; - - if (m_streaming) { - ChatSession* session = m_active; - endStream(); - if (session && m_pendingClear == session) { - session->clearMessages(); - m_pendingClear.clear(); - } - if (session) - session->persist(); - } - Q_EMIT errorOccurred(message); -} - -void LlmClient::drainBuffer() { - while (true) { - const qsizetype newline = m_buffer.indexOf('\n'); - if (newline < 0) - break; - const QByteArray line = m_buffer.left(newline).trimmed(); - m_buffer.remove(0, newline + 1); - handleLine(line); - } -} - -void LlmClient::handleLine(const QByteArray& line) { - if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) - return; - - const QByteArray data = line.mid(5).trimmed(); - if (data == "[DONE]") { - finalize(); - return; - } - - const QJsonDocument doc = QJsonDocument::fromJson(data); - if (!doc.isObject()) - return; - const QJsonObject obj = doc.object(); - updateTokenUsage(obj); - - if (obj.contains("error")) { - const QJsonObject error = obj["error"].toObject(); - const QString message = error["message"].toString(); - fail(message.isEmpty() - ? QStringLiteral("LLM server returned an error") - : message); - return; - } - - for (const QJsonValue& choiceValue : obj["choices"].toArray()) { - if (!m_streaming) - continue; - const QJsonObject delta = - choiceValue.toObject()["delta"].toObject(); - m_streaming->appendContent(delta["content"].toString()); - QString reasoning = delta["reasoning_content"].toString(); - if (reasoning.isEmpty()) - reasoning = delta["reasoning"].toString(); - m_streaming->appendReasoning(reasoning); - } -} - -void LlmClient::refreshModels() { - const QUrl url = - QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); - if (!url.isValid() || url.host().isEmpty()) - return; - - auto* reply = m_manager.get(QNetworkRequest(url)); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - const QNetworkReply::NetworkError error = reply->error(); - const QByteArray data = reply->readAll(); - reply->deleteLater(); - - if (error != QNetworkReply::NoError) { - qWarning() << "LlmClient: failed to fetch models:" << error; - return; - } - const QJsonArray dataArr = - QJsonDocument::fromJson(data).object()["data"].toArray(); - - QStringList models; - QSet seen; - for (const QJsonValue& value : dataArr) { - const QString id = value.toObject()["id"].toString(); - if (!id.isEmpty() && !seen.contains(id)) { - seen.insert(id); - models.append(id); - } - } - if (models.isEmpty()) - return; - - m_availableModels = models; - Q_EMIT availableModelsChanged(); - - if (m_model.isEmpty()) { - m_model = models.first(); - Q_EMIT modelChanged(); - } - }); -} - -void LlmClient::setContextSize(int size) { - if (size <= 0 || m_contextSize == size) - return; - m_contextSize = size; - Q_EMIT contextSizeChanged(); -} - -void LlmClient::probeContextSize() { - // llama.cpp-specific endpoint; other servers fall back to 4096. - QString base = m_endpoint.trimmed(); - while (base.endsWith('/')) - base.chop(1); - const QUrl url = QUrl::fromUserInput(base + "/props"); - if (!url.isValid() || url.host().isEmpty()) - return; - - auto* reply = m_manager.get(QNetworkRequest(url)); - connect( - reply, - &QNetworkReply::finished, - this, - [this, reply]() { - const QNetworkReply::NetworkError error = reply->error(); - const QByteArray data = reply->readAll(); - reply->deleteLater(); - - int size = 0; - if (error == QNetworkReply::NoError) { - const QJsonDocument doc = QJsonDocument::fromJson(data); - if (doc.isArray()) { - for (const auto& value : doc.array()) { - const QJsonObject slot = value.toObject(); - if (slot.contains("n_ctx")) { - size = slot["n_ctx"].toInt(0); - if (size > 0) - break; - } - } - } else if (doc.isObject()) { - const QJsonObject obj = doc.object(); - size = obj["n_ctx"].toInt(0); - if (size <= 0) - size = obj["default_generation_settings"].toObject() - ["n_ctx"].toInt(0); - } - } - setContextSize(size > 0 ? size : 4096); - }); -} - -void LlmClient::updateTokenUsage(const QJsonObject& data) { - if (!m_active || m_contextSize <= 0) - return; - const QJsonObject usage = data["usage"].toObject(); - if (usage.isEmpty()) - return; - const double used = - usage.value("prompt_tokens").toDouble() + - usage.value("completion_tokens").toDouble(); - if (used > 0) - m_active->setLastTokenCount(static_cast(used)); -} - } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp index 4f3f339..1397d57 100644 --- a/Plugins/ZShell/Llm/llmclient.hpp +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -1,11 +1,16 @@ #pragma once +#include "segment.hpp" +#include "tool.hpp" + #include +#include #include #include #include #include #include + #include class QJsonObject; @@ -15,9 +20,12 @@ namespace ZShell::llm { class ChatGeneration; class ChatSession; +class LlmSegment; +class LlmTool; // The only component that talks to the LLM server: owns the network -// manager, the in-flight streaming state and the SSE parsing. +// manager, the in-flight streaming state, the SSE parsing and the +// tool-calling loop. class LlmClient : public QObject { Q_OBJECT @@ -32,6 +40,7 @@ class LlmClient : public QObject { return m_availableModels; } [[nodiscard]] int contextSize() const { return m_contextSize; } + [[nodiscard]] ToolRegistry* tools() const { return m_tools; } void setEndpoint(const QString& value); void setModel(const QString& value); void setTemperature(double value); @@ -39,12 +48,16 @@ class LlmClient : public QObject { void probeContextSize(); [[nodiscard]] bool busy() const { return m_busy; } + [[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); } + void setToolsEnabled(bool value) { m_tools->setEnabled(value); } [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } [[nodiscard]] ChatSession* streamingSession() const { return m_active; } - // Streams a new assistant reply into `target` (the active generation of - // a message of `session`). The context sent to the model is every + // Streams a new assistant reply into `target` (the active generation + // of a message of `session`). The context sent to the model is every // message of the session that is older than the target message. + // Tool calls made by the model are executed and fed back + // transparently until the model produces its final answer. void startGeneration(ChatSession* session, ChatGeneration* target); void stop(); void endStream(); @@ -63,13 +76,39 @@ class LlmClient : public QObject { void modelChanged(); void availableModelsChanged(); void contextSizeChanged(); + void toolsEnabledChanged(); void streamingChatIdChanged(); void errorOccurred(const QString& message); void titleSuggested(ZShell::llm::ChatSession* session, const QString& title); void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon); private: - void finalize(); + struct ToolCallBuilder { + QString id; + QString name; + QString arguments; + QPointer segment; + bool seen = false; + }; + + // Sends one streaming round: context + transcript so far. + void sendRound(); + // The session context for a round, oldest first, ending just before + // `stopBeforeRow` (the message of the generation being streamed). + QJsonArray buildContextMessages( + ChatSession* session, int stopBeforeRow) const; + void applyToolCallDelta(const QJsonObject& call); + // One round's stream ended; either ends the turn or executes the + // requested tool calls and sends the next round. Runs at most once + // per round ([DONE] and the reply's finished signal both reach it). + void roundFinished(); + // Dispatches every call of the round; tools run concurrently. + void executeAllCalls(); + // All results in: appends the tool messages (in call order) and + // sends the next round. + void flushCallResults(); + // Ends the current turn gracefully and persists the session. + void finishTurn(); void fail(const QString& message); void handleLine(const QByteArray& line); void drainBuffer(); @@ -86,6 +125,7 @@ class LlmClient : public QObject { const QByteArray& body, const QString& fallback); QNetworkAccessManager m_manager; + ToolRegistry* m_tools = nullptr; QNetworkReply* m_reply = nullptr; QByteArray m_buffer; ChatSession* m_active = nullptr; @@ -98,6 +138,23 @@ class LlmClient : public QObject { QStringList m_availableModels; double m_temperature = 0.7; int m_contextSize = 0; + + // State of the multi-round tool loop of the current turn. + QJsonArray m_transcript; + QList m_callBuilders; + struct ToolCallResult { + QString content; + bool success = false; + }; + QList m_callResults; + QString m_finishReason; + int m_round = 0; + qsizetype m_contentMark = 0; + qsizetype m_reasoningMark = 0; + bool m_roundDone = false; + bool m_toolPhase = false; + int m_pendingCalls = 0; + static constexpr int kMaxToolRounds = 12; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownblock.hpp b/Plugins/ZShell/Llm/markdownblock.hpp new file mode 100644 index 0000000..cbc6408 --- /dev/null +++ b/Plugins/ZShell/Llm/markdownblock.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace ZShell::llm { + +// Marker class exposing the LlmMarkdown block type enum to QML +// (LlmMarkdown.Type.*). Blocks themselves are value maps produced by +// MarkdownParser and stored on LlmSegment. +class LlmMarkdown : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Blocks are produced by LlmSegment") + + public: + enum class Type : int { + Text = 0, // Paragraph, list, quote, table; "text" is markdown source + Heading, // "level" + "text" (markdown source of the content) + Code, // "language" + "code" + Math // "latex" (display math, without the $$ delimiters) + }; + Q_ENUM(Type) + + explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {} +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownparser.cpp b/Plugins/ZShell/Llm/markdownparser.cpp new file mode 100644 index 0000000..1203d72 --- /dev/null +++ b/Plugins/ZShell/Llm/markdownparser.cpp @@ -0,0 +1,144 @@ +#include "markdownparser.hpp" + +#include "markdownblock.hpp" + +#include +#include + +#include +#include +#include + +namespace ZShell::llm { + +QVariantList MarkdownParser::parse(const QString& source) { + QVariantList blocks; + if (source.trimmed().isEmpty()) + return blocks; + + const QStringList lines = source.split('\n'); + + // cmark-gfm line/column numbers are 1-based and inclusive. + auto sliceSource = [&](int startLine, int endLine) -> QString { + if (startLine < 1 || endLine < startLine) + return QString(); + const int from = startLine; + const int to = qMin(endLine, static_cast(lines.size())); + return lines.mid(from - 1, to - from + 1).join('\n').trimmed(); + }; + + const QByteArray utf8 = source.toUtf8(); + cmark_node* doc = cmark_parse_document( + utf8.constData(), static_cast(utf8.size()), + CMARK_OPT_DEFAULT | CMARK_OPT_SOURCEPOS); + if (!doc) + return blocks; + + // cmark-gfm has no math extension, so $$...$$ parses as an ordinary + // paragraph. Re-detect display math (a $$...$$ pair) here and split it + // out as its own block. Single-$ inline math is intentionally left + // untouched (rendered raw) for now. + const QRegularExpression mathRe( + QStringLiteral("\\$\\$(.+?)\\$\\$"), + QRegularExpression::DotMatchesEverythingOption); + + auto makeBlock = [](LlmMarkdown::Type type) { + QVariantMap block; + block.insert("type", static_cast(type)); + return block; + }; + + auto appendText = [&](const QString& text) { + if (text.trimmed().isEmpty()) + return; + QVariantMap block = makeBlock(LlmMarkdown::Type::Text); + block.insert("text", text); + blocks.append(block); + }; + + auto appendMath = [&](const QString& latex) { + if (latex.trimmed().isEmpty()) + return; + QVariantMap block = makeBlock(LlmMarkdown::Type::Math); + block.insert("latex", latex); + blocks.append(block); + }; + + auto appendCode = [&](const QString& language, const QString& code) { + QVariantMap block = makeBlock(LlmMarkdown::Type::Code); + block.insert("language", language); + block.insert("code", code); + blocks.append(block); + }; + + auto appendHeading = [&](int level, const QString& text) { + if (text.trimmed().isEmpty()) + return; + QVariantMap block = makeBlock(LlmMarkdown::Type::Heading); + block.insert("level", level); + block.insert("text", text); + blocks.append(block); + }; + + for (cmark_node* node = cmark_node_first_child(doc); node; + node = cmark_node_next(node)) { + const cmark_node_type type = cmark_node_get_type(node); + const int startLine = cmark_node_get_start_line(node); + const int endLine = cmark_node_get_end_line(node); + + if (type == CMARK_NODE_CODE_BLOCK) { + const char* literal = cmark_node_get_literal(node); + QString code = literal ? QString::fromUtf8(literal) : QString(); + // Fenced block literals carry a trailing newline; drop one. + if (code.endsWith('\n')) + code.chop(1); + + QString language; + if (const char* info = cmark_node_get_fence_info(node); info) + language = QString::fromUtf8(info).section(' ', 0, 0).trimmed().toLower(); + + appendCode(language, code); + continue; + } + + if (type == CMARK_NODE_HEADING) { + // Inline content only (no leading #), so QML can style by + // level. + const char* content = cmark_node_get_string_content(node); + appendHeading( + cmark_node_get_heading_level(node), + content ? QString::fromUtf8(content).trimmed() : QString()); + continue; + } + + if (type == CMARK_NODE_PARAGRAPH) { + const QString text = sliceSource(startLine, endLine); + int cursor = 0; + bool anyMath = false; + for (auto it = mathRe.globalMatch(text, cursor); it.hasNext(); + it = mathRe.globalMatch(text, cursor)) { + const QRegularExpressionMatch m = it.next(); + anyMath = true; + appendText( + text.mid(cursor, static_cast(m.capturedStart() - cursor))); + appendMath(m.captured(1).trimmed()); + cursor = static_cast(m.capturedEnd()); + } + if (!anyMath) + appendText(text); + else + appendText(text.mid(cursor)); + continue; + } + + // Lists, block quotes, tables, horizontal rules, custom blocks: + // hand the raw markdown source to QML (rendered via + // Text.MarkdownText). + appendText(sliceSource(startLine, endLine)); + } + + cmark_node_free(doc); + return blocks; +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownparser.hpp b/Plugins/ZShell/Llm/markdownparser.hpp new file mode 100644 index 0000000..ea739d7 --- /dev/null +++ b/Plugins/ZShell/Llm/markdownparser.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace ZShell::llm { + +// Splits a markdown document into top-level blocks for QML rendering +// (cmark-gfm AST walk). Inline structure is not flattened; Text and +// Heading blocks carry markdown source that QML renders with +// Text.MarkdownText. +// +// Block map keys: +// "type" int (LlmMarkdown::Type) +// "level" int (Heading) +// "language" QString (Code, lowercased, empty when unknown) +// "code" QString (Code) +// "text" QString (Text/Heading, markdown source) +// "latex" QString (Math, without the $$ delimiters) +class MarkdownParser { + public: + [[nodiscard]] static QVariantList parse(const QString& source); +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/mathtext.cpp b/Plugins/ZShell/Llm/mathtext.cpp new file mode 100644 index 0000000..4b87f30 --- /dev/null +++ b/Plugins/ZShell/Llm/mathtext.cpp @@ -0,0 +1,176 @@ +#include "mathtext.hpp" + +#include "latinmodern-fonts.hpp" + +#include + +#include +#include +#include +#include + +namespace ZShell::llm { + +namespace { +// A few px of breathing room around the equation. +constexpr int kRenderMargin = 2; +constexpr unsigned int kResolutionDpi = 96; + +// Registers the embedded Latin Modern faces with the font database +// (once per process) and reports which families became available. +struct LatinModern { + bool roman = false; + bool math = false; +}; + +const LatinModern& loadLatinModern() { + static const LatinModern fonts = [] { + LatinModern result; + // addApplicationFont(QByteArray) fails in this environment, so + // the embedded bytes are staged to a per-process temp file and + // registered through the (stable) file-based API. + const auto add = [](const unsigned char* data, size_t size, + const QString& fileName, const QString& family) { + const QString path = QDir::tempPath() + QLatin1Char('/') + fileName; + { + QFile f(path); + if (!f.open(QIODevice::WriteOnly) || + f.write(reinterpret_cast(data), + static_cast(size)) != static_cast(size)) + return false; + } + const int key = QFontDatabase::addApplicationFont(path); + if (key < 0) + return false; + return QFontDatabase::applicationFontFamilies(key).contains(family); + }; + result.roman = + add(lmfont::lmroman10_regular, sizeof(lmfont::lmroman10_regular), + QStringLiteral("lmroman10-regular.otf"), QStringLiteral("LMRoman10")) + && add(lmfont::lmroman10_italic, sizeof(lmfont::lmroman10_italic), + QStringLiteral("lmroman10-italic.otf"), QStringLiteral("LMRoman10")) + && add(lmfont::lmroman10_bold, sizeof(lmfont::lmroman10_bold), + QStringLiteral("lmroman10-bold.otf"), QStringLiteral("LMRoman10")) + && add(lmfont::lmroman10_bolditalic, sizeof(lmfont::lmroman10_bolditalic), + QStringLiteral("lmroman10-bolditalic.otf"), + QStringLiteral("LMRoman10")); + result.math = add( + lmfont::latinmodern_math, sizeof(lmfont::latinmodern_math), + QStringLiteral("latinmodern-math.otf"), QStringLiteral("Latin Modern Math")); + return result; + }(); + return fonts; +} + +} // namespace + +LlmMathText::LlmMathText(QObject* parent) +: QObject(parent), m_renderer(parent, /* useFontsForGUI */ true) { + // Latin Modern is the default font of modern LaTeX; use the embedded + // faces instead of whatever the system happens to have installed. + const LatinModern& fonts = loadLatinModern(); + if (fonts.roman) + m_renderer.setFontRomanAndMath(QStringLiteral("LMRoman10"), + JKQTMathTextFontEncoding::MTFEUnicode); + if (fonts.math) { + // Same pattern as JKQTMathText's useXITS(): the OpenType math + // font supplies the math alphabet and operators from its MATH + // table. + m_renderer.setFontMathRoman(QStringLiteral("Latin Modern Math"), + JKQTMathTextFontEncoding::MTFEUnicode); + m_renderer.setFallbackFontSymbols( + QStringLiteral("Latin Modern Math"), + JKQTMathTextFontEncoding::MTFEUnicode); + } +} + +void LlmMathText::setLatex(const QString& value) { + if (m_latex == value) + return; + m_latex = value; + reRender(); +} + +void LlmMathText::setColor(const QColor& value) { + if (m_color == value) + return; + m_color = value; + reRender(); +} + +void LlmMathText::setFontPointSize(double value) { + if (qFuzzyCompare(m_fontPointSize, value)) + return; + m_fontPointSize = value; + reRender(); +} + +void LlmMathText::setDevicePixelRatio(qreal value) { + if (qFuzzyCompare(m_devicePixelRatio, value)) + return; + m_devicePixelRatio = value; + reRender(); +} + +void LlmMathText::reRender() { + if (m_latex.trimmed().isEmpty()) { + m_image = QImage(); + m_imageUrl = QUrl(); + m_width = 0; + m_height = 0; + m_ok = false; + Q_EMIT changed(); + return; + } + + m_renderer.setFontPointSize(m_fontPointSize); + m_renderer.setFontColor(m_color); + + const bool ok = m_renderer.parse( + m_latex, + JKQTMathText::LatexParser, + JKQTMathText::DefaultParseOptions); + if (!ok) { + m_image = QImage(); + m_imageUrl = QUrl(); + m_width = 0; + m_height = 0; + m_ok = false; + Q_EMIT changed(); + return; + } + + const QImage image = m_renderer.drawIntoImage( + /* drawBoxes */ false, + QColor(Qt::transparent), + kRenderMargin, + m_devicePixelRatio, + kResolutionDpi); + if (image.isNull()) { + m_image = QImage(); + m_imageUrl = QUrl(); + m_width = 0; + m_height = 0; + m_ok = false; + Q_EMIT changed(); + return; + } + + m_image = image; + QByteArray png; + { + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + image.save(&buffer, "PNG"); + } + m_imageUrl = QUrl( + QStringLiteral("data:image/png;base64,") + QString::fromLatin1(png.toBase64())); + // drawIntoImage renders at devicePixelRatio; convert back to + // logical pixels. + m_width = image.width() / m_devicePixelRatio; + m_height = image.height() / m_devicePixelRatio; + m_ok = true; + Q_EMIT changed(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/mathtext.hpp b/Plugins/ZShell/Llm/mathtext.hpp new file mode 100644 index 0000000..3b713fc --- /dev/null +++ b/Plugins/ZShell/Llm/mathtext.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace ZShell::llm { + +// QML wrapper around JKQTMathText (JKQtPlotter's LaTeX renderer). +// +// Parses a display-math string and renders it into a transparent +// QImage at the given device pixel ratio. QML displays the image +// (scaling it to the bubble width when needed) and falls back to the +// raw LaTeX when parsing fails. +class LlmMathText : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString latex READ latex WRITE setLatex NOTIFY changed) + Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY changed) + Q_PROPERTY(double fontPointSize READ fontPointSize WRITE setFontPointSize NOTIFY changed) + Q_PROPERTY(qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio NOTIFY changed) + Q_PROPERTY(QImage image READ image NOTIFY changed) + // data: URL of the rendered equation; usable directly as + // Image.source (a raw QImage is not). + Q_PROPERTY(QUrl imageUrl READ imageUrl NOTIFY changed) + // Logical (CSS pixel) size of the rendered equation. + Q_PROPERTY(qreal width READ width NOTIFY changed) + Q_PROPERTY(qreal height READ height NOTIFY changed) + Q_PROPERTY(bool ok READ ok NOTIFY changed) + + public: + explicit LlmMathText(QObject* parent = nullptr); + + [[nodiscard]] QString latex() const { return m_latex; } + void setLatex(const QString& value); + [[nodiscard]] QColor color() const { return m_color; } + void setColor(const QColor& value); + [[nodiscard]] double fontPointSize() const { return m_fontPointSize; } + void setFontPointSize(double value); + [[nodiscard]] qreal devicePixelRatio() const { return m_devicePixelRatio; } + void setDevicePixelRatio(qreal value); + [[nodiscard]] QImage image() const { return m_image; } + [[nodiscard]] QUrl imageUrl() const { return m_imageUrl; } + [[nodiscard]] qreal width() const { return m_width; } + [[nodiscard]] qreal height() const { return m_height; } + [[nodiscard]] bool ok() const { return m_ok; } + + Q_SIGNALS: + void changed(); + + private: + void reRender(); + + JKQTMathText m_renderer; + QString m_latex; + QColor m_color; + double m_fontPointSize = 12.0; + qreal m_devicePixelRatio = 1.0; + QImage m_image; + QUrl m_imageUrl; + qreal m_width = 0; + qreal m_height = 0; + bool m_ok = false; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp index b0cde7c..6c5f2b5 100644 --- a/Plugins/ZShell/Llm/message.cpp +++ b/Plugins/ZShell/Llm/message.cpp @@ -18,16 +18,8 @@ ChatSession* sessionOf(const ChatMessage* message) { ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent) : QObject(parent), m_role(role), m_timestamp(timestamp) {} -ChatGeneration* ChatMessage::addGeneration( - qint64 timestamp, - const QString& content, - const QString& reasoning, - qint64 reasoningElapsedMs, - qint64 contentElapsedMs) { +ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) { auto* generation = new ChatGeneration(timestamp, this); - generation->setContent(content); - generation->setReasoning(reasoning); - generation->setElapsedMs(reasoningElapsedMs, contentElapsedMs); m_generations.append(generation); if (m_active < 0) m_active = static_cast(m_generations.size() - 1); @@ -36,8 +28,7 @@ ChatGeneration* ChatMessage::addGeneration( } ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) { - auto* generation = - addGeneration(timestamp, QString(), QString(), 0, 0); + auto* generation = addGeneration(timestamp); setActiveInternal(static_cast(m_generations.size() - 1)); return generation; } diff --git a/Plugins/ZShell/Llm/message.hpp b/Plugins/ZShell/Llm/message.hpp index 19abc4a..5fbaaf5 100644 --- a/Plugins/ZShell/Llm/message.hpp +++ b/Plugins/ZShell/Llm/message.hpp @@ -58,12 +58,8 @@ class ChatMessage : public QObject { Q_INVOKABLE void retry(); Q_INVOKABLE void generate(); - ChatGeneration* addGeneration( - qint64 timestamp, - const QString& content, - const QString& reasoning, - qint64 reasoningElapsedMs, - qint64 contentElapsedMs); + // Creates an empty generation; callers fill it with segments. + ChatGeneration* addGeneration(qint64 timestamp); ChatGeneration* appendGeneration(qint64 timestamp); void removeGeneration(ChatGeneration* generation); diff --git a/Plugins/ZShell/Llm/messagemodel.cpp b/Plugins/ZShell/Llm/messagemodel.cpp index 03f40b8..534c409 100644 --- a/Plugins/ZShell/Llm/messagemodel.cpp +++ b/Plugins/ZShell/Llm/messagemodel.cpp @@ -44,7 +44,8 @@ ChatMessage* ChatMessageModel::createMessage( ChatMessage* ChatMessageModel::appendNewest( ChatMessage::Role role, const QString& content, qint64 timestamp) { auto* message = new ChatMessage(role, timestamp, this); - message->addGeneration(timestamp, content, QString(), 0, 0); + auto* generation = message->addGeneration(timestamp); + generation->setContent(content); beginInsertRows(QModelIndex(), 0, 0); m_messages.prepend(message); diff --git a/Plugins/ZShell/Llm/segment.cpp b/Plugins/ZShell/Llm/segment.cpp new file mode 100644 index 0000000..c0a66a0 --- /dev/null +++ b/Plugins/ZShell/Llm/segment.cpp @@ -0,0 +1,138 @@ +#include "segment.hpp" + +#include "markdownparser.hpp" + +#include + +namespace ZShell::llm { + +namespace { +// Re-parse cadence while a segment streams; content between refreshes is +// at most this stale. +constexpr int kMarkdownRefreshMs = 150; +} // namespace + +LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent) +: QObject(parent), m_type(type), m_timestamp(timestamp) { + m_markdownTimer.setInterval(kMarkdownRefreshMs); + connect( + &m_markdownTimer, &QTimer::timeout, this, [this]() { + if (!m_markdownDirty) + return; + m_markdownDirty = false; + parseMarkdown(); + }); +} + +qint64 LlmSegment::elapsedMs() const { + if (m_startedAt <= 0) + return 0; + const qint64 end = m_endedAt > 0 ? m_endedAt + : QDateTime::currentMSecsSinceEpoch(); + return end - m_startedAt; +} + +void LlmSegment::begin() { + if (m_running) + return; + m_running = true; + m_startedAt = QDateTime::currentMSecsSinceEpoch(); + m_endedAt = 0; + Q_EMIT runningChanged(); + Q_EMIT elapsedMsChanged(); +} + +void LlmSegment::close() { + if (m_running) { + m_running = false; + m_endedAt = QDateTime::currentMSecsSinceEpoch(); + Q_EMIT runningChanged(); + Q_EMIT elapsedMsChanged(); + } + // The segment is final; parse immediately so the UI does not wait + // out the debounce. + if (m_type == Type::Content && m_markdownDirty) { + m_markdownTimer.stop(); + parseMarkdown(); + } +} + +void LlmSegment::appendText(const QString& piece) { + if (piece.isEmpty()) + return; + m_text += piece; + Q_EMIT textChanged(); + scheduleMarkdown(); +} + +void LlmSegment::setText(const QString& value) { + if (m_text == value) + return; + m_text = value; + Q_EMIT textChanged(); + scheduleMarkdown(); +} + +void LlmSegment::setName(const QString& value) { + if (m_name == value) + return; + m_name = value; + Q_EMIT nameChanged(); +} + +void LlmSegment::setToolCallId(const QString& value) { + if (m_toolCallId == value) + return; + m_toolCallId = value; + Q_EMIT toolCallIdChanged(); +} + +void LlmSegment::appendArguments(const QString& piece) { + if (piece.isEmpty()) + return; + m_arguments += piece; + Q_EMIT argumentsChanged(); +} + +void LlmSegment::setResult(const QString& value) { + if (m_result == value) + return; + m_result = value; + Q_EMIT resultChanged(); +} + +void LlmSegment::setStatus(Status value) { + if (m_status == value) + return; + m_status = value; + Q_EMIT statusChanged(); +} + +void LlmSegment::finishTool(const QString& resultText, bool success) { + setResult(resultText); + setStatus(success ? Status::Success : Status::Error); + close(); +} + +void LlmSegment::restore(qint64 elapsedMs) { + m_startedAt = m_timestamp; + m_endedAt = m_timestamp + qMax(0, elapsedMs); +} + +void LlmSegment::scheduleMarkdown() { + // Content segments only; reasoning/tool output is never parsed. + // (User content is parsed too, so it can later be rendered as blocks + // as well; the QML currently only does that for assistant messages.) + if (m_type != Type::Content) + return; + m_markdownDirty = true; + if (!m_markdownTimer.isActive()) + m_markdownTimer.start(); +} + +void LlmSegment::parseMarkdown() { + m_markdown = MarkdownParser::parse(m_text); + Q_EMIT markdownChanged(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/segment.hpp b/Plugins/ZShell/Llm/segment.hpp new file mode 100644 index 0000000..9033e32 --- /dev/null +++ b/Plugins/ZShell/Llm/segment.hpp @@ -0,0 +1,113 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +// One unit of activity within a ChatGeneration. A generation keeps its +// segments in chronological order: zero or more reasoning bursts and +// tool calls interleaved, plus at most one content segment holding the +// final answer. +class LlmSegment : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Segments are managed by ChatGeneration") + + Q_PROPERTY(Type type READ type CONSTANT) + Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + Q_PROPERTY(QString text READ text NOTIFY textChanged) + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QString toolCallId READ toolCallId NOTIFY toolCallIdChanged) + Q_PROPERTY(QString arguments READ arguments NOTIFY argumentsChanged) + Q_PROPERTY(QString result READ result NOTIFY resultChanged) + Q_PROPERTY(Status status READ status NOTIFY statusChanged) + Q_PROPERTY(bool running READ running NOTIFY runningChanged) + Q_PROPERTY(qint64 elapsedMs READ elapsedMs NOTIFY elapsedMsChanged) + // Parsed markdown blocks (QVariantList of maps, see MarkdownParser). + // Only Content segments are parsed. Refreshes are debounced so a + // streaming segment re-parses at a steady cadence rather than per + // chunk. + Q_PROPERTY(QVariantList markdown READ markdown NOTIFY markdownChanged) + + public: + enum class Type : int { + Reasoning = 0, + ToolCall, + Content + }; + Q_ENUM(Type) + + enum class Status : int { + None = 0, + Running, + Success, + Error + }; + Q_ENUM(Status) + + explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); + + [[nodiscard]] Type type() const { return m_type; } + [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + [[nodiscard]] QString text() const { return m_text; } + [[nodiscard]] QString name() const { return m_name; } + [[nodiscard]] QString toolCallId() const { return m_toolCallId; } + [[nodiscard]] QString arguments() const { return m_arguments; } + [[nodiscard]] QString result() const { return m_result; } + [[nodiscard]] Status status() const { return m_status; } + [[nodiscard]] bool running() const { return m_running; } + [[nodiscard]] qint64 elapsedMs() const; + [[nodiscard]] QVariantList markdown() const { return m_markdown; } + + // Starts the segment's clock; a no-op while already running. + void begin(); + // Stops the segment's clock; a no-op when not running. + void close(); + void appendText(const QString& piece); + void setText(const QString& value); + void setName(const QString& value); + void setToolCallId(const QString& value); + void appendArguments(const QString& piece); + void setResult(const QString& value); + void setStatus(Status value); + // Completes a tool call with the model-facing result text. + void finishTool(const QString& resultText, bool success); + // Restores persisted timing without a live clock. + void restore(qint64 elapsedMs); + + Q_SIGNALS: + void textChanged(); + void markdownChanged(); + void nameChanged(); + void toolCallIdChanged(); + void argumentsChanged(); + void resultChanged(); + void statusChanged(); + void runningChanged(); + void elapsedMsChanged(); + + private: + void scheduleMarkdown(); + void parseMarkdown(); + + Type m_type; + qint64 m_timestamp; + QString m_text; + QString m_name; + QString m_toolCallId; + QString m_arguments; + QString m_result; + Status m_status = Status::None; + bool m_running = false; + qint64 m_startedAt = 0; + qint64 m_endedAt = 0; + QVariantList m_markdown; + QTimer m_markdownTimer; + bool m_markdownDirty = false; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/tool.cpp b/Plugins/ZShell/Llm/tool.cpp new file mode 100644 index 0000000..15a9c25 --- /dev/null +++ b/Plugins/ZShell/Llm/tool.cpp @@ -0,0 +1,59 @@ +#include "tool.hpp" + +namespace ZShell::llm { + +LlmTool::LlmTool(QObject* parent) : QObject(parent) {} + +LlmTool::~LlmTool() = default; + +void LlmTool::cancel() {} + +QJsonObject LlmTool::specification() const { + QJsonObject function; + function[QStringLiteral("name")] = name(); + function[QStringLiteral("description")] = description(); + function[QStringLiteral("parameters")] = parameters(); + QJsonObject spec; + spec[QStringLiteral("type")] = QStringLiteral("function"); + spec[QStringLiteral("function")] = function; + return spec; +} + +ToolRegistry::ToolRegistry(QObject* parent) : QObject(parent) {} + +void ToolRegistry::setEnabled(bool value) { + if (m_enabled == value) + return; + m_enabled = value; + Q_EMIT enabledChanged(); +} + +void ToolRegistry::registerTool(LlmTool* tool) { + if (!tool || m_tools.contains(tool)) + return; + tool->setParent(this); + m_tools.append(tool); +} + +LlmTool* ToolRegistry::tool(const QString& name) const { + for (const auto* tool : m_tools) + if (tool->name() == name) + return const_cast(tool); + return nullptr; +} + +QJsonArray ToolRegistry::specifications() const { + if (!m_enabled) + return {}; + QJsonArray specs; + for (const auto* tool : m_tools) + specs.append(tool->specification()); + return specs; +} + +void ToolRegistry::cancelAll() { + for (auto* tool : m_tools) + tool->cancel(); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/tool.hpp b/Plugins/ZShell/Llm/tool.hpp new file mode 100644 index 0000000..ef2195c --- /dev/null +++ b/Plugins/ZShell/Llm/tool.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace ZShell::llm { + +// A capability the model may invoke mid-turn. Tools run asynchronously +// and report exactly one result: `{"output": ...}` on success or +// `{"error": ...}` on failure. +class LlmTool : public QObject { + Q_OBJECT + + public: + explicit LlmTool(QObject* parent = nullptr); + ~LlmTool() override; + + [[nodiscard]] virtual QString name() const = 0; + [[nodiscard]] virtual QString description() const = 0; + // JSON Schema describing the tool's `arguments` object. + [[nodiscard]] virtual QJsonObject parameters() const = 0; + + // Runs the tool; `done` is invoked exactly once, with + // `{"output": ...}` on success or `{"error": ...}` on failure. + // `done` must be invoked asynchronously (on a later event loop + // iteration), never synchronously within execute(). + virtual void execute( + const QJsonObject& args, + std::function done) = 0; + // Abandons in-flight work, if any. + virtual void cancel(); + + // The OpenAI-compatible `tools` entry for this tool. + [[nodiscard]] QJsonObject specification() const; +}; + +// Owns the set of tools available to the model. +class ToolRegistry : public QObject { + Q_OBJECT + + Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged) + + public: + explicit ToolRegistry(QObject* parent = nullptr); + + [[nodiscard]] bool enabled() const { return m_enabled; } + void setEnabled(bool value); + + // Takes ownership; tools become children of the registry. + void registerTool(LlmTool* tool); + [[nodiscard]] LlmTool* tool(const QString& name) const; + // The request body's `tools` array; empty while disabled. + [[nodiscard]] QJsonArray specifications() const; + // Abandons in-flight work in every tool. + void cancelAll(); + + Q_SIGNALS: + void enabledChanged(); + + private: + bool m_enabled = true; + QList m_tools; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/webfetchtool.cpp b/Plugins/ZShell/Llm/webfetchtool.cpp new file mode 100644 index 0000000..bc573f8 --- /dev/null +++ b/Plugins/ZShell/Llm/webfetchtool.cpp @@ -0,0 +1,428 @@ +#include "webfetchtool.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +namespace { + +const char* kUserAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"; + +QJsonObject makeOutput(const QString& text) { + QJsonObject obj; + obj[QStringLiteral("output")] = text; + return obj; +} + +QJsonObject makeError(const QString& message) { + QJsonObject obj; + obj[QStringLiteral("error")] = message; + return obj; +} + +} // namespace + +WebFetchTool::WebFetchTool(QObject* parent) : LlmTool(parent) {} + +WebFetchTool::~WebFetchTool() { + for (auto* job : m_jobs) { + if (job->timer) + job->timer->stop(); + if (job->reply) + job->reply->abort(); + } + // Pending result callbacks are dropped; the client is going away. + qDeleteAll(m_jobs); +} + +QString WebFetchTool::name() const { + return QStringLiteral("webfetch"); +} + +QString WebFetchTool::description() const { + return QStringLiteral( + "Fetch content from an HTTP or HTTPS URL and return it as plain " + "text or raw HTML. HTML pages are reduced to their visible text " + "by default. This tool is read-only."); +} + +QJsonObject WebFetchTool::parameters() const { + QJsonObject url; + url[QStringLiteral("type")] = QStringLiteral("string"); + url[QStringLiteral("description")] = + QStringLiteral("The HTTP or HTTPS URL to fetch content from"); + + QJsonArray formats; + formats.append(QStringLiteral("text")); + formats.append(QStringLiteral("html")); + QJsonObject format; + format[QStringLiteral("type")] = QStringLiteral("string"); + format[QStringLiteral("enum")] = formats; + format[QStringLiteral("description")] = + QStringLiteral("The format to return the content in. Defaults to " + "text."); + + QJsonObject timeout; + timeout[QStringLiteral("type")] = QStringLiteral("integer"); + timeout[QStringLiteral("minimum")] = 1; + timeout[QStringLiteral("maximum")] = MaxTimeoutSeconds; + timeout[QStringLiteral("description")] = + QStringLiteral("Optional timeout in seconds"); + + QJsonObject properties; + properties[QStringLiteral("url")] = url; + properties[QStringLiteral("format")] = format; + properties[QStringLiteral("timeout")] = timeout; + + QJsonObject schema; + schema[QStringLiteral("type")] = QStringLiteral("object"); + schema[QStringLiteral("properties")] = properties; + QJsonArray required; + required.append(QStringLiteral("url")); + schema[QStringLiteral("required")] = required; + return schema; +} + +void WebFetchTool::completeJob(Job* job, QJsonObject result) { + // Deliver on a later event loop iteration; LlmClient relies on tool + // results never arriving synchronously within execute(). + QMetaObject::invokeMethod( + this, + [this, job, result = std::move(result)]() mutable { + if (!m_jobs.contains(job)) + return; + auto done = std::move(job->done); + m_jobs.removeAll(job); + job->timer->deleteLater(); + if (job->reply) + job->reply->deleteLater(); + delete job; + done(std::move(result)); + }, + Qt::QueuedConnection); +} + +void WebFetchTool::execute( + const QJsonObject& args, std::function done) { + auto* job = new Job; + job->done = std::move(done); + m_jobs.append(job); + + auto fail = [this, job](const QString& message) { + job->timer->stop(); + completeJob(job, makeError(message)); + }; + + const QString urlText = args[QStringLiteral("url")].toString().trimmed(); + const QUrl url = QUrl::fromUserInput(urlText); + if (!url.isValid() || url.host().isEmpty()) { + fail(QStringLiteral("Invalid URL: %1").arg(urlText)); + return; + } + if (url.scheme() != QLatin1String("http") && + url.scheme() != QLatin1String("https")) { + fail(QStringLiteral("URL must use http:// or https://")); + return; + } + + job->format = + args[QStringLiteral("format")].toString(QStringLiteral("text")); + if (job->format != QLatin1String("html")) + job->format = QStringLiteral("text"); + + const int timeoutMs = qBound( + 1, + args[QStringLiteral("timeout")].toInt( + DefaultTimeoutSeconds), + MaxTimeoutSeconds) * + 1000; + + job->timer = new QTimer(this); + job->timer->setSingleShot(true); + connect( + job->timer, &QTimer::timeout, this, [this, job]() { + if (job->reply) + job->reply->abort(); + }); + job->timer->start(timeoutMs); + + QNetworkRequest request(url); + request.setRawHeader("User-Agent", QByteArray(kUserAgent)); + request.setRawHeader( + "Accept", + job->format == QLatin1String("html") + ? "text/html;q=1.0, application/xhtml+xml;q=0.9, */*;q=0.1" + : "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, " + "*/*;q=0.1"); + request.setRawHeader("Accept-Language", "en-US,en;q=0.9"); + + job->reply = m_manager.get(request); + connect(job->reply, &QNetworkReply::readyRead, this, [this, job]() { + if (!job->reply) + return; + job->body += job->reply->readAll(); + if (job->body.size() > MaxResponseBytes) { + job->tooLarge = true; + job->reply->abort(); + } + }); + connect(job->reply, &QNetworkReply::finished, this, [this, job]() { + QNetworkReply* reply = job->reply; + job->reply = nullptr; + job->timer->stop(); + if (!reply) + return; + + const QNetworkReply::NetworkError error = reply->error(); + const QString errorString = reply->errorString(); + QByteArray body = job->body; + body += reply->readAll(); + job->body.clear(); + const QByteArray contentType = + reply->rawHeader("Content-Type").toLower(); + const int status = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute) + .toInt(); + + if (job->tooLarge) { + completeJob(job, makeError( + QStringLiteral("Response too large (exceeds the 5 MB " + "limit"))); + return; + } + if (error != QNetworkReply::NoError) { + completeJob( + job, + makeError( + QStringLiteral("Request failed: %1").arg(errorString))); + return; + } + if (status < 200 || status >= 300) { + completeJob( + job, + makeError( + QStringLiteral("Server returned status %1") + .arg(status))); + return; + } + + const QString mime = + QString::fromLatin1(contentType).section(QLatin1Char(';'), 0, 0) + .trimmed(); + if (mime.startsWith(QLatin1String("image/"))) { + completeJob( + job, + makeError( + QStringLiteral( + "Unsupported fetched image content type: %1") + .arg(mime))); + return; + } + const bool textual = mime.isEmpty() || + mime.startsWith(QLatin1String("text/")) || + mime == QLatin1String("application/json") || + mime.endsWith(QLatin1String("+json")) || + mime == QLatin1String("application/xml") || + mime.endsWith(QLatin1String("+xml")) || + mime.startsWith(QLatin1String("application/javascript")) || + mime.startsWith(QLatin1String("text/javascript")); + if (!textual) { + completeJob( + job, + makeError( + QStringLiteral( + "Unsupported fetched file content type: %1") + .arg(mime))); + return; + } + + QString content = QString::fromUtf8(body); + if (mime.contains(QLatin1String("text/html")) && + job->format == QLatin1String("text")) + content = extractTextFromHtml(content); + if (content.size() > MaxOutputChars) + content = content.left(MaxOutputChars) + + QStringLiteral("\n[... truncated ...]"); + completeJob(job, makeOutput(content)); + }); +} + +void WebFetchTool::cancel() { + for (auto* job : m_jobs) { + job->timer->stop(); + if (job->reply) + job->reply->abort(); + } +} + +QString WebFetchTool::decodeEntities(const QString& text) { + if (!text.contains(QLatin1Char('&'))) + return text; + QString out; + out.reserve(text.size()); + for (qsizetype i = 0; i < text.size(); ++i) { + if (text.at(i) != QLatin1Char('&')) { + out += text.at(i); + continue; + } + const qsizetype semi = text.indexOf(QLatin1Char(';'), i); + if (semi < 0 || semi - i > 12) { + out += QLatin1Char('&'); + continue; + } + const QString entity = text.mid(i + 1, semi - i - 1); + QString replacement; + if (entity == QLatin1String("amp")) + replacement = QLatin1Char('&'); + else if (entity == QLatin1String("lt")) + replacement = QLatin1Char('<'); + else if (entity == QLatin1String("gt")) + replacement = QLatin1Char('>'); + else if (entity == QLatin1String("quot")) + replacement = QLatin1Char('"'); + else if (entity == QLatin1String("apos")) + replacement = QLatin1Char('\''); + else if (entity == QLatin1String("nbsp")) + replacement = QLatin1Char(' '); + else { + bool ok = false; + const quint32 codePoint = entity.startsWith(QLatin1String("#x")) || + entity.startsWith(QLatin1String("#X")) + ? entity.mid(2).toUInt(&ok, 16) + : entity.toUInt(&ok); + if (ok && codePoint != 0) { + const char32_t ucs4[2] = { + static_cast(codePoint), + 0, + }; + replacement = QString::fromUcs4(ucs4); + } + } + if (replacement.isEmpty()) { + out += QLatin1Char('&'); + continue; + } + out += replacement; + i = semi; + } + return out; +} + +QString WebFetchTool::extractTextFromHtml(const QString& html) { + static const QSet kSkipTags = { + QStringLiteral("noscript"), + QStringLiteral("iframe"), + QStringLiteral("object"), + QStringLiteral("head"), + }; + static const QSet kRawTags = { + QStringLiteral("script"), + QStringLiteral("style"), + }; + static const QSet kVoidTags = { + QStringLiteral("area"), QStringLiteral("base"), + QStringLiteral("br"), QStringLiteral("col"), + QStringLiteral("embed"), QStringLiteral("hr"), + QStringLiteral("img"), QStringLiteral("input"), + QStringLiteral("link"), QStringLiteral("meta"), + QStringLiteral("source"), QStringLiteral("track"), + QStringLiteral("wbr"), + }; + + QString text; + text.reserve(html.size() / 2); + int skipDepth = 0; + qsizetype i = 0; + while (i < html.size()) { + const qsizetype open = html.indexOf(QLatin1Char('<'), i); + if (open < 0) { + if (skipDepth == 0) + text += html.mid(i); + break; + } + if (skipDepth == 0) + text += html.mid(i, open - i); + const qsizetype close = html.indexOf(QLatin1Char('>'), open); + if (close < 0) + break; + const QString tag = + html.mid(open + 1, close - open - 1).trimmed().toLower(); + i = close + 1; + + if (tag.startsWith(QLatin1Char('!')) || + tag.startsWith(QLatin1Char('?'))) + continue; + + QString name = tag; + if (name.startsWith(QLatin1Char('/'))) { + if (skipDepth > 0) + --skipDepth; + continue; + } + qsizetype j = 0; + while (j < name.size() && + (name.at(j).isLetterOrNumber() || + name.at(j) == QLatin1Char(':') || + name.at(j) == QLatin1Char('-'))) + ++j; + name = name.left(j); + + if (kRawTags.contains(name)) { + // Raw-text element: swallow everything up to its close tag. + const qsizetype rawEnd = + html.indexOf(QStringLiteral("'), rawEnd); + if (rawClose < 0) + break; + i = rawClose + 1; + continue; + } + if (kVoidTags.contains(name)) + continue; + if (skipDepth > 0) { + // Browsers implicitly close at ; malformed pages + // without a would otherwise swallow the whole page. + if (name == QLatin1String("body")) { + skipDepth = 0; + continue; + } + ++skipDepth; + continue; + } + if (kSkipTags.contains(name)) { + ++skipDepth; + continue; + } + // Normal tag: replace with a space so words do not merge. + text += QLatin1Char(' '); + } + + QString out = decodeEntities(text); + QStringList lines; + for (const QString& line : out.split(QLatin1Char('\n'))) { + const QString flat = line.simplified(); + if (flat.isEmpty()) { + if (!lines.isEmpty() && lines.last().isEmpty()) + continue; + lines.append(QString()); + } else { + lines.append(flat); + } + } + while (lines.size() > 1 && lines.first().isEmpty()) + lines.removeFirst(); + while (lines.size() > 1 && lines.last().isEmpty()) + lines.removeLast(); + return lines.join(QLatin1Char('\n')); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/webfetchtool.hpp b/Plugins/ZShell/Llm/webfetchtool.hpp new file mode 100644 index 0000000..70046d1 --- /dev/null +++ b/Plugins/ZShell/Llm/webfetchtool.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include "tool.hpp" + +#include +#include +#include + +#include + +class QNetworkReply; +class QTimer; + +namespace ZShell::llm { + +// Fetches an http(s) URL and returns its content as plain text or raw +// HTML. Read-only. Mirrors opencode's webfetch tool, without markdown +// conversion and the permission prompt. Concurrent fetches are +// supported; results are always delivered on a later event loop +// iteration, never synchronously from execute(). +class WebFetchTool : public LlmTool { + Q_OBJECT + + public: + static constexpr int MaxResponseBytes = 5 * 1024 * 1024; + static constexpr int DefaultTimeoutSeconds = 30; + static constexpr int MaxTimeoutSeconds = 120; + // Caps the characters handed to the model so a large page cannot + // blow out the context. + static constexpr int MaxOutputChars = 64 * 1024; + + explicit WebFetchTool(QObject* parent = nullptr); + ~WebFetchTool() override; + + QString name() const override; + QString description() const override; + QJsonObject parameters() const override; + void execute( + const QJsonObject& args, + std::function done) override; + void cancel() override; + + // Strips tags (skipping script/style/noscript/iframe/object/embed/ + // head) and decodes common entities. + static QString extractTextFromHtml(const QString& html); + static QString decodeEntities(const QString& text); + + private: + struct Job { + QNetworkReply* reply = nullptr; + QTimer* timer = nullptr; + QByteArray body; + bool tooLarge = false; + QString format; + std::function done; + }; + + void completeJob(Job* job, QJsonObject result); + + QNetworkAccessManager m_manager; + QList m_jobs; +}; + +} // namespace ZShell::llm From 3946541f8799b9a5ff7ff81a80c086c80efa309b Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 26 Aug 2026 02:06:10 +0200 Subject: [PATCH 06/90] async parsing + tex caching --- Components/CustomScrollBar.qml | 130 ++-- Components/HorizontalScrollBarTrack.qml | 56 ++ Components/TextAreaBase.qml | 86 +++ Components/VerticalScrollBarTrack.qml | 56 ++ Modules/Bar/Components/Clock.qml | 22 +- .../Sidebar/Chat/ChatContent.qml | 9 +- .../Notifications/Sidebar/Chat/ChatInput.qml | 4 +- .../Sidebar/Chat/Content/CodeBlockView.qml | 177 ++++-- .../Sidebar/Chat/Content/CodeColors.qml | 231 +++++++ .../Sidebar/Chat/Content/CodeIcons.qml | 443 ++++++++++++++ .../Sidebar/Chat/Content/ContentBubble.qml | 4 +- .../Sidebar/Chat/Content/MarkdownBlocks.qml | 4 + Modules/Settings/Common/DialogRowButton.qml | 19 +- Modules/Settings/PageCompRegistry.qml | 75 +-- .../Pages/Panels/Bar/BarStatusIcons.qml | 1 + .../Pages/Panels/Sidebar/SidebarLlm.qml | 95 +++ .../Settings/Pages/Panels/SidebarPanel.qml | 14 + Plugins/ZShell/Config/llm.hpp | 18 +- Plugins/ZShell/Llm/CMakeLists.txt | 254 +++++++- Plugins/ZShell/Llm/chatstore.cpp | 315 +++++++--- Plugins/ZShell/Llm/chatstore.hpp | 8 + Plugins/ZShell/Llm/codehighlighter.cpp | 260 ++++---- Plugins/ZShell/Llm/codehighlighter.hpp | 52 +- Plugins/ZShell/Llm/mathtext.cpp | 170 ++++-- Plugins/ZShell/Llm/mathtext.hpp | 14 +- Plugins/ZShell/Llm/messagemodel.cpp | 9 +- Plugins/ZShell/Llm/segment.cpp | 42 +- Plugins/ZShell/Llm/segment.hpp | 5 +- Plugins/ZShell/Llm/session.cpp | 36 +- Plugins/ZShell/Llm/session.hpp | 13 +- SCHEME_LINKS.md | 25 + scripts/codeicons.py | 572 ++++++++++++++++++ 32 files changed, 2730 insertions(+), 489 deletions(-) create mode 100644 Components/HorizontalScrollBarTrack.qml create mode 100644 Components/TextAreaBase.qml create mode 100644 Components/VerticalScrollBarTrack.qml create mode 100644 Modules/Notifications/Sidebar/Chat/Content/CodeColors.qml create mode 100644 Modules/Notifications/Sidebar/Chat/Content/CodeIcons.qml create mode 100644 Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml create mode 100644 SCHEME_LINKS.md create mode 100644 scripts/codeicons.py diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index 0159df0..581cb11 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -1,32 +1,42 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Templates import qs.Helpers import ZShell.Config -import qs.Services ScrollBar { id: root + required property Flickable flickable + readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root + readonly property real axisSize: isHorizontal ? flickable.width : flickable.height + readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight + readonly property real axisContentPos: isHorizontal ? flickable.contentX : flickable.contentY + readonly property real axisLength: isHorizontal ? root.width : root.height readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize) readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize) - required property Flickable flickable - readonly property real nonAnimHeight: flickable.height / flickable.contentHeight - readonly property real nonAnimY: flickable.contentY / flickable.contentHeight + readonly property real nonAnimHeight: root.axisSize / root.axisContentSize + readonly property real nonAnimY: root.axisContentPos / root.axisContentSize readonly property real rawTravel: Math.max(0, 1 - root.nonAnimHeight) - readonly property bool reversed: flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop + readonly property bool reversed: isHorizontal ? (flickable instanceof ListView && flickable.layoutDirection === Qt.RightToLeft) : (flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop) property bool shouldBeActive readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0 enabled: !Visibilities.getForActive().isDrawing - implicitWidth: Tokens.padding.extraSmall * 2 + parent: flickable.parent + anchors.left: isHorizontal ? flickable.left : undefined + anchors.right: flickable.right + anchors.top: isHorizontal ? undefined : flickable.top + anchors.bottom: flickable.bottom + implicitWidth: isHorizontal ? 0 : Tokens.padding.extraSmall * 2 + implicitHeight: isHorizontal ? Tokens.padding.extraSmall * 2 : 0 - contentItem: Item { - } + contentItem: Item {} Behavior on position { enabled: !fullMouse.pressed - Anim { - } + Anim {} } onHoveredChanged: { @@ -47,54 +57,26 @@ ScrollBar { target: root.flickable } - CustomClippingRect { - anchors.bottom: parent.bottom - anchors.right: parent.right - anchors.top: parent.top - implicitWidth: handle.implicitWidth - radius: Tokens.rounding.full + Loader { + anchors.fill: parent + sourceComponent: root.isHorizontal ? horizontalTrack : verticalTrack + } - CustomRect { - id: handle + Component { + id: verticalTrack - anchors.right: parent.right - color: Colors.palette.m3secondary - implicitHeight: root.height * root.effectiveSize - implicitWidth: fullMouse.pressed || fullMouse.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall - opacity: { - if (!root.enabled) - return 0; - if (root.size === 1) - return 0; - if (fullMouse.pressed) - return 1; - if (fullMouse.containsMouse) - return 0.8; - if (root.policy === ScrollBar.AlwaysOn || root.shouldBeActive) - return 0.6; - return 0; - } - radius: Tokens.rounding.full - y: root.reversed ? root.height * (1 + root.nonAnimY) * root.travelScale : root.height * root.nonAnimY * root.travelScale + VerticalScrollBarTrack { + scrollBar: root + mouseArea: fullMouse + } + } - Behavior on implicitWidth { - Anim { - } - } - Behavior on opacity { - Anim { - type: Anim.DefaultEffects - } - } + Component { + id: horizontalTrack - MouseArea { - id: mouse - - acceptedButtons: Qt.NoButton - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: true - } + HorizontalScrollBarTrack { + scrollBar: root + mouseArea: fullMouse } } @@ -111,21 +93,25 @@ ScrollBar { property real pressOffset: 0 - function contentYFromThumbTop(thumbTop) { - var visualPos = root.effectiveTravel > 0 ? thumbTop / root.travelScale : 0; - - return root.reversed ? (visualPos - 1) * root.flickable.contentHeight : visualPos * root.flickable.contentHeight; + function contentPosFromThumbStart(thumbStart) { + var visualPos = root.effectiveTravel > 0 ? thumbStart / root.travelScale : 0; + return root.reversed ? (visualPos - 1) * root.axisContentSize : visualPos * root.axisContentSize; } function updateFromEvent(event) { - var posInTrack = event.y / root.height; - var thumbTop = posInTrack - pressOffset; - thumbTop = Math.max(0, Math.min(root.effectiveTravel, thumbTop)); + var eventPos = root.isHorizontal ? event.x : event.y; + var posInTrack = eventPos / root.axisLength; + var thumbStart = posInTrack - pressOffset; + thumbStart = Math.max(0, Math.min(root.effectiveTravel, thumbStart)); - root.flickable.contentY = contentYFromThumbTop(thumbTop); + var newPos = contentPosFromThumbStart(thumbStart); + if (root.isHorizontal) + root.flickable.contentX = newPos; + else + root.flickable.contentY = newPos; } - function visualThumbTop() { + function visualThumbStart() { const visualPos = root.reversed ? (1 + root.nonAnimY) : root.nonAnimY; return visualPos * root.travelScale; } @@ -140,22 +126,18 @@ ScrollBar { updateFromEvent(event); } onPressed: event => { - var currentTop = visualThumbTop(); - var currentBottom = currentTop + root.effectiveSize; - var clickPos = event.y / root.height; + var currentStart = visualThumbStart(); + var currentEnd = currentStart + root.effectiveSize; + var eventPos = root.isHorizontal ? event.x : event.y; + var clickPos = eventPos / root.axisLength; - var clickedInsideThumb = clickPos >= currentTop && clickPos <= currentBottom; - - if (clickedInsideThumb) { - pressOffset = clickPos - currentTop; - } else { - pressOffset = root.effectiveSize / 2; - } + var clickedInsideThumb = clickPos >= currentStart && clickPos <= currentEnd; + pressOffset = clickedInsideThumb ? (clickPos - currentStart) : root.effectiveSize / 2; updateFromEvent(event); } onWheel: event => { - var delta = event.angleDelta.y > 0 ? -0.1 : 0.1; + var delta = (root.isHorizontal ? event.angleDelta.x : event.angleDelta.y) > 0 ? -0.1 : 0.1; var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta)); root.position = newPos; } diff --git a/Components/HorizontalScrollBarTrack.qml b/Components/HorizontalScrollBarTrack.qml new file mode 100644 index 0000000..92185c4 --- /dev/null +++ b/Components/HorizontalScrollBarTrack.qml @@ -0,0 +1,56 @@ +import QtQuick +import ZShell.Config +import qs.Services + +CustomClippingRect { + id: track + + required property var scrollBar + required property var mouseArea + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + implicitHeight: handle.implicitHeight + radius: Tokens.rounding.full + + CustomRect { + id: handle + + anchors.bottom: parent.bottom + color: Colors.palette.m3secondary + implicitWidth: track.scrollBar.width * track.scrollBar.effectiveSize + implicitHeight: track.mouseArea.pressed || track.mouseArea.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall + opacity: { + if (!track.scrollBar.enabled) + return 0; + if (track.scrollBar.size === 1) + return 0; + if (track.mouseArea.pressed) + return 1; + if (track.mouseArea.containsMouse) + return 0.8; + if (track.scrollBar.policy === CustomScrollBar.AlwaysOn || track.scrollBar.shouldBeActive) + return 0.6; + return 0; + } + radius: Tokens.rounding.full + x: track.scrollBar.reversed ? track.scrollBar.width * (1 + track.scrollBar.nonAnimY) * track.scrollBar.travelScale : track.scrollBar.width * track.scrollBar.nonAnimY * track.scrollBar.travelScale + + Behavior on implicitHeight { + Anim {} + } + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + MouseArea { + acceptedButtons: Qt.NoButton + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + } + } +} diff --git a/Components/TextAreaBase.qml b/Components/TextAreaBase.qml new file mode 100644 index 0000000..a96b9c0 --- /dev/null +++ b/Components/TextAreaBase.qml @@ -0,0 +1,86 @@ +import QtQuick +import QtQuick.Templates +import ZShell.Config +import qs.Services + +TextArea { + id: root + + color: Colors.palette.m3onSurface + cursorVisible: !readOnly + font.pointSize: Tokens.font.size.small + implicitHeight: contentHeight + topPadding + bottomPadding + implicitWidth: contentWidth + leftPadding + rightPadding + placeholderTextColor: Colors.palette.m3onSurfaceVariant // No anim cause placeholder is custom + renderType: TextArea.NativeRendering + selectedTextColor: color + selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4) + verticalAlignment: TextInput.AlignVCenter + + Behavior on color { + CAnim {} + } + Behavior on selectionColor { + CAnim {} + } + cursorDelegate: Item {} + + CustomRect { + id: cursor + + property bool disableBlink + + color: Colors.palette.m3primary + implicitHeight: root.cursorRectangle.height + implicitWidth: 1.5 + radius: Tokens.rounding.largeIncreased + x: root.cursorRectangle.x + y: root.cursorRectangle.y + + Behavior on x { + Anim { + duration: Tokens.anim.durations.expressiveFastEffects + easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1] + } + } + Behavior on opacity { + Anim { + type: Anim.StandardSmall + } + } + + Connections { + function onCursorPositionChanged(): void { + if (root.activeFocus && root.cursorVisible) { + cursor.opacity = 1; + cursor.disableBlink = true; + enableBlink.restart(); + } + } + + target: root + } + + Timer { + id: enableBlink + + interval: 500 + + onTriggered: cursor.disableBlink = false + } + + Timer { + interval: 500 + repeat: true + running: root.activeFocus && root.cursorVisible && !cursor.disableBlink + triggeredOnStart: true + + onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1 + } + + Binding { + cursor.opacity: 0 + when: !root.activeFocus || !root.cursorVisible + } + } +} diff --git a/Components/VerticalScrollBarTrack.qml b/Components/VerticalScrollBarTrack.qml new file mode 100644 index 0000000..62e7b5a --- /dev/null +++ b/Components/VerticalScrollBarTrack.qml @@ -0,0 +1,56 @@ +import QtQuick +import ZShell.Config +import qs.Services + +CustomClippingRect { + id: track + + required property var scrollBar + required property var mouseArea + + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.top: parent.top + implicitWidth: handle.implicitWidth + radius: Tokens.rounding.full + + CustomRect { + id: handle + + anchors.right: parent.right + color: Colors.palette.m3secondary + implicitHeight: track.scrollBar.height * track.scrollBar.effectiveSize + implicitWidth: track.mouseArea.pressed || track.mouseArea.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall + opacity: { + if (!track.scrollBar.enabled) + return 0; + if (track.scrollBar.size === 1) + return 0; + if (track.mouseArea.pressed) + return 1; + if (track.mouseArea.containsMouse) + return 0.8; + if (track.scrollBar.policy === CustomScrollBar.AlwaysOn || track.scrollBar.shouldBeActive) + return 0.6; + return 0; + } + radius: Tokens.rounding.full + y: track.scrollBar.reversed ? track.scrollBar.height * (1 + track.scrollBar.nonAnimY) * track.scrollBar.travelScale : track.scrollBar.height * track.scrollBar.nonAnimY * track.scrollBar.travelScale + + Behavior on implicitWidth { + Anim {} + } + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + MouseArea { + acceptedButtons: Qt.NoButton + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + } + } +} diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index e7de27d..dbe05b9 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -44,7 +44,7 @@ WidgetBase { } required property GridLayout loader required property Wrapper popouts - readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.small * 2 : verticalColumn.implicitHeight + Tokens.padding.small * 2 + readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2 required property PersistentProperties visibilities color: visibilities.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer @@ -57,14 +57,13 @@ WidgetBase { anchors.centerIn: parent color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type height: implicitHeight text: Time.dateStr visible: root.horizontal Behavior on color { - CAnim { - } + CAnim {} } } @@ -84,12 +83,11 @@ WidgetBase { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type text: Qt.formatDateTime(Time.date, modelData) Behavior on color { - CAnim { - } + CAnim {} } } } @@ -114,7 +112,7 @@ WidgetBase { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type text: { if (modelData.includes("h")) return Time.hourStr; @@ -126,8 +124,7 @@ WidgetBase { } Behavior on color { - CAnim { - } + CAnim {} } } } @@ -135,13 +132,12 @@ WidgetBase { CustomText { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type text: Qt.formatDateTime(Time.date, "AP") visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0 Behavior on color { - CAnim { - } + CAnim {} } } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 2908013..4878c9d 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -177,7 +177,14 @@ Item { sendIcon.icon: "arrow_upward" sendIcon.padding: Tokens.padding.extraSmall - onAccepted: root.send(text) + Keys.onPressed: e => { + if (e.key == Qt.Key_Return) { + if (!(e.modifiers & Qt.ShiftModifier)) { + root.send(text); + e.accepted = true; + } + } + } onSendPressed: root.send(text) } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatInput.qml b/Modules/Notifications/Sidebar/Chat/ChatInput.qml index 290d0cd..4d7216d 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatInput.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatInput.qml @@ -3,7 +3,7 @@ import ZShell.Config import qs.Components import qs.Services -TextFieldBase { +TextAreaBase { id: root readonly property alias bg: bg @@ -15,7 +15,7 @@ TextFieldBase { leftPadding: Tokens.padding.extraLarge rightPadding: sendIcon.width + sendIcon.anchors.rightMargin + Tokens.spacing.small topPadding: Tokens.padding.large - wrapMode: TextFieldBase.Wrap + wrapMode: TextAreaBase.Wrap background: CustomRect { id: bg diff --git a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml index 617c0a1..e9f007e 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Shapes import QtQuick.Layouts import Quickshell import ZShell.Config @@ -12,57 +13,69 @@ CustomClippingRect { required property string language required property string code property bool copied: false - property color codeColor: Colors.palette.m3onSurfaceVariant - property color codeBackgroundColor: Colors.palette.m3surfaceContainerLow + property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh property color codeHeaderColor: Colors.palette.m3outline - property color codeAccentColor: Colors.palette.m3primary // Highlighter spans for the current code; refreshed when the code or - // its language changes. + // its language changes. Highlighting runs off the GUI thread; the + // token drops results that arrive after the code already changed. property var codeSpans: [] + property int highlightToken: 0 function refresh() { - codeSpans = CodeHighlighter.highlight(root.code, root.language); + const token = ++root.highlightToken; + codeSpans = []; + CodeHighlighter.highlight(root.code, root.language, root, token); + } + + function onHighlightSpans(token, spans) { + if (token !== root.highlightToken) + return; + codeSpans = spans; } function roleColor(kind) { + var s = CodeColors.active; switch (kind) { case "comment": - return Colors.palette.m3outline; + return s.comment; case "string": - return Colors.palette.m3tertiary; + return s.string; case "string.key": - return Colors.palette.m3secondary; + return s.stringKey; case "number": case "constant": - return Colors.palette.m3tertiaryFixed; + return s.number; case "keyword": - return root.codeAccentColor; + return s.keyword; case "type": - return Colors.palette.m3secondary; + return s.type; case "function": + return s.functions; case "method": - return Colors.palette.m3onSurface; + return s.method ?? s.functions; case "macro": + return s.macro; case "preproc": - return Colors.palette.m3secondaryContainer; + return s.preproc ?? s.macro; case "operator": + return s.operator ?? s.normal; case "property": - return root.codeColor; + return s.property ?? s.normal; case "label": + return s.label; case "attribute": - return Colors.palette.m3tertiaryFixedDim; + return s.attribute ?? s.label; default: - return root.codeColor; + return s.normal; } } - function escapeHtml(text) { + function escapeHtml(text): string { return text.replace(/&/g, "&").replace(//g, ">"); } - // Wraps the code in tags following the highlighter spans. - function highlightedHtml(code, spans) { + function highlightedHtml(code, spans): string { let out; if (!spans.length) { out = escapeHtml(code); @@ -79,37 +92,17 @@ CustomClippingRect { if (pos < code.length) out += escapeHtml(code.slice(pos)); } - // RichText collapses HTML whitespace: break newlines, and protect - // line-leading indentation with nbsp (internal spaces stay normal - // so long lines can still wrap). - return out - .replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, " ")) - .replace(/\n/g, "
"); + return out.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, " ")).replace(/\n/g, "
"); } implicitWidth: headerRow.implicitWidth + headerRow.anchors.leftMargin + headerRow.anchors.rightMargin - implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeText.implicitHeight + codeText.anchors.margins * 2 + implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeRect.implicitHeight + codeRect.anchors.margins + codeRect.anchors.topMargin color: root.codeBackgroundColor radius: Tokens.rounding.medium onLanguageChanged: refresh() onCodeChanged: refresh() - CustomText { - id: codeText - - anchors.left: parent.left - anchors.top: headerRow.bottom - anchors.right: parent.right - anchors.margins: Tokens.padding.small - color: root.codeColor - font.family: Config.appearance.font.family.mono - font.pointSize: Tokens.font.size.small - textFormat: Text.RichText - text: root.highlightedHtml(root.code, root.codeSpans) - wrapMode: Text.WrapAtWordBoundaryOrAnywhere - } - RowLayout { id: headerRow @@ -121,11 +114,74 @@ CustomClippingRect { anchors.rightMargin: Tokens.padding.small spacing: Tokens.spacing.small + Item { + id: iconItem + + readonly property string iconPath: CodeIcons.path(root.language) + + Layout.fillHeight: true + implicitWidth: height + + Shape { + id: shape + + anchors.centerIn: parent + visible: iconItem.iconPath !== "" + preferredRendererType: Shape.CurveRenderer + scale: Math.min(langText.implicitHeight / height, langText.implicitHeight / width) + + ShapePath { + strokeColor: "transparent" + fillColor: Colors.palette.m3tertiary + fillRule: ShapePath.WindingFill + + PathSvg { + path: iconItem.iconPath + } + } + } + + Shape { + id: fallbackShape + + anchors.centerIn: parent + anchors.verticalCenterOffset: -1 + visible: iconItem.iconPath === "" + preferredRendererType: Shape.CurveRenderer + scale: Math.min(langText.implicitHeight / height, langText.implicitHeight / width) + + ShapePath { + strokeColor: Colors.palette.m3tertiary + strokeWidth: 16 + capStyle: ShapePath.RoundCap + joinStyle: ShapePath.RoundJoin + fillColor: "transparent" + + PathSvg { + path: "M 40 64 L 112 128 L 40 192" + } + } + + ShapePath { + strokeColor: Colors.palette.m3tertiary + strokeWidth: 16 + capStyle: ShapePath.RoundCap + fillColor: "transparent" + + PathSvg { + path: "M 120 192 L 216 192" + } + } + } + } + CustomText { + id: langText + Layout.alignment: Qt.AlignVCenter color: root.codeHeaderColor font.pointSize: Tokens.font.size.small - text: root.language + text: CodeIcons.name(root.language) visible: root.language.length > 0 } @@ -137,6 +193,7 @@ CustomClippingRect { icon: root.copied ? "check" : "content_copy" inactiveColor: "transparent" inactiveOnColor: root.codeHeaderColor + label.animate: true type: IconButton.Text onClicked: { @@ -154,4 +211,40 @@ CustomClippingRect { } } } + + CustomRect { + id: codeRect + + anchors.left: parent.left + anchors.top: headerRow.bottom + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.topMargin: Tokens.padding.small + radius: root.radius - anchors.margins + anchors.margins: Tokens.padding.extraSmall + implicitWidth: codeText.implicitWidth + codeFlick.anchors.margins * 2 + implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2 + color: CodeColors.active.bg + + Flickable { + id: codeFlick + + anchors.fill: parent + anchors.margins: Tokens.padding.small + + CustomScrollBar.horizontal: CustomScrollBar { + flickable: codeFlick + } + TextAreaBase.flickable: TextAreaBase { + id: codeText + + color: CodeColors.active.normal + font.family: Config.appearance.font.family.mono + font.pointSize: Tokens.font.size.small + textFormat: Text.RichText + text: root.highlightedHtml(root.code, root.codeSpans) + readOnly: true + } + } + } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/CodeColors.qml b/Modules/Notifications/Sidebar/Chat/Content/CodeColors.qml new file mode 100644 index 0000000..7f14d8b --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/CodeColors.qml @@ -0,0 +1,231 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import ZShell.Config + +Singleton { + id: root + + readonly property JsonObject schemes: JsonObject { + readonly property Scheme oneDark: Scheme { + comment: "#5c6370" + string: "#98c379" + stringKey: "#e06c75" + number: "#d19a66" + keyword: "#c678dd" + type: "#e5c07b" + functions: "#61afef" + method: "#61afef" + macro: "#98c379" + preproc: "#abb2bf" + operator: "#abb2bf" + property: "#abb2bf" + label: "#e06c75" + attribute: "#d19a66" + bg: "#282c34" + normal: "#abb2bf" + } + readonly property Scheme nord: Scheme { + comment: "#4c566a" + string: "#a3be8c" + stringKey: "#ebcb8b" + number: "#b48ead" + keyword: "#81a1c1" + type: "#8fbcbb" + functions: "#88c0d0" + method: "#88c0d0" + macro: "#5e81ac" + preproc: "#5e81ac" + operator: "#81a1c1" + property: "#d8dee9" + label: "#d08770" + attribute: "#d8dee9" + bg: "#2e3440" + normal: "#d8dee9" + } + readonly property Scheme dracula: Scheme { + comment: "#6272a4" + string: "#f1fa8c" + stringKey: "#f8f8f2" + number: "#ffb86c" + keyword: "#ff79c6" + type: "#8be9fd" + functions: "#50fa7b" + method: "#50fa7b" + macro: "#ff79c6" + preproc: "#ff79c6" + operator: "#f8f8f2" + property: "#f8f8f2" + label: "#6272a4" + attribute: "#8be9fd" + bg: "#282a36" + normal: "#f8f8f2" + } + readonly property Scheme githubDark: Scheme { + comment: "#8b949e" + string: "#a5d6ff" + stringKey: "#79c0ff" + number: "#79c0ff" + keyword: "#ff7b72" + type: "#ffa657" + functions: "#d2a8ff" + method: "#d2a8ff" + macro: "#ff7b72" + preproc: "#79c0ff" + operator: "#ff7b72" + property: "#79c0ff" + label: "#7ee787" + attribute: "#7ee787" + bg: "#0d1117" + normal: "#c9d1d9" + } + readonly property Scheme solarizedDark: Scheme { + comment: "#586e75" + string: "#2aa198" + stringKey: "#268bd2" + number: "#2aa198" + keyword: "#859900" + type: "#b58900" + functions: "#268bd2" + method: "#268bd2" + macro: "#cb4b16" + preproc: "#cb4b16" + operator: "#859900" + property: "#268bd2" + label: "#6c71c4" + attribute: "#657b83" + bg: "#002b36" + normal: "#839496" + } + readonly property Scheme monokai: Scheme { + comment: "#75715e" + string: "#e6db74" + stringKey: "#f8f8f2" + number: "#ae81ff" + keyword: "#f92672" + type: "#a6e22e" + functions: "#a6e22e" + method: "#a6e22e" + macro: "#a6e22e" + preproc: "#f92672" + operator: "#f92672" + property: "#fda5ff" + label: "#f92672" + attribute: "#a6e22e" + bg: "#272822" + normal: "#f8f8f2" + } + readonly property Scheme gruvboxDark: Scheme { + comment: "#928374" + string: "#b8bb26" + stringKey: "#ebdbb2" + number: "#d3869b" + keyword: "#fb4934" + type: "#fabd2f" + functions: "#b8bb26" + method: "#b8bb26" + macro: "#8ec07c" + preproc: "#8ec07c" + operator: "#ebdbb2" + property: "#83a598" + label: "#fb4934" + attribute: "#8ec07c" + bg: "#1d2021" + normal: "#ebdbb2" + } + readonly property Scheme catppuccinMocha: Scheme { + comment: "#9399b2" + string: "#a6e3a1" + stringKey: "#b4befe" + number: "#fab387" + keyword: "#cba6f7" + type: "#f9e2af" + functions: "#89b4fa" + method: "#89b4fa" + macro: "#cba6f7" + preproc: "#f5c2e7" + operator: "#89dceb" + property: "#b4befe" + label: "#74c7ec" + attribute: "#f9e2af" + bg: "#1e1e2e" + normal: "#cdd6f4" + } + readonly property Scheme tokyoNight: Scheme { + comment: "#565f89" + string: "#9ece6a" + stringKey: "#73daca" + number: "#ff9e64" + keyword: "#9d7cd8" + type: "#2ac3de" + functions: "#7aa2f7" + method: "#7aa2f7" + macro: "#7dcfff" + preproc: "#7dcfff" + operator: "#89ddff" + property: "#73daca" + label: "#7aa2f7" + attribute: "#73daca" + bg: "#1a1b26" + normal: "#c0caf5" + } + readonly property Scheme ayuDark: Scheme { + comment: "#5a6673" + string: "#aad94c" + stringKey: "#aad94c" + number: "#d2a6ff" + keyword: "#ff8f40" + type: "#59c2ff" + functions: "#ffb454" + method: "#ffb454" + macro: "#59c2ff" + preproc: "#ff8f40" + operator: "#f29668" + property: "#f07178" + label: "#59c2ff" + attribute: "#ffb454" + bg: "#0a0e14" + normal: "#bfbdb6" + } + readonly property Scheme palenight: Scheme { + comment: "#697098" + string: "#c3e88d" + stringKey: "#82b1ff" + number: "#f78c6c" + keyword: "#ff5370" + type: "#ffcb6b" + functions: "#82b1ff" + method: "#82b1ff" + macro: "#c792ea" + preproc: "#ffcb6b" + operator: "#89ddff" + property: "#c3e88d" + label: "#c792ea" + attribute: "#ffcb6b" + bg: "#292d3e" + normal: "#bfc7d5" + } + } + readonly property var active: schemes[Config.llm.appearance.scheme] + + component Scheme: JsonObject { + property color comment: "#697098" + property color string: "#c3e88d" + property color stringKey: "#82b1ff" + property color number: "#f78c6c" + property color keyword: "#ff5370" + property color type: "#ffcb6b" + property color functions: "#82b1ff" + property color method: "#82b1ff" + property color macro: "#c792ea" + property color preproc: "#ffcb6b" + property color operator: "#89ddff" + property color property: "#c3e88d" + property color label: "#c792ea" + property color attribute: "#ffcb6b" + property color bg: "#292d3e" + property color normal: "#bfc7d5" + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/CodeIcons.qml b/Modules/Notifications/Sidebar/Chat/Content/CodeIcons.qml new file mode 100644 index 0000000..ba81fb4 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/CodeIcons.qml @@ -0,0 +1,443 @@ +pragma Singleton + +import Quickshell +import QtQuick + +Singleton { + readonly property string c: "M 125 50 c -4 -32 -24 -50 -62 -50 C 29 0 3 24 3 64 c 0 39 24 64 64 64 c 32 0 55 -19 58 -50 H 87 c -2 11 -8 20 -20 20 c -21 0 -24 -16 -24 -33 c 0 -23 8 -35 22 -35 c 13 0 20 7 22 20 z" + readonly property string cpp: "M 63.443 0 c -1.782 0 -3.564 .39 -4.916 1.172 L 11.594 28.27 C 8.89 29.828 6.68 33.66 6.68 36.78 v 54.197 c 0 1.562 .55 3.298 1.441 4.841 l -.002 .002 c .89 1.543 2.123 2.89 3.475 3.672 l 46.931 27.094 c 2.703 1.562 7.13 1.562 9.832 0 h .002 l 46.934 -27.094 c 1.352 -.78 2.582 -2.129 3.473 -3.672 c .89 -1.543 1.441 -3.28 1.441 -4.843 V 36.779 c 0 -1.557 -.55 -3.295 -1.441 -4.838 v -.002 c -.891 -1.545 -2.121 -2.893 -3.473 -3.67 L 68.359 1.173 C 67.008 .39 65.226 0 63.443 0 z m .002 26.033 c 13.465 0 26.02 7.246 32.77 18.91 l -16.38 9.479 c -3.372 -5.836 -9.66 -9.467 -16.39 -9.467 c -10.432 0 -18.922 8.49 -18.922 18.924 S 53.013 82.8 63.445 82.8 c 6.735 0 13.015 -3.625 16.395 -9.465 l 16.375 9.477 c -6.746 11.662 -19.305 18.91 -32.77 18.91 c -20.867 0 -37.843 -16.977 -37.843 -37.844 s 16.976 -37.844 37.843 -37.844 v -.002 z M 92.881 57.57 h 4.201 v 4.207 h 4.203 v 4.203 h -4.203 v 4.207 h -4.201 V 65.98 h -4.207 v -4.203 h 4.207 V 57.57 z m 15.765 0 h 4.208 v 4.207 h 4.203 v 4.203 h -4.203 v 4.207 h -4.208 V 65.98 h -4.205 v -4.203 h 4.205 V 57.57 z" + readonly property string csharp: "M 117.5 33.5 l .3 -.2 c -.6 -1.1 -1.5 -2.1 -2.4 -2.6 L 67.1 2.9 c -.8 -.5 -1.9 -.7 -3.1 -.7 c -1.2 0 -2.3 .3 -3.1 .7 l -48 27.9 c -1.7 1 -2.9 3.5 -2.9 5.4 v 55.7 c 0 1.1 .2 2.3 .9 3.4 l -.2 .1 c .5 .8 1.2 1.5 1.9 1.9 l 48.2 27.9 c .8 .5 1.9 .7 3.1 .7 c 1.2 0 2.3 -.3 3.1 -.7 l 48 -27.9 c 1.7 -1 2.9 -3.5 2.9 -5.4 V 36.1 c .1 -.8 0 -1.7 -.4 -2.6 z m -53.5 70 c -21.8 0 -39.5 -17.7 -39.5 -39.5 S 42.2 24.5 64 24.5 c 14.7 0 27.5 8.1 34.3 20 l -13 7.5 C 81.1 44.5 73.1 39.5 64 39.5 c -13.5 0 -24.5 11 -24.5 24.5 s 11 24.5 24.5 24.5 c 9.1 0 17.1 -5 21.3 -12.4 l 12.9 7.6 c -6.8 11.8 -19.6 19.8 -34.2 19.8 z M 115 62 h -3.2 l -.9 4 h 4.1 v 5 h -5 l -1.2 6 h -4.9 l 1.2 -6 h -3.8 l -1.2 6 h -4.8 l 1.2 -6 H 94 v -5 h 3.5 l .9 -4 H 94 v -5 h 5.3 l 1.2 -6 h 4.9 l -1.2 6 h 3.8 l 1.2 -6 h 4.8 l -1.2 6 h 2.2 v 5 z m -12.7 4 h 3.8 l .9 -4 h -3.8 z" + readonly property string python: "M 49.33 62 h 29.159 C 86.606 62 93 55.132 93 46.981 V 19.183 c 0 -7.912 -6.632 -13.856 -14.555 -15.176 c -5.014 -.835 -10.195 -1.215 -15.187 -1.191 c -4.99 .023 -9.612 .448 -13.805 1.191 C 37.098 6.188 35 10.758 35 19.183 V 30 h 29 v 4 H 23.776 c -8.484 0 -15.914 5.108 -18.237 14.811 c -2.681 11.12 -2.8 17.919 0 29.53 C 7.614 86.983 12.569 93 21.054 93 H 31 V 79.952 C 31 70.315 39.428 62 49.33 62 z m -1.838 -39.11 c -3.026 0 -5.478 -2.479 -5.478 -5.545 c 0 -3.079 2.451 -5.581 5.478 -5.581 c 3.015 0 5.479 2.502 5.479 5.581 c -.001 3.066 -2.465 5.545 -5.479 5.545 z m 74.789 25.921 C 120.183 40.363 116.178 34 107.682 34 H 97 v 12.981 C 97 57.031 88.206 65 78.489 65 H 49.33 C 41.342 65 35 72.326 35 80.326 v 27.8 c 0 7.91 6.745 12.564 14.462 14.834 c 9.242 2.717 17.994 3.208 29.051 0 C 85.862 120.831 93 116.549 93 108.126 V 97 H 64 v -4 h 43.682 c 8.484 0 11.647 -5.776 14.599 -14.66 c 3.047 -9.145 2.916 -17.799 0 -29.529 z m -41.955 55.606 c 3.027 0 5.479 2.479 5.479 5.547 c 0 3.076 -2.451 5.579 -5.479 5.579 c -3.015 0 -5.478 -2.502 -5.478 -5.579 c 0 -3.068 2.463 -5.547 5.478 -5.547 z" + readonly property string rust: "M 62.96 .242 c -.232 .135 -1.203 1.528 -2.16 3.097 c -2.4 3.94 -2.426 3.942 -5.65 .55 c -2.098 -2.208 -2.605 -2.612 -3.28 -2.607 c -.44 .002 -.995 .152 -1.235 .332 c -.24 .18 -.916 1.612 -1.504 3.183 c -1.346 3.6 -1.41 3.715 -2.156 3.86 c -.46 .086 -1.343 -.407 -3.463 -1.929 c -1.565 -1.125 -3.1 -2.045 -3.411 -2.045 c -1.291 0 -1.655 .706 -2.27 4.4 c -.78 4.697 -.754 4.681 -4.988 2.758 c -1.71 -.776 -3.33 -1.41 -3.603 -1.41 c -.274 0 -.792 .293 -1.15 .652 c -.652 .652 -.653 .655 -.475 4.246 l .178 3.595 l -.68 .364 c -.602 .322 -1.017 .283 -3.684 -.348 c -3.48 -.822 -4.216 -.8 -4.92 .15 l -.516 .693 l .692 2.964 c .38 1.63 .745 3.2 .814 3.487 c .067 .287 -.05 .746 -.26 1.02 c -.348 .448 -.717 .49 -3.94 .44 c -5.452 -.086 -5.761 .382 -3.51 5.3 c .718 1.56 1.305 2.98 1.305 3.15 c 0 .898 -.717 1.224 -3.794 1.727 c -1.722 .28 -3.218 .51 -3.326 .51 c -.107 0 -.43 .235 -.717 .522 c -.937 .936 -.671 1.816 1.453 4.814 c 2.646 3.735 2.642 3.75 -1.73 5.421 c -4.971 1.902 -5.072 2.37 -1.287 5.96 c 3.525 3.344 3.53 3.295 -.461 5.804 C .208 62.8 .162 62.846 .085 63.876 c -.093 1.253 -.071 1.275 3.538 3.48 c 3.57 2.18 3.57 2.246 .067 5.56 C -.078 76.48 .038 77 5.013 78.877 c 4.347 1.64 4.353 1.66 1.702 5.394 c -1.502 2.117 -1.981 3 -1.981 3.653 c 0 1.223 .637 1.535 4.44 2.174 c 3.206 .54 3.92 .857 3.92 1.741 c 0 .182 -.588 1.612 -1.307 3.177 c -2.236 4.87 -1.981 5.275 3.31 5.275 c 4.93 0 4.799 -.15 3.737 4.294 c -.8 3.35 -.813 3.992 -.088 4.715 c .554 .556 1.6 .494 4.87 -.289 c 2.499 -.596 2.937 -.637 3.516 -.328 l .66 .354 l -.177 3.594 c -.178 3.593 -.177 3.595 .475 4.248 c .358 .36 .884 .652 1.165 .652 c .282 0 1.903 -.63 3.604 -1.404 c 4.22 -1.916 4.194 -1.932 4.973 2.75 c .617 3.711 .977 4.4 2.294 4.4 c .327 0 1.83 -.88 3.34 -1.958 c 2.654 -1.893 3.342 -2.19 4.049 -1.74 c .182 .115 .89 1.67 1.572 3.455 c 1.003 2.625 1.37 3.31 1.929 3.576 c 1.062 .51 1.72 .1 4.218 -2.62 c 3.016 -3.286 3.14 -3.27 5.602 .72 c 2.72 4.406 3.424 4.396 6.212 -.089 c 2.402 -3.864 2.374 -3.862 5.621 -.47 c 2.157 2.25 2.616 2.61 3.343 2.61 c .464 0 1.019 -.175 1.23 -.388 c .214 -.213 .92 -1.786 1.568 -3.496 c .649 -1.71 1.321 -3.2 1.495 -3.31 c .687 -.436 1.398 -.13 4.048 1.752 c 1.56 1.108 3.028 1.96 3.377 1.96 c 1.296 0 1.764 -.92 2.302 -4.535 c .46 -3.082 .554 -3.378 1.16 -3.685 c .596 -.302 .954 -.2 3.75 1.07 c 1.701 .77 3.323 1.402 3.604 1.402 c .282 0 .816 -.302 1.184 -.672 l .672 -.67 l -.184 -3.448 c -.177 -3.29 -.16 -3.468 .364 -3.943 c .54 -.488 .596 -.486 3.615 .204 c 3.656 .835 4.338 .857 5.025 .17 c .671 -.67 .664 -.818 -.254 -4.69 c -1.03 -4.346 -1.168 -4.19 3.78 -4.19 c 3.374 0 3.75 -.049 4.18 -.523 c .718 -.793 .547 -1.702 -.896 -4.779 c -.729 -1.55 -1.32 -2.96 -1.315 -3.135 c .024 -.914 .743 -1.227 4.065 -1.767 c 2.033 -.329 3.553 -.71 3.829 -.96 c .923 -.833 .584 -1.918 -1.523 -4.873 c -2.642 -3.703 -2.63 -3.738 1.599 -5.297 c 5.064 -1.866 5.209 -2.488 1.419 -6.09 c -3.51 -3.335 -3.512 -3.317 .333 -5.677 c 4.648 -2.853 4.655 -3.496 .082 -6.335 c -3.933 -2.44 -3.93 -2.406 -.405 -5.753 c 3.78 -3.593 3.678 -4.063 -1.295 -5.965 c -4.388 -1.679 -4.402 -1.72 -1.735 -5.38 c 1.588 -2.18 1.982 -2.903 1.982 -3.65 c 0 -1.306 -.586 -1.598 -4.436 -2.22 c -3.216 -.52 -3.924 -.835 -3.924 -1.75 c 0 -.174 .588 -1.574 1.307 -3.113 c 1.406 -3.013 1.604 -4.22 .808 -4.94 c -.428 -.387 -1 -.443 -4.067 -.392 c -3.208 .054 -3.618 .008 -4.063 -.439 c -.486 -.488 -.48 -.557 .278 -3.725 c .931 -3.88 .935 -3.975 .17 -4.694 c -.777 -.73 -1.262 -.718 -4.826 .121 c -2.597 .612 -3.027 .653 -3.617 .337 l -.67 -.36 l .185 -3.582 l .186 -3.58 l -.67 -.67 c -.369 -.37 -.891 -.67 -1.163 -.67 c -.27 0 -1.884 .64 -3.583 1.421 c -2.838 1.306 -3.143 1.393 -3.757 1.072 c -.612 -.32 -.714 -.637 -1.237 -3.829 c -.603 -3.693 -.977 -4.412 -2.288 -4.412 c -.311 0 -1.853 .925 -3.426 2.055 c -2.584 1.856 -2.93 2.032 -3.574 1.807 c -.533 -.186 -.843 -.59 -1.221 -1.599 c -.28 -.742 -.817 -2.172 -1.194 -3.177 c -.762 -2.028 -1.187 -2.482 -2.328 -2.482 c -.637 0 -1.213 .458 -3.28 2.604 c -3.25 3.375 -3.261 3.374 -5.65 -.545 C 66.073 1.78 65.075 .382 64.81 .24 c -.597 -.32 -1.3 -.32 -1.85 .002 m 2.96 11.798 c 2.83 2.014 1.326 6.75 -2.144 6.75 c -3.368 0 -5.064 -4.057 -2.66 -6.36 c 1.358 -1.3 3.304 -1.459 4.805 -.39 m -3.558 12.507 c 1.855 .705 2.616 .282 6.852 -3.8 l 3.182 -3.07 l 1.347 .18 c 4.225 .56 12.627 4.25 17.455 7.666 c 4.436 3.14 10.332 9.534 12.845 13.93 l .537 .942 l -2.38 5.364 c -1.31 2.95 -2.382 5.673 -2.382 6.053 c 0 .878 .576 2.267 1.13 2.726 c .234 .195 2.457 1.265 4.939 2.378 l 4.51 2.025 l .178 1.148 c .23 1.495 .26 5.167 .052 6.21 l -.163 .816 h -2.575 c -2.987 0 -2.756 -.267 -2.918 3.396 c -.118 2.656 -.76 4.124 -2.22 5.075 c -2.377 1.551 -6.304 1.27 -7.97 -.57 c -.255 -.284 -.752 -1.705 -1.105 -3.16 c -1.03 -4.254 -2.413 -6.64 -5.193 -8.965 c -.878 -.733 -1.595 -1.418 -1.595 -1.522 c 0 -.102 .965 -.915 2.145 -1.803 c 4.298 -3.24 6.77 -7.012 7.04 -10.747 c .519 -7.126 -5.158 -13.767 -13.602 -15.92 c -2.002 -.51 -2.857 -.526 -27.624 -.526 c -14.057 0 -25.56 -.092 -25.56 -.204 c 0 -.263 3.125 -3.295 4.965 -4.816 c 5.054 -4.178 11.618 -7.465 18.417 -9.22 l 2.35 -.61 l 3.34 3.387 c 1.839 1.863 3.64 3.5 4.003 3.637 M 20.3 46.34 c 1.539 1.008 2.17 3.54 1.26 5.062 c -1.405 2.356 -4.966 2.455 -6.373 .178 c -2.046 -3.309 1.895 -7.349 5.113 -5.24 m 90.672 .13 c 4.026 2.454 .906 8.493 -3.404 6.586 c -2.877 -1.273 -2.97 -5.206 -.155 -6.64 c 1.174 -.6 2.523 -.579 3.56 .053 M 32.163 61.5 v 15.02 h -13.28 l -.526 -2.285 c -1.036 -4.5 -1.472 -9.156 -1.211 -12.969 l .182 -2.679 l 4.565 -2.047 c 2.864 -1.283 4.706 -2.262 4.943 -2.625 c 1.038 -1.584 .94 -2.715 -.518 -5.933 l -.68 -1.502 h 6.523 V 61.5 M 70.39 47.132 c 2.843 .74 4.345 2.245 4.349 4.355 c .002 1.55 -.765 2.52 -2.67 3.38 c -1.348 .61 -1.562 .625 -10.063 .708 l -8.686 .084 v -8.92 h 7.782 c 6.078 0 8.112 .086 9.288 .393 m -2.934 21.554 c 1.41 .392 3.076 1.616 3.93 2.888 c .898 1.337 1.423 3.076 2.667 8.836 c 1.05 4.87 1.727 6.46 3.62 8.532 c 2.345 2.566 1.8 2.466 13.514 2.466 c 5.61 0 10.198 .09 10.198 .2 c 0 .197 -3.863 4.764 -4.03 4.764 c -.048 0 -2.066 -.422 -4.484 -.939 c -6.829 -1.458 -7.075 -1.287 -8.642 6.032 l -1.008 4.702 l -.91 .448 c -1.518 .75 -6.453 2.292 -9.01 2.82 c -4.228 .87 -8.828 1.162 -12.871 .821 c -6.893 -.585 -16.02 -3.259 -16.377 -4.8 c -.075 -.327 -.535 -2.443 -1.018 -4.704 c -.485 -2.26 -1.074 -4.404 -1.31 -4.764 c -1.13 -1.724 -2.318 -1.83 -7.547 -.674 c -1.98 .44 -3.708 .796 -3.84 .796 c -.248 0 -3.923 -4.249 -3.923 -4.535 c 0 -.09 8.728 -.194 19.396 -.23 l 19.395 -.066 l .07 -6.89 c .05 -4.865 -.018 -6.997 -.23 -7.25 c -.234 -.284 -1.485 -.358 -6.011 -.358 H 53.32 v -8.36 l 6.597 .001 c 3.626 .002 7.02 .12 7.539 .264 M 37.57 100.02 c 3.084 1.88 1.605 6.804 -2.043 6.8 c -3.74 0 -5.127 -4.88 -1.94 -6.826 c 1.055 -.643 2.908 -.63 3.983 .026 m 56.48 .206 c 1.512 1.108 2.015 3.413 1.079 4.95 c -2.46 4.034 -8.612 .827 -6.557 -3.419 c 1.01 -2.085 3.695 -2.837 5.478 -1.53" + readonly property string javascript: "M 2 1 v 125 h 125 V 1 H 2 z m 66.119 106.513 c -1.845 3.749 -5.367 6.212 -9.448 7.401 c -6.271 1.44 -12.269 .619 -16.731 -2.059 c -2.986 -1.832 -5.318 -4.652 -6.901 -7.901 l 9.52 -5.83 c .083 .035 .333 .487 .667 1.071 c 1.214 2.034 2.261 3.474 4.319 4.485 c 2.022 .69 6.461 1.131 8.175 -2.427 c 1.047 -1.81 .714 -7.628 .714 -14.065 C 58.433 78.073 58.48 68 58.48 58 h 11.709 c 0 11 .06 21.418 0 32.152 c .025 6.58 .596 12.446 -2.07 17.361 z m 48.574 -3.308 c -4.07 13.922 -26.762 14.374 -35.83 5.176 c -1.916 -2.165 -3.117 -3.296 -4.26 -5.795 c 4.819 -2.772 4.819 -2.772 9.508 -5.485 c 2.547 3.915 4.902 6.068 9.139 6.949 c 5.748 .702 11.531 -1.273 10.234 -7.378 c -1.333 -4.986 -11.77 -6.199 -18.873 -11.531 c -7.211 -4.843 -8.901 -16.611 -2.975 -23.335 c 1.975 -2.487 5.343 -4.343 8.877 -5.235 l 3.688 -.477 c 7.081 -.143 11.507 1.727 14.756 5.355 c .904 .916 1.642 1.904 3.022 4.045 c -3.772 2.404 -3.76 2.381 -9.163 5.879 c -1.154 -2.486 -3.069 -4.046 -5.093 -4.724 c -3.142 -.952 -7.104 .083 -7.926 3.403 c -.285 1.023 -.226 1.975 .227 3.665 c 1.273 2.903 5.545 4.165 9.377 5.926 c 11.031 4.474 14.756 9.271 15.672 14.981 c .882 4.916 -.213 8.105 -.38 8.581 z" + readonly property string typescript: "M 2 63.91 v 62.5 h 125 v -125 H 2 z m 100.73 -5 a 15.56 15.56 0 0 1 7.82 4.5 a 20.58 20.58 0 0 1 3 4 c 0 .16 -5.4 3.81 -8.69 5.85 c -.12 .08 -.6 -.44 -1.13 -1.23 a 7.09 7.09 0 0 0 -5.87 -3.53 c -3.79 -.26 -6.23 1.73 -6.21 5 a 4.58 4.58 0 0 0 .54 2.34 c .83 1.73 2.38 2.76 7.24 4.86 c 8.95 3.85 12.78 6.39 15.16 10 c 2.66 4 3.25 10.46 1.45 15.24 c -2 5.2 -6.9 8.73 -13.83 9.9 a 38.32 38.32 0 0 1 -9.52 -.1 A 23 23 0 0 1 80 109.19 c -1.15 -1.27 -3.39 -4.58 -3.25 -4.82 a 9.34 9.34 0 0 1 1.15 -.73 l 4.6 -2.64 l 3.59 -2.08 l .75 1.11 a 16.78 16.78 0 0 0 4.74 4.54 c 4 2.1 9.46 1.81 12.16 -.62 a 5.43 5.43 0 0 0 .69 -6.92 c -1 -1.39 -3 -2.56 -8.59 -5 c -6.45 -2.78 -9.23 -4.5 -11.77 -7.24 a 16.48 16.48 0 0 1 -3.43 -6.25 a 25 25 0 0 1 -.22 -8 c 1.33 -6.23 6 -10.58 12.82 -11.87 a 31.66 31.66 0 0 1 9.49 .26 z m -29.34 5.24 v 5.12 H 57.16 v 46.23 H 45.65 V 69.26 H 29.38 v -5 a 49.19 49.19 0 0 1 .14 -5.16 c .06 -.08 10 -.12 22 -.1 h 21.81 z" + readonly property string bash: "M 112.205 26.129 L 71.8 2.142 A 15.326 15.326 0 0 0 64.005 0 c -2.688 0 -5.386 .717 -7.796 2.152 L 15.795 26.14 C 10.976 28.999 8 34.289 8 40.018 v 47.975 c 0 5.729 2.967 11.019 7.796 13.878 L 56.2 125.858 A 15.193 15.193 0 0 0 63.995 128 a 15.32 15.32 0 0 0 7.796 -2.142 l 40.414 -23.987 c 4.819 -2.86 7.796 -8.16 7.796 -13.878 V 40.007 c 0 -5.718 -2.967 -11.019 -7.796 -13.878 z m -31.29 74.907 l .063 3.448 c 0 .418 -.267 .889 -.588 1.06 l -2.046 1.178 c -.321 .16 -.6 -.032 -.6 -.45 l -.032 -3.394 c -1.745 .728 -3.523 .9 -4.647 .45 c -.214 -.086 -.31 -.397 -.225 -.761 l .739 -3.116 c .064 -.246 .193 -.493 .364 -.643 a .725 .725 0 0 1 .193 -.139 c .117 -.064 .235 -.075 .332 -.032 c 1.22 .407 2.773 .214 4.272 -.535 c 1.907 -.964 3.18 -2.913 3.16 -4.84 c -.022 -1.757 -.964 -2.474 -3.267 -2.496 c -2.934 .01 -5.675 -.567 -5.718 -4.894 c -.032 -3.555 1.81 -7.26 4.744 -9.595 l -.032 -3.48 c 0 -.428 .257 -.9 .589 -1.07 l 1.98 -1.264 c .322 -.161 .6 .043 .6 .46 l .033 3.48 c 1.456 -.578 2.72 -.738 3.865 -.47 c .247 .064 .364 .406 .257 .802 l -.77 3.084 a 1.372 1.372 0 0 1 -.354 .622 a .825 .825 0 0 1 -.203 .15 c -.108 .053 -.204 .064 -.3 .053 c -.525 -.118 -1.767 -.385 -3.727 .6 c -2.056 1.038 -2.773 2.827 -2.763 4.155 c .022 1.585 .825 2.066 3.63 2.11 c 3.738 .064 5.344 1.691 5.387 5.45 c .053 3.684 -1.917 7.657 -4.937 10.077 z m 21.18 -5.794 c 0 .322 -.042 .621 -.31 .771 l -10.216 6.211 c -.267 .161 -.482 .022 -.482 -.3 V 99.29 c 0 -.321 .193 -.492 .46 -.653 l 10.067 -6.018 c .268 -.16 .482 -.022 .482 .3 z m 7.026 -58.993 L 70.89 59.86 c -4.765 2.784 -8.278 5.911 -8.288 11.662 v 47.107 c 0 3.437 1.392 5.665 3.523 6.318 a 12.81 12.81 0 0 1 -2.12 .204 c -2.239 0 -4.445 -.61 -6.383 -1.757 L 17.219 99.408 c -3.951 -2.345 -6.403 -6.725 -6.403 -11.426 V 40.007 c 0 -4.7 2.452 -9.08 6.403 -11.426 L 57.634 4.594 a 12.555 12.555 0 0 1 6.382 -1.756 c 2.238 0 4.444 .61 6.382 1.756 l 40.415 23.987 c 3.33 1.981 5.579 5.397 6.21 9.242 c -1.36 -2.86 -4.38 -3.63 -7.902 -1.574 z" + readonly property string zsh: "M 19.264 3.822 C 8.664 3.822 0 12.486 0 23.086 v 81.771 c 0 10.6 8.664 19.264 19.264 19.264 h 43.955 a 3.24 3.24 0 0 0 3.24 -3.24 a 3.24 3.24 0 0 0 -3.24 -3.24 H 19.264 a 12.736 12.736 0 0 1 -12.785 -12.784 v -72.26 h 115.043 v 68.471 a .713 .713 0 0 1 -.131 .186 a .716 .716 0 0 1 -.506 .21 h -3.965 a .716 .716 0 0 1 -.506 -.21 a .715 .715 0 0 1 -.209 -.506 V 88.84 l -.002 -.035 l .002 -.037 a 3.208 3.208 0 0 0 -3.207 -3.207 a 3.207 3.207 0 0 0 -2.267 .939 a 3.206 3.206 0 0 0 -.94 2.268 l .002 .037 l -.002 .035 v 32.13 c 0 .851 .338 1.667 .94 2.268 a 3.206 3.206 0 0 0 4.535 0 a 3.208 3.208 0 0 0 .94 -2.267 v -12.735 c 0 -.19 .074 -.371 .208 -.505 a .715 .715 0 0 1 .506 -.21 h 3.965 c .19 0 .372 .076 .506 .21 a .716 .716 0 0 1 .13 .185 v 12.965 a 3.24 3.24 0 0 0 .034 .12 c .003 .267 .033 .535 .101 .794 a 3.187 3.187 0 0 0 .64 1.23 a 3.2 3.2 0 0 0 1.104 .844 a 3.189 3.189 0 0 0 1.356 .297 l .014 -.002 l .011 .002 a 3.18 3.18 0 0 0 2.94 -1.965 c .009 -.02 .013 -.042 .021 -.062 a 3.24 3.24 0 0 0 .03 -.078 a 3.18 3.18 0 0 0 .095 -.313 a 3.24 3.24 0 0 0 .133 -.867 V 23.086 c 0 -10.6 -8.662 -19.264 -19.262 -19.264 H 19.264 z m 0 6.479 h 89.474 a 12.736 12.736 0 0 1 12.783 12.785 v 3.033 H 6.478 v -3.033 a 12.737 12.737 0 0 1 12.786 -12.785 z m 14.658 28.515 a 1.138 1.138 0 0 0 -.774 .4 l -21.14 25.178 a 1.138 1.138 0 0 0 .14 1.604 a 1.138 1.138 0 0 0 1.602 -.139 l 21.143 -25.177 a 1.138 1.138 0 0 0 -.141 -1.604 a 1.138 1.138 0 0 0 -.83 -.262 z m -15.68 .637 c -3.37 0 -6.14 2.772 -6.14 6.143 s 2.77 6.14 6.14 6.14 c 3.37 0 6.14 -2.77 6.14 -6.14 c 0 -3.371 -2.77 -6.143 -6.14 -6.143 z m 0 3.557 c 1.45 0 2.586 1.137 2.586 2.586 s -1.137 2.586 -2.586 2.586 a 2.56 2.56 0 0 1 -2.586 -2.586 a 2.56 2.56 0 0 1 2.586 -2.586 z m 10.344 10.344 c -3.37 0 -6.143 2.77 -6.143 6.14 c 0 3.37 2.772 6.14 6.143 6.14 c 3.37 0 6.14 -2.77 6.14 -6.14 s -2.77 -6.14 -6.14 -6.14 z m 0 3.554 a 2.56 2.56 0 0 1 2.586 2.586 a 2.56 2.56 0 0 1 -2.586 2.586 A 2.56 2.56 0 0 1 26 59.494 a 2.56 2.56 0 0 1 2.586 -2.586 z m 14.18 6.451 a 1.138 1.138 0 0 0 -1.139 1.137 a 1.138 1.138 0 0 0 1.139 1.139 H 57.86 A 1.138 1.138 0 0 0 59 64.496 a 1.138 1.138 0 0 0 -1.139 -1.137 H 42.766 z m 30.767 22.186 a 3.253 3.253 0 0 0 -1.244 .248 a 3.265 3.265 0 0 0 -1.057 .705 a 3.251 3.251 0 0 0 -.703 1.055 a 3.25 3.25 0 0 0 -.248 1.244 v .002 a 3.24 3.24 0 0 0 .951 2.299 a 3.246 3.246 0 0 0 1.057 .703 a 3.241 3.241 0 0 0 1.244 .248 c .051 0 .1 -.006 .15 -.008 h 5.99 a .404 .404 0 0 1 .335 .176 a .41 .41 0 0 1 .066 .183 a .408 .408 0 0 1 -.027 .194 l -9.51 23.748 a 3.58 3.58 0 0 0 -.256 1.33 v 2.912 c 0 .948 .377 1.857 1.047 2.527 a 3.58 3.58 0 0 0 2.53 1.05 h 11.146 a 3.334 3.334 0 0 0 1.275 -.255 a 3.343 3.343 0 0 0 1.082 -.722 a 3.327 3.327 0 0 0 .723 -1.082 a 3.326 3.326 0 0 0 .254 -1.276 v -.002 a 3.338 3.338 0 0 0 -.977 -2.36 a 3.338 3.338 0 0 0 -2.359 -.976 c -.026 0 -.05 .003 -.076 .004 h -7.498 a .387 .387 0 0 1 -.182 -.045 a .38 .38 0 0 1 -.137 -.125 a .39 .39 0 0 1 -.066 -.176 a .382 .382 0 0 1 .027 -.183 l 10.967 -27.344 a 2.964 2.964 0 0 0 .195 -1.418 a 2.955 2.955 0 0 0 -.49 -1.344 a 2.97 2.97 0 0 0 -1.066 -.957 a 2.961 2.961 0 0 0 -1.389 -.345 H 73.728 c -.065 -.004 -.128 -.01 -.195 -.01 z m 26.192 .18 c -1.523 0 -3.266 .147 -4.315 .441 a 5.042 5.042 0 0 0 -2.486 1.424 c -.609 .687 -1.05 1.619 -1.32 2.797 c -.272 1.145 -.405 2.602 -.405 4.369 c 0 1.701 .015 3.076 .049 4.123 a 19.145 19.145 0 0 0 .305 2.553 a 4.957 4.957 0 0 0 .66 1.57 a 6.3 6.3 0 0 0 1.015 1.129 a 10.337 10.337 0 0 0 1.725 1.228 c .677 .393 1.372 .767 2.082 1.127 a 29.432 29.432 0 0 1 2.03 1.13 a 8.647 8.647 0 0 1 1.675 1.228 c .2 .217 .386 .447 .557 .687 c .123 .146 .22 .311 .283 .49 c .059 .16 .11 .325 .148 .49 c .056 .26 .082 .522 .079 .786 c .033 .392 .048 .934 .048 1.62 a 20.153 20.153 0 0 1 -.15 2.798 a 3.99 3.99 0 0 1 -.406 1.521 a 1.397 1.397 0 0 1 -.813 .639 c -.338 .098 -1.508 .146 -2.015 .146 a 4.42 4.42 0 0 1 -1.219 -.146 a 1.676 1.676 0 0 1 -.76 -.688 a 5.713 5.713 0 0 1 -.406 -1.472 a 23.667 23.667 0 0 1 -.104 -2.45 c 0 -.09 -.004 -.182 -.013 -.273 h -.02 a 2.728 2.728 0 0 0 -.918 -1.678 a 2.757 2.757 0 0 0 -1.8 -.668 a 2.747 2.747 0 0 0 -1.798 .668 a 2.728 2.728 0 0 0 -.917 1.678 h -.018 a 2.6 2.6 0 0 0 -.014 .301 v .123 c .009 .645 .018 1.223 .018 1.514 c 0 1.93 .136 3.501 .406 4.713 c .305 1.177 .78 2.11 1.422 2.796 a 4.887 4.887 0 0 0 2.436 1.327 a 14.194 14.194 0 0 0 3.603 .392 c 1.625 0 3.724 -.147 4.807 -.441 a 4.78 4.78 0 0 0 2.588 -1.668 c .676 -.818 1.15 -1.949 1.422 -3.389 c .304 -1.472 .457 -3.354 .457 -5.644 c 0 -1.67 -.051 -3.011 -.153 -4.026 a 8.816 8.816 0 0 0 -.457 -2.504 a 4.07 4.07 0 0 0 -.965 -1.57 a 7.968 7.968 0 0 0 -1.47 -1.178 a 14.43 14.43 0 0 0 -1.524 -.834 a 23.424 23.424 0 0 0 -1.826 -.933 a 44.885 44.885 0 0 1 -1.828 -.98 a 15.28 15.28 0 0 1 -1.47 -1.032 a 5 5 0 0 1 -.61 -.59 a 2.125 2.125 0 0 1 -.305 -.736 a 7.721 7.721 0 0 1 -.152 -1.13 V 95.64 a 17.363 17.363 0 0 1 .101 -2.112 a 3.367 3.367 0 0 1 .356 -1.226 a 1.222 1.222 0 0 1 .76 -.54 c .304 -.098 1.153 -.148 1.627 -.148 c 1.082 0 1.758 .312 2.029 .934 c .214 .393 .335 .828 .353 1.275 c .012 .358 .017 .824 .018 1.31 h .02 v .02 c 0 .726 .289 1.421 .804 1.934 a 2.753 2.753 0 0 0 1.944 .8 c .728 0 1.428 -.287 1.943 -.8 a 2.728 2.728 0 0 0 .805 -1.934 l -.002 -.02 h .002 v -1.31 a 16.978 16.978 0 0 0 -.356 -3.779 c -.203 -1.047 -.61 -1.88 -1.219 -2.502 c -.575 -.655 -1.387 -1.113 -2.435 -1.375 c -1.015 -.294 -2.317 -.441 -3.906 -.441 h -.002 z" + readonly property string powershell: "M 124.912 19.358 c -.962 -1.199 -2.422 -1.858 -4.111 -1.858 h -92.61 c -3.397 0 -6.665 2.642 -7.444 6.015 L 2.162 104.022 c -.396 1.711 -.058 3.394 .926 4.619 c .963 1.199 2.423 1.858 4.111 1.858 v .001 H 99.81 c 3.396 0 6.665 -2.643 7.443 -6.016 l 18.586 -80.508 c .395 -1.711 .057 -3.395 -.927 -4.618 z m -98.589 77.17 c -1.743 -2.397 -1.323 -5.673 .94 -7.318 l 37.379 -27.067 v -.556 L 41.157 36.603 c -1.916 -2.038 -1.716 -5.333 .445 -7.361 c 2.162 -2.027 5.466 -2.019 7.382 .019 l 28.18 29.979 c 1.6 1.702 1.718 4.279 .457 6.264 c -.384 .774 -1.182 1.628 -2.593 2.618 l -41.45 29.769 c -2.263 1.644 -5.512 1.034 -7.255 -1.363 z m 59.543 .538 H 63.532 c -2.597 0 -4.702 -2.082 -4.702 -4.65 s 2.105 -4.65 4.702 -4.65 h 22.333 c 2.597 0 4.702 2.082 4.702 4.65 s -2.104 4.65 -4.701 4.65 z" + readonly property string cmake: "M 62.8 .4 L .3 123.8 l 68.1 -57.9 z m 61 127.3 l -84 -33.9 L 0 127.7 z m 4.2 -1.1 L 65.6 2.5 l 9.2 102.6 z M 71.9 104 l -3.1 -34.9 L 42 92 z" + readonly property string lua: "M 61.7 0 c -1.9 0 -3.8 .2 -5.6 .4 l .2 1.5 c 1.8 -.2 3.6 -.4 5.5 -.4 L 61.7 0 z m 5.6 0 l -.1 1.5 c 1.8 .1 3.6 .3 5.4 .5 l .3 -1.5 C 71 .3 69.2 .1 67.3 0 z m 45.7 .8 c -7.9 0 -14.4 6.3 -14.4 14.3 S 105 29.4 113 29.4 s 14.3 -6.4 14.3 -14.3 S 120.9 .8 113 .8 z m -62.4 .6 c -1.8 .4 -3.6 .9 -5.4 1.4 l .4 1.4 c 1.7 -.5 3.5 -1 5.3 -1.4 l -.3 -1.4 z m 27.6 .3 l -.3 1.4 c 1.8 .4 3.6 .8 5.3 1.4 l .4 -1.3 c -1.8 -.6 -3.6 -1.1 -5.4 -1.5 z m -38.3 3 c -1.7 .7 -3.4 1.5 -5.1 2.3 l .7 1.3 c 1.6 -.8 3.3 -1.6 5 -2.3 l -.6 -1.3 z m 49 .3 l -.5 1.4 c 1.6 .7 3.3 1.5 4.9 2.3 l .6 -1.3 c -1.6 -.9 -3.3 -1.7 -5 -2.4 z M 30 9.7 c -1.6 1 -3.1 2.1 -4.6 3.2 l .9 1.2 c 1.4 -1.1 2.9 -2.1 4.5 -3.2 L 30 9.7 z m 34 5.4 c -27 0 -49 21.9 -49 49 s 21.9 49 49 49 s 49 -21.9 49 -49 s -22 -49 -49 -49 z m -42.9 1.4 c -1.4 1.2 -2.7 2.5 -4 3.9 l 1.1 1 c 1.2 -1.3 2.5 -2.6 3.9 -3.8 l -1 -1.1 z m -7.6 8.2 c -1.1 1.4 -2.2 2.9 -3.2 4.5 l 1.2 .8 c 1 -1.5 2 -3 3.2 -4.4 l -1.2 -.9 z m 70.8 4.7 c 7.9 0 14.3 6.4 14.3 14.3 S 92.2 58 84.3 58 S 70 51.6 70 43.7 s 6.4 -14.3 14.3 -14.3 z M 7.4 34.1 c -.9 1.6 -1.7 3.3 -2.4 5 l 1.4 .5 c .7 -1.6 1.5 -3.3 2.3 -4.8 l -1.3 -.7 z m 113.6 .8 l -1.3 .7 c .9 1.6 1.6 3.3 2.3 5 l 1.3 -.6 c -.7 -1.7 -1.5 -3.4 -2.3 -5.1 z M 3.1 44.3 c -.6 1.8 -1.1 3.6 -1.5 5.4 L 3 50 c .4 -1.8 .9 -3.5 1.5 -5.2 l -1.4 -.5 z m 122.1 1 l -1.4 .4 c .6 1.7 1 3.5 1.4 5.3 l 1.4 -.3 c -.4 -1.8 -.9 -3.6 -1.4 -5.4 z M .5 55.1 C .3 57 .1 58.8 0 60.7 l 1.5 .1 c .1 -1.8 .3 -3.6 .5 -5.4 l -1.5 -.3 z m 127.1 1.1 l -1.5 .2 c .2 1.8 .3 3.6 .4 5.4 h 1.5 c 0 -1.9 -.2 -3.8 -.4 -5.6 z m -96.9 .2 h 4.1 v 28.5 h 15.9 v 3.6 h -20 V 56.4 z m 57.7 8.3 c 5.7 0 8.7 2.2 8.7 6.3 v 13.6 c 0 1.1 .7 1.8 2 1.8 c .2 0 .4 0 .8 -.1 l -.1 2.8 c -1.2 .3 -1.8 .4 -2.5 .4 c -2.4 0 -3.5 -1.1 -3.8 -3.4 c -2.6 2.4 -4.9 3.4 -7.8 3.4 c -4.7 0 -7.6 -2.6 -7.6 -6.8 c 0 -3 1.4 -5.1 4.1 -6.2 c 1.4 -.6 2.2 -.7 7.4 -1.4 c 2.9 -.4 3.8 -1 3.8 -2.6 v -1 c 0 -2.2 -1.9 -3.4 -5.2 -3.4 c -3.4 0 -5.1 1.3 -5.4 4.1 h -3.7 c .1 -2.3 .5 -3.6 1.6 -4.8 c 1.5 -1.7 4.3 -2.7 7.7 -2.7 z m -33.8 .7 h 3.7 v 16.3 c 0 2.8 1.9 4.5 4.8 4.5 c 3.8 0 6.3 -3.1 6.3 -7.8 v -13 H 73 v 23.1 h -3.3 v -3.2 c -2.2 3 -4.3 4.2 -7.7 4.2 c -4.5 0 -7.4 -2.5 -7.4 -6.3 V 65.4 z m -53.1 .8 l -1.5 .1 c .1 1.9 .2 3.7 .5 5.6 l 1.4 -.3 c -.2 -1.8 -.3 -3.6 -.4 -5.4 z m 124.9 1.1 c -.1 1.8 -.3 3.6 -.5 5.4 l 1.5 .2 c .3 -1.8 .5 -3.7 .5 -5.5 l -1.5 -.1 z M 2.8 77.1 l -1.4 .3 c .4 1.8 .9 3.6 1.4 5.4 l 1.4 -.4 c -.6 -1.8 -1 -3.5 -1.4 -5.3 z m 90.6 0 c -1.2 .6 -2 .7 -5.9 1.3 c -3.9 .5 -5.6 1.8 -5.6 4.2 c 0 2.3 1.7 3.7 4.5 3.7 c 2.2 0 4 -.7 5.5 -2.1 c 1.1 -1 1.5 -1.8 1.5 -3 v -4.1 z m 31.6 .9 c -.5 1.8 -.9 3.6 -1.5 5.3 l 1.4 .5 c .6 -1.8 1.1 -3.6 1.5 -5.5 L 125 78 z M 6 87.5 l -1.3 .5 c .7 1.7 1.5 3.4 2.3 5.1 l 1.3 -.6 c -.9 -1.7 -1.6 -3.3 -2.3 -5 z m 115.7 1 c -.8 1.6 -1.5 3.3 -2.4 4.9 l 1.3 .7 L 123 89 l -1.3 -.5 z M 10.9 97.2 l -1.2 .8 c 1 1.6 2.1 3.1 3.2 4.6 l 1.1 -.9 c -1.1 -1.5 -2.1 -3 -3.1 -4.5 z m 105.6 .9 c -1 1.5 -2.1 3 -3.2 4.4 l 1.2 .9 c 1.1 -1.4 2.2 -3 3.2 -4.5 l -1.2 -.8 z m -98.9 7.8 l -1.1 1 c 1.2 1.4 2.5 2.7 3.9 4 l 1 -1.1 c -1.3 -1.2 -2.6 -2.6 -3.8 -3.9 z m 92.2 .8 c -1.2 1.3 -2.6 2.6 -3.9 3.8 l 1 1.1 c 1.3 -1.3 2.7 -2.6 4 -3.9 l -1.1 -1 z m -84.2 6.6 l -.9 1.2 c 1.4 1.1 2.9 2.2 4.5 3.2 l .8 -1.2 c -1.5 -1 -3 -2.1 -4.4 -3.2 z m 76.1 .7 c -1.5 1.1 -3 2.1 -4.5 3.1 l .8 1.2 c 1.5 -1 3.1 -2 4.6 -3.1 l -.9 -1.2 z m -67 5.3 l -.7 1.3 c 1.6 .9 3.3 1.7 5 2.4 l .6 -1.3 c -1.6 -.8 -3.3 -1.5 -4.9 -2.4 z m 57.7 .4 c -1.7 .9 -3.3 1.6 -5 2.3 l .6 1.4 c 1.7 -.7 3.4 -1.5 5.1 -2.4 l -.7 -1.3 z m -47.7 3.8 l -.5 1.4 c 1.8 .6 3.6 1.1 5.4 1.5 l .4 -1.4 c -1.8 -.5 -3.6 -.9 -5.3 -1.5 z m 37.6 .3 c -1.8 .6 -3.5 1 -5.3 1.4 l .3 1.4 c 1.9 -.3 3.7 -.8 5.4 -1.4 l -.4 -1.4 z m -27 2.2 l -.2 1.5 c 1.9 .2 3.7 .4 5.6 .5 v -1.5 c -1.8 -.1 -3.6 -.3 -5.4 -.5 z m 16.3 .1 c -1.8 .2 -3.6 .3 -5.4 .4 l .1 1.5 c 1.8 -.1 3.7 -.2 5.5 -.4 l -.2 -1.5 z" + readonly property string java: "M 47.617 98.12 c -19.192 5.362 11.677 16.439 36.115 5.969 c -4.003 -1.556 -6.874 -3.351 -6.874 -3.351 c -10.897 2.06 -15.952 2.222 -25.844 1.092 c -8.164 -.935 -3.397 -3.71 -3.397 -3.71 z m 33.189 -10.46 c -14.444 2.779 -22.787 2.69 -33.354 1.6 c -8.171 -.845 -2.822 -4.805 -2.822 -4.805 c -21.137 7.016 11.767 14.977 41.309 6.336 c -3.14 -1.106 -5.133 -3.131 -5.133 -3.131 z m 11.319 -60.575 c .001 0 -42.731 10.669 -22.323 34.187 c 6.024 6.935 -1.58 13.17 -1.58 13.17 s 15.289 -7.891 8.269 -17.777 c -6.559 -9.215 -11.587 -13.793 15.634 -29.58 z m 9.998 81.144 s 3.529 2.91 -3.888 5.159 c -14.102 4.272 -58.706 5.56 -71.095 .171 c -4.45 -1.938 3.899 -4.625 6.526 -5.192 c 2.739 -.593 4.303 -.485 4.303 -.485 c -4.952 -3.487 -32.013 6.85 -13.742 9.815 c 49.821 8.076 90.817 -3.637 77.896 -9.468 z M 85 77.896 c 2.395 -1.634 5.703 -3.053 5.703 -3.053 s -9.424 1.685 -18.813 2.474 c -11.494 .964 -23.823 1.154 -30.012 .326 c -14.652 -1.959 8.033 -7.348 8.033 -7.348 s -8.812 -.596 -19.644 4.644 C 17.455 81.134 61.958 83.958 85 77.896 z m 5.609 15.145 c -.108 .29 -.468 .616 -.468 .616 c 31.273 -8.221 19.775 -28.979 4.822 -23.725 c -1.312 .464 -2 1.543 -2 1.543 s .829 -.334 2.678 -.72 c 7.559 -1.575 18.389 10.119 -5.032 22.286 z M 64.181 70.069 c -4.614 -10.429 -20.26 -19.553 .007 -35.559 C 89.459 14.563 76.492 1.587 76.492 1.587 c 5.23 20.608 -18.451 26.833 -26.999 39.667 c -5.821 8.745 2.857 18.142 14.688 28.815 z m 27.274 51.748 c -19.187 3.612 -42.854 3.191 -56.887 .874 c 0 0 2.874 2.38 17.646 3.331 c 22.476 1.437 57 -.8 57.816 -11.436 c .001 0 -1.57 4.032 -18.575 7.231 z" + readonly property string kotlin: "M 112.484 112.484 H 15.516 V 15.516 h 96.968 L 64 64 Z m 0 0" + readonly property string swift: "M 125.54 26.23 a 28.78 28.78 0 0 0 -2.65 -7.58 a 28.84 28.84 0 0 0 -4.76 -6.32 a 23.42 23.42 0 0 0 -6.62 -4.55 a 27.27 27.27 0 0 0 -7.68 -2.53 c -2.65 -.51 -5.56 -.51 -8.21 -.76 H 30.25 a 45.46 45.46 0 0 0 -6.09 .51 a 21.81 21.81 0 0 0 -5.82 1.52 c -.53 .25 -1.32 .51 -1.85 .76 a 33.82 33.82 0 0 0 -5 3.28 c -.53 .51 -1.06 .76 -1.59 1.26 a 22.41 22.41 0 0 0 -4.76 6.32 a 23.61 23.61 0 0 0 -2.65 7.58 a 78.47 78.47 0 0 0 -.79 7.83 v 60.39 a 39.32 39.32 0 0 0 .79 7.83 a 28.78 28.78 0 0 0 2.65 7.58 a 28.84 28.84 0 0 0 4.76 6.32 a 23.42 23.42 0 0 0 6.62 4.55 a 27.27 27.27 0 0 0 7.68 2.53 c 2.65 .51 5.56 .51 8.21 .76 h 63.22 a 45.08 45.08 0 0 0 8.21 -.76 a 27.27 27.27 0 0 0 7.68 -2.53 a 30.13 30.13 0 0 0 6.62 -4.55 a 22.41 22.41 0 0 0 4.76 -6.32 a 23.61 23.61 0 0 0 2.65 -7.58 a 78.47 78.47 0 0 0 .79 -7.83 V 34.06 a 39.32 39.32 0 0 0 -.8 -7.83 z m -18.79 75.54 C 101 91 90.37 94.33 85 96.5 c -11.11 6.13 -26.38 6.76 -41.75 .47 A 64.53 64.53 0 0 1 13.84 73 a 50 50 0 0 0 10.85 6.32 c 15.87 7.1 31.73 6.61 42.9 0 c -15.9 -11.66 -29.4 -26.82 -39.46 -39.2 a 43.47 43.47 0 0 1 -5.29 -6.82 c 12.16 10.61 31.5 24 38.38 27.79 a 271.77 271.77 0 0 1 -27 -32.34 a 266.8 266.8 0 0 0 44.47 34.87 c .71 .38 1.26 .7 1.7 1 a 32.71 32.71 0 0 0 1.21 -3.51 c 3.71 -12.89 -.53 -27.54 -9.79 -39.67 C 93.25 33.81 106 57.05 100.66 76.51 c -.14 .53 -.29 1 -.45 1.55 l .19 .22 c 10.6 12.63 7.67 26.02 6.35 23.49 z" + readonly property string go: "M 108.2 64.8 c -.1 -.1 -.2 -.2 -.4 -.2 l -.1 -.1 c -.1 -.1 -.2 -.1 -.2 -.2 l -.1 -.1 c -.1 0 -.2 -.1 -.2 -.1 l -.2 -.1 c -.1 0 -.2 -.1 -.2 -.1 l -.2 -.1 c -.1 0 -.2 -.1 -.2 -.1 c -.1 0 -.1 0 -.2 -.1 l -.3 -.1 c -.1 0 -.1 0 -.2 -.1 l -.3 -.1 h -.1 l -.4 -.1 h -.2 c -.1 0 -.2 0 -.3 -.1 h -2.3 c -.6 -13.3 .6 -26.8 -2.8 -39.6 c 12.9 -4.6 2.8 -22.3 -8.4 -14.4 c -7.4 -6.4 -17.6 -7.8 -28.3 -7.8 c -10.5 .7 -20.4 2.9 -27.4 8.4 c -2.8 -1.4 -5.5 -1.8 -7.9 -1.1 v .1 c -.1 0 -.3 .1 -.4 .2 c -.1 0 -.3 .1 -.4 .2 h -.1 c -.1 0 -.2 .1 -.4 .2 h -.1 l -.3 .2 h -.1 l -.3 .2 h -.1 l -.3 .2 s -.1 0 -.1 .1 l -.3 .2 s -.1 0 -.1 .1 l -.3 .2 s -.1 0 -.1 .1 l -.3 .2 l -.1 .1 c -.1 .1 -.2 .1 -.2 .2 l -.1 .1 l -.2 .2 l -.1 .1 c -.1 .1 -.1 .2 -.2 .2 l -.1 .1 c -.1 .1 -.1 .2 -.2 .2 l -.1 .1 c -.1 .1 -.1 .2 -.2 .2 l -.1 .1 c -.1 .1 -.1 .2 -.2 .2 l -.1 .1 c -.1 .1 -.1 .2 -.2 .2 l -.1 .1 l -.1 .3 s 0 .1 -.1 .1 l -.1 .3 s 0 .1 -.1 .1 l -.1 .3 s 0 .1 -.1 .1 l -.1 .3 s 0 .1 -.1 .1 c .4 .3 .4 .4 .4 .4 v .1 l -.1 .3 v .1 c 0 .1 0 .2 -.1 .3 v 3.1 c 0 .1 0 .2 .1 .3 v .1 l .1 .3 v .1 l .1 .3 s 0 .1 .1 .1 l .1 .3 s 0 .1 .1 .1 l .1 .3 s 0 .1 .1 .1 l .2 .3 s 0 .1 .1 .1 l .2 .3 s 0 .1 .1 .1 l .2 .3 l .1 .1 l .3 .3 l .3 .3 h .1 c 1 .9 2 1.6 4 2.2 v -.2 C 23 37.3 26.5 50 26.7 63 c -.6 0 -.7 .4 -1.7 .5 h -.5 c -.1 0 -.3 0 -.5 .1 c -.1 0 -.3 0 -.4 .1 l -.4 .1 h -.1 l -.4 .1 h -.1 l -.3 .1 h -.1 l -.3 .1 s -.1 0 -.1 .1 l -.3 .1 l -.2 .1 c -.1 0 -.2 .1 -.2 .1 l -.2 .1 l -.2 .1 c -.1 0 -.2 .1 -.2 .1 l -.2 .1 l -.4 .3 c -.1 .1 -.2 .2 -.3 .2 l -.4 .4 l -.1 .1 c -.1 .2 -.3 .4 -.4 .5 l -.2 .3 l -.3 .6 l -.1 .3 v .3 c 0 .5 .2 .9 .9 1.2 c .2 3.7 3.9 2 5.6 .8 l .1 -.1 c .2 -.2 .5 -.3 .6 -.3 h .1 l .2 -.1 c .1 0 .1 0 .2 -.1 c .2 -.1 .4 -.1 .5 -.2 c .1 0 .1 -.1 .1 -.2 l .1 -.1 c .1 -.2 .2 -.6 .2 -1.2 l .1 -1.3 v 1.8 c -.5 13.1 -4 30.7 3.3 42.5 c 1.3 2.1 2.9 3.9 4.7 5.4 h -.5 c -.2 .2 -.5 .4 -.8 .6 l -.9 .6 l -.3 .2 l -.6 .4 l -.9 .7 l -1.1 1 c -.2 .2 -.3 .4 -.4 .5 l -.4 .6 l -.2 .3 c -.1 .2 -.2 .4 -.2 .6 l -.1 .3 c -.2 .8 0 1.7 .6 2.7 l .4 .4 h .2 c .1 0 .2 0 .4 .1 c .2 .4 1.2 2.5 3.9 .9 c 2.8 -1.5 4.7 -4.6 8.1 -5.1 l -.5 -.6 c 5.9 2.8 12.8 4 19 4.2 c 8.7 .3 18.6 -.9 26.5 -5.2 c 2.2 .7 3.9 3.9 5.8 5.4 l .1 .1 l .1 .1 l .1 .1 l .1 .1 s .1 0 .1 .1 c 0 0 .1 0 .1 .1 c 0 0 .1 0 .1 .1 h 2.1 s .1 0 .1 -.1 h .1 s .1 0 .1 -.1 h .1 s .1 0 .1 -.1 c 0 0 .1 0 .1 -.1 l .1 -.1 s .1 0 .1 -.1 l .1 -.1 h .1 l .2 -.2 l .2 -.1 h .1 l .1 -.1 h .1 l .1 -.1 l .1 -.1 l .1 -.1 l .1 -.1 l .1 -.1 l .1 -.1 l .1 -.1 v -.1 s 0 -.1 .1 -.1 v -.1 s 0 -.1 .1 -.1 v -.1 s 0 -.1 .1 -.1 v -1.4 s -.3 0 -.3 -.1 l -.3 -.1 v -.1 l .3 -.1 s .2 0 .2 -.1 l .1 -.1 v -2.1 s 0 -.1 -.1 -.1 v -.1 s 0 -.1 -.1 -.1 v -.1 s 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 c 0 0 0 -.1 -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 v -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 l -.1 -.1 c 2 -1.9 3.8 -4.2 5.1 -6.9 c 5.9 -11.8 4.9 -26.2 4.1 -39.2 h .1 c .1 0 .2 .1 .2 .1 h .3 s .1 0 .1 .1 h .1 s .1 0 .1 .1 l .2 .1 c 1.7 1.2 5.4 2.9 5.6 -.8 c 1.6 .6 -.3 -1.8 -1.3 -2.5 z M 36 23 C 32.8 7 58.4 4 59.3 19.6 c .8 13 -20 16.3 -23.3 3.4 z m 36.1 15 c -1.3 1.4 -2.7 1.2 -4.1 .7 c 0 1.9 .4 3.9 .1 5.9 c -.5 .9 -1.5 1 -2.3 1.4 c -1.2 -.2 -2.1 -.9 -2.6 -2 l -.2 -.1 c -3.9 5.2 -6.3 -1.1 -5.2 -5 c -1.2 .1 -2.2 -.2 -3 -1.5 c -1.4 -2.6 .7 -5.8 3.4 -6.3 c .7 3 8.7 2.6 10.1 -.2 c 3.1 1.5 6.5 4.3 3.8 7.1 z m -7 -17.5 c -.9 -13.8 20.3 -17.5 23.4 -4 c 3.5 15 -20.8 18.9 -23.4 4 z M 41.7 17 c -1.9 0 -3.5 1.7 -3.5 3.8 c 0 2.1 1.6 3.8 3.5 3.8 s 3.5 -1.7 3.5 -3.8 c 0 -2.1 -1.5 -3.8 -3.5 -3.8 z m 1.6 5.7 c -.5 0 -.8 -.4 -.8 -1 c 0 -.5 .4 -1 .8 -1 c .5 0 .8 .4 .8 1 c 0 .5 -.3 1 -.8 1 z m 27.8 -6.6 c -1.9 0 -3.4 1.7 -3.4 3.8 c 0 2.1 1.5 3.8 3.4 3.8 s 3.4 -1.7 3.4 -3.8 c 0 -2.1 -1.5 -3.8 -3.4 -3.8 z m 1.6 5.6 c -.4 0 -.8 -.4 -.8 -1 c 0 -.5 .4 -1 .8 -1 s .8 .4 .8 1 s -.4 1 -.8 1 z" + readonly property string dart: "M 86.6 25 l 3 .1 c 1.1 .1 2.2 .3 3.4 .5 l -2.5 -7.4 L 75.7 3.5 c -3.4 -3.4 -8 -4.4 -10.4 -2.3 L 29.2 25.1 l 57.4 -.1 z m 6.1 3.6 c -1.2 -.2 -2.3 -.4 -3.3 -.5 l -2.9 -.1 l -56 .1 l 78.6 78.6 l 6.1 -13.8 l -22.5 -64.3 z M 28.9 92.2 l 64.3 22.7 l 13.8 -6.1 l -78.6 -78.6 v 56.1 l .1 2.7 c 0 .9 .1 2 .4 3.2 z M 106.9 34.3 c -2.6 -2.6 -7 -5.1 -11.3 -6.5 L 118.4 93 l -6.9 15.7 l 15.8 -5.2 V 54.8 l -20.4 -20.5 z m -13.5 83.8 l -65 -22.9 c 1.4 4.3 3.8 8.7 6.5 11.4 l 21.3 21.2 l 47.6 .1 l 5.3 -16.7 l -15.7 6.9 z m -67.9 -29 l -.1 -2.7 V 28.9 L 1.7 65.1 C -.4 67.3 .7 72 4 75.5 l 14.7 14.8 l 7.3 2.6 c -.3 -1.3 -.5 -2.5 -.5 -3.8 z" + readonly property string php: "M 64 30.332 C 28.654 30.332 0 45.407 0 64 s 28.654 33.668 64 33.668 c 35.345 0 64 -15.075 64 -33.668 S 99.346 30.332 64 30.332 z m -5.982 9.81 h 7.293 v .003 l -1.745 8.968 h 6.496 c 4.087 0 6.908 .714 8.458 2.139 c 1.553 1.427 2.017 3.737 1.398 6.93 l -3.053 15.7 h -7.408 l 2.902 -14.929 c .33 -1.698 .208 -2.855 -.365 -3.473 c -.573 -.617 -1.793 -.925 -3.658 -.925 h -5.828 L 58.752 73.88 h -7.291 l 6.557 -33.738 z M 26.73 49.114 h 14.133 c 4.252 0 7.355 1.116 9.305 3.348 c 1.95 2.232 2.536 5.346 1.758 9.346 c -.32 1.649 -.863 3.154 -1.625 4.52 c -.763 1.364 -1.76 2.613 -2.99 3.745 c -1.468 1.373 -3.098 2.353 -4.891 2.936 c -1.794 .585 -4.08 .875 -6.858 .875 h -6.294 l -1.745 8.97 h -7.35 l 6.557 -33.74 z m 57.366 0 h 14.13 c 4.252 0 7.353 1.116 9.303 3.348 h .002 c 1.95 2.232 2.538 5.346 1.76 9.346 c -.32 1.649 -.861 3.154 -1.623 4.52 c -.763 1.364 -1.76 2.613 -2.992 3.745 c -1.467 1.373 -3.098 2.353 -4.893 2.936 c -1.794 .585 -4.077 .875 -6.855 .875 h -6.295 l -1.744 8.97 h -7.35 l 6.557 -33.74 z m -51.051 5.325 l -2.742 14.12 h 4.468 c 2.963 0 5.172 -.556 6.622 -1.673 c 1.45 -1.116 2.428 -2.981 2.937 -5.592 c .485 -2.507 .264 -4.279 -.666 -5.309 c -.93 -1.032 -2.79 -1.547 -5.584 -1.547 h -5.035 z m 57.363 0 l -2.744 14.12 h 4.47 c 2.965 0 5.17 -.556 6.622 -1.673 c 1.449 -1.116 2.427 -2.981 2.935 -5.592 c .487 -2.507 .266 -4.279 -.664 -5.309 c -.93 -1.032 -2.792 -1.547 -5.584 -1.547 h -5.035 z" + readonly property string ruby: "m 35.971 111.33 l 81.958 11.188 c -9.374 -15.606 -18.507 -30.813 -27.713 -46.144 Z m 89.71 -86.383 L 93.513 73.339 c -.462 .696 -1.061 1.248 -.41 2.321 c 8.016 13.237 15.969 26.513 23.942 39.777 c 1.258 2.095 2.53 4.182 4.157 6.192 l 4.834 -96.58 z M 16.252 66.22 c .375 .355 1.311 .562 1.747 .347 c 7.689 -3.779 15.427 -7.474 22.948 -11.564 c 2.453 -1.333 4.339 -3.723 6.452 -5.661 c 6.997 -6.417 13.983 -12.847 20.966 -19.278 c .427 -.395 .933 -.777 1.188 -1.275 c 2.508 -4.902 4.973 -9.829 7.525 -14.898 c -3.043 -1.144 -5.928 -2.263 -8.849 -3.281 c -.396 -.138 -1.02 .136 -1.449 .375 c -6.761 3.777 -13.649 7.353 -20.195 11.472 c -3.275 2.061 -5.943 5.098 -8.843 7.743 c -4.674 4.266 -9.342 8.542 -13.948 12.882 a 24.011 24.011 0 0 0 -3.288 3.854 c -3.15 4.587 -6.206 9.24 -9.402 14.025 c 1.786 1.847 3.41 3.613 5.148 5.259 z m 28.102 -6.271 l -11.556 48.823 l 54.3 -34.987 z m 76.631 -34.846 l -46.15 7.71 l 15.662 38.096 z M 44.996 56.644 l 41.892 13.6 c -5.25 -12.79 -10.32 -25.133 -15.495 -37.737 Z M 16.831 75.643 L 2.169 110.691 l 27.925 -.825 Z m 13.593 26.096 l .346 -.076 c 3.353 -13.941 6.754 -27.786 10.177 -42.272 L 18.544 71.035 c 3.819 9.926 7.891 20.397 11.88 30.704 z m 84.927 -78.897 c -4.459 -1.181 -8.918 -2.366 -13.379 -3.539 c -6.412 -1.686 -12.829 -3.351 -19.237 -5.052 c -.801 -.213 -1.38 -.352 -1.851 .613 c -2.265 4.64 -4.6 9.245 -6.901 13.868 c -.071 .143 -.056 .328 -.111 .687 l 41.47 -6.285 z M 89.482 12.288 l 36.343 10.054 l -6.005 -17.11 l -30.285 6.715 Z M 33.505 114.007 c -4.501 -.519 -9.122 -.042 -13.687 .037 c -3.75 .063 -7.5 .206 -11.25 .323 c -.386 .012 -.771 .09 -1.156 .506 c 31.003 2.866 62.005 5.732 93.007 8.6 l .063 -.414 l -29.815 -4.07 c -12.384 -1.691 -24.747 -3.551 -37.162 -4.982 Z M 2.782 99.994 c 3.995 -9.27 7.973 -18.546 11.984 -27.809 c .401 -.929 .37 -1.56 -.415 -2.308 c -1.678 -1.597 -3.237 -3.318 -5.071 -5.226 c -2.479 12.24 -4.897 24.177 -7.317 36.113 l .271 .127 c .185 -.297 .411 -.578 .548 -.897 z m 78.74 -90.153 c 6.737 -1.738 13.572 -3.097 20.367 -4.613 c .44 -.099 .87 -.244 1.303 -.368 l -.067 -.332 l -29.194 3.928 c 2.741 1.197 4.853 2.091 7.591 1.385 z" + readonly property string scala: "M 25.411 110.572 V 95.077 l 11.842 -.474 c 12.315 -.473 21.45 -1.488 34.847 -3.789 c 15.225 -2.639 30.246 -7.375 31.803 -10.082 c .406 -.677 .676 4.534 .676 14.616 v 15.698 l -1.76 1.353 c -1.894 1.489 -9.202 3.993 -17.524 6.09 C 72.303 121.737 40.568 126 29.742 126 h -4.33 z M 25.411 71.327 V 55.83 l 11.842 -.406 c 13.127 -.541 23.344 -1.691 36.877 -4.195 c 15.157 -2.842 28.96 -7.443 29.976 -9.947 c .203 -.473 .406 6.09 .406 14.616 c .067 13.533 -.068 15.698 -1.083 16.78 c -2.368 2.64 -20.638 7.376 -39.449 10.286 c -11.435 1.76 -30.381 3.79 -35.66 3.79 h -2.909 z M 25.411 32.352 V 17.195 l 2.098 -.406 c 1.15 -.203 3.992 -.406 6.293 -.406 c 11.367 0 38.366 -3.722 51.628 -7.105 c 9.27 -2.436 15.698 -4.872 17.931 -6.902 c 1.15 -1.015 1.218 -.406 1.218 14.48 c 0 14.548 -.067 15.63 -1.285 16.714 c -1.827 1.691 -14.345 5.548 -24.09 7.51 c -15.765 3.113 -41.951 6.429 -50.883 6.429 h -2.91 z" + readonly property string haskell: "M 0 110.2 L 30.1 65 L 0 19.9 h 22.6 L 52.7 65 l -30.1 45.1 H 0 z M 30.1 110.2 L 60.2 65 L 30.1 19.9 h 22.6 l 60.2 90.3 H 90.4 L 71.5 81.9 l -18.8 28.2 H 30.1 z M 102.9 83.8 l -10 -15.1 H 128 v 15.1 h -25.1 z M 87.8 61.3 l -10 -15.1 H 128 v 15.1 H 87.8 z" + readonly property string elixir: "M 90.1 80 c .8 -.5 1.7 -1 2.5 -1.5 c 5.6 -4.8 3.6 -13.5 -5.9 -25.7 c .2 6.1 1.2 12.4 2.2 19.1 c .4 2.6 .8 5.3 1.2 8.1 z M 36 103.2 c 3.2 13.7 11.3 21.1 20.5 24.2 c 5.1 .9 10.2 .7 15.3 -.4 c .8 -.4 1.5 -.9 2.2 -1.4 c 2 -1.3 3.7 -2.8 5.3 -4.3 c -6.5 -2.8 -13.1 -8.2 -19.3 -14.6 c -4.8 -.3 -9.5 -.8 -13.6 -1.4 c -4 -.4 -7.4 -1.1 -10.4 -2.1 z m -1.4 -64.5 l -.2 1.4 c -1.4 7.9 -1.5 15 -.4 21.2 c 1.1 -7.4 2.6 -15 4.7 -22.3 c .3 -1.1 .6 -2.3 1 -3.5 h .1 c 1.6 -5 3.4 -9.8 5.5 -14.3 c -4 5.3 -7.5 11.2 -10.7 17.5 z M 33.9 114 c 3.5 4.4 7.5 7.6 11.9 9.9 c -5.6 -4.6 -10 -11.5 -12 -21.4 c -3.8 -1.4 -6.8 -3.3 -9.1 -5.7 c 1.6 6.4 4.6 12.3 9.2 17.2 z m -.6 -47 c -1.8 -5.9 -2.4 -12.7 -1.8 -20.4 c -1.1 2.3 -2.1 4.8 -3.2 7.3 c -4.5 13.1 -6.5 26.7 -4.6 38.7 c 2.1 3.2 5.2 5.8 9.6 7.6 c -1.4 -7.7 -1.5 -19.9 0 -33.2 z m 2.2 33.8 c 0 .1 0 .1 0 0 c 3.2 1.2 6.8 2 11.1 2.5 c 3.5 .6 7.3 1 11.3 1.3 c -8.3 -9.1 -15.6 -19.8 -20.3 -28.6 c -1.1 -1.6 -2 -3.3 -2.8 -5 c -1.1 12.2 -.8 23.1 .7 29.8 z m 51.4 -28.7 c -1.1 -7.5 -2.3 -15.1 -2.3 -22.3 C 71.9 38 59.6 25.4 60.1 5.1 c -.4 .3 -.8 .6 -1.1 .9 c -2.2 1.9 -4.4 4 -6.5 6.3 c -4.5 6.4 -8.1 14.6 -10.9 23.5 c -1.6 11.6 9.6 34.3 24.5 51.7 c 7.2 -.3 14.8 -2.5 22.1 -6.5 c -.4 -2.9 -.8 -5.9 -1.3 -8.9 z m 10 39.7 c -2.6 -.5 -5.2 -1.4 -7.8 -2.6 c -1.3 4.1 -3.4 7.9 -6.4 11.4 c 1.1 .3 2.1 .6 3.1 .7 c 4.2 -2.5 8 -5.8 11.1 -9.5 z M 62.1 3.6 C 61 23.1 72.2 35.5 84.4 46.8 C 73.7 32.6 67.5 24.1 67.5 .3 c -1.7 .9 -3.5 2 -5.4 3.3 z m 21.1 102.2 c -5.3 .8 -10.8 1.1 -16.2 1.1 c -1.4 0 -2.9 0 -4.3 -.1 c 5.8 5.8 12 10.6 18 13 c 3.2 -3.5 5.3 -7.4 6.6 -11.6 c -1.4 -.7 -2.8 -1.5 -4.1 -2.4 z m -2.4 -1.6 c -2 -1.4 -3.8 -2.9 -5.5 -4.4 c -3.5 -3.1 -6.9 -6.5 -10.1 -10.2 c -7.1 .1 -13.7 -1.6 -19.1 -5.1 c -.8 -.5 -1.6 -1.1 -2.3 -1.7 c 4.6 7.3 10.5 15.3 16.9 22 c 6.5 .3 13.5 .2 20.1 -.6 z m 9.6 .2 c -.2 1 -.4 1.9 -.7 2.9 c 2.8 1.4 5.7 2.4 8.6 2.7 c 2.5 -3.4 4.5 -7.2 5.7 -11.3 c -3.7 2.5 -8.4 4.4 -13.6 5.7 z M 47.2 82.8 c 4.7 3 10.3 4.6 16.3 4.8 c -12.3 -14.6 -21.8 -32.4 -23.7 -45 c -2.3 8.9 -3.1 14.4 -4.4 23.6 v .2 c .9 2.3 2.1 5.1 3.7 8.1 c 2.1 3.4 4.8 6.2 8.1 8.3 z m 41.3 .4 c -6.8 3.7 -13.9 5.8 -20.7 6.3 c 2.9 3.2 5.8 6.2 8.8 8.8 c 2.1 1.9 4.5 3.8 7.1 5.5 c 1.7 -.3 3.3 -.6 4.9 -.9 c 1 -6.3 .7 -12.9 -.1 -19.7 z m 2.2 19.1 c 5.6 -1.5 10.5 -3.6 14.1 -6.6 c 1.5 -6.9 1 -14.4 -2.3 -22.4 c -2.9 -4.9 -5.7 -9.2 -8.3 -13 c 4.8 8.8 4.7 15.2 -.4 19.6 l -.1 .1 c -1.1 .7 -2.2 1.4 -3.4 2.1 c .9 6.9 1.3 13.8 .4 20.2 z" + readonly property string erlang: "M 18.2 24.1 L 1 24 v 80 h 19.7 v -.1 C 11 93.6 5.2 79.2 5.3 62.1 C 5.2 47 10 33.9 18.2 24.1 z m 92.9 79.8 z M 127 24 h -16.4 c 6.2 9 9.6 19.3 9.1 32.1 c .1 1.2 .1 1.9 0 4.9 H 46.3 c 0 22 7.7 38.3 27.3 38.4 c 13.5 -.1 23.2 -10.1 29.9 -20.9 l 19 9.5 c -3.4 6.1 -7.2 11 -11.4 16 H 127 V 24 z m -16.5 .1 z m -45.4 1.5 c -9 0 -16.8 7.4 -17.6 16.4 H 81 c -.3 -9 -6.8 -16.4 -15.9 -16.4 z" + readonly property string clojure: "M 64 0 C 28.712 0 0 28.6 0 63.751 c 0 35.155 28.712 63.753 64 63.753 s 64 -28.598 64 -63.753 C 128 28.6 99.288 0 64 0 M 61.659 64.898 a 265.825 265.825 0 0 0 -1.867 4.12 c -2.322 5.241 -4.894 11.62 -5.834 15.706 c -.337 1.455 -.546 3.258 -.542 5.258 c 0 .79 .043 1.622 .11 2.469 a 30.74 30.74 0 0 0 10.533 1.87 a 30.796 30.796 0 0 0 9.642 -1.566 a 18.09 18.09 0 0 1 -2.011 -2.12 c -4.11 -5.221 -6.403 -12.872 -10.031 -25.737 M 46.485 38.96 c -7.85 5.51 -12.986 14.6 -13.005 24.9 c .019 10.145 5.001 19.116 12.653 24.65 c 1.877 -7.789 6.582 -14.92 13.637 -29.214 a 114.691 114.691 0 0 0 -1.43 -3.72 c -1.955 -4.884 -4.776 -10.556 -7.294 -13.124 c -1.283 -1.342 -2.84 -2.502 -4.561 -3.492 M 90.697 98.798 c -4.05 -.506 -7.392 -1.116 -10.317 -2.144 a 36.708 36.708 0 0 1 -16.32 3.807 c -20.293 0 -36.742 -16.383 -36.745 -36.602 c 0 -10.97 4.852 -20.805 12.528 -27.512 c -2.053 -.495 -4.194 -.783 -6.38 -.779 c -10.782 .101 -22.162 6.044 -26.9 22.095 c -.443 2.337 -.337 4.103 -.337 6.197 c 0 31.818 25.895 57.613 57.835 57.613 c 19.561 0 36.841 -9.682 47.305 -24.489 c -5.66 1.405 -11.103 2.077 -15.763 2.091 c -1.747 0 -3.387 -.093 -4.906 -.277 M 79.829 87.634 c .357 .176 1.167 .464 2.293 .783 c 7.579 -5.542 12.504 -14.469 12.523 -24.558 h -.003 c -.028 -16.82 -13.693 -30.43 -30.582 -30.462 a 30.765 30.765 0 0 0 -9.602 1.554 c 6.21 7.05 9.196 17.127 12.084 28.148 l .005 .013 c .005 .009 .924 3.06 2.501 7.11 c 1.566 4.042 3.797 9.048 6.23 12.696 c 1.597 2.444 3.354 4.2 4.551 4.716 M 17.057 30.311 c 5.463 -3.408 11.04 -4.637 15.908 -4.593 c 6.722 .02 12.008 2.096 14.544 3.516 c .612 .352 1.194 .73 1.764 1.12 a 36.714 36.714 0 0 1 14.786 -3.096 c 20.295 .003 36.747 16.386 36.75 36.601 c -.003 10.192 -4.188 19.408 -10.934 26.044 a 45.3 45.3 0 0 0 5.225 .29 c 6.406 .004 13.329 -1.404 18.52 -5.753 c 3.384 -2.84 6.22 -6.998 7.792 -13.233 c .307 -2.408 .484 -4.856 .484 -7.347 c 0 -31.817 -25.892 -57.614 -57.835 -57.614 c -19.372 0 -36.508 9.5 -47.004 24.065 z" + readonly property string r: "M 64 14.6465 v .002 c -35.346 0 -64 19.1902 -64 42.8632 c 0 20.764 22.0464 38.0766 51.3164 42.0176 v -12.83 c -15.55 -4.89 -26.166 -14.6943 -26.166 -25.9923 c 0 -16.183 21.7795 -29.3027 48.6465 -29.3027 c 26.866 0 46.6914 8.9748 46.6914 29.3027 c 0 10.486 -5.2715 17.9507 -14.0645 22.7207 c 1.204 .908 2.2184 2.073 2.9024 3.42 l .3886 .6543 C 121.0248 79.772 128 69.1888 128 57.5098 c 0 -23.672 -28.654 -42.8633 -64 -42.8633 z M 52.7363 41.2637 v 72.084 l 21.834 -.0098 l -.0039 -28.2188 h 5.8613 c 1.199 0 1.7167 .3481 2.9297 1.3301 c 1.454 1.177 3.8164 5.2383 3.8164 5.2383 l 11.5371 21.666 l 24.6739 -.0097 l -15.2657 -25.7403 a 8.388 8.388 0 0 0 -1.4199 -2.041 c -.974 -1.036 -2.3255 -1.8227 -3.1055 -2.2188 c -2.249 -1.1375 -6.12 -2.3076 -6.123 -2.3085 c 0 0 19.08 -1.4151 19.08 -20.4141 c 0 -18.999 -19.9706 -19.3574 -19.9706 -19.3574 H 52.7363 z m 22.0176 15.627 l 13.2188 .0077 s 6.123 -.3302 6.123 6.0098 c 0 6.216 -6.123 6.2344 -6.123 6.2344 l -13.2247 .0039 l .006 -12.2559 z m 9.3457 32.6366 c -2.612 .257 -5.3213 .411 -8.1133 .463 l .002 9.6288 a 88.362 88.362 0 0 0 12.4746 -2.4902 l -.502 -.9414 c -.68 -1.268 -1.3472 -2.5426 -2.0332 -3.8066 a 41.01 41.01 0 0 0 -1.828 -2.8516 v -.002 z" + readonly property string perl: "M 53.343 127.515 c -13.912 -2.458 -25.845 -8.812 -35.707 -19.004 C 9.252 99.845 3.48 88.926 .851 76.764 C -.284 71.51 -.284 56.477 .85 51.222 c 1.776 -8.219 5.228 -16.388 9.927 -23.509 c 3.112 -4.71 12.227 -13.825 16.938 -16.937 c 7.121 -4.698 15.292 -8.15 23.511 -9.925 c 5.256 -1.135 20.29 -1.135 25.546 0 c 12.809 2.769 23.454 8.553 32.638 17.736 c 9.188 9.187 14.969 19.827 17.738 32.635 c 1.135 5.255 1.135 20.287 0 25.542 c -2.769 12.808 -8.55 23.452 -17.738 32.635 c -9.043 9.042 -19.55 14.81 -32.146 17.652 c -4.469 1.005 -19.24 1.295 -23.922 .464 z m 11.565 -12.772 c 0 -4.194 -.06 -4.496 -.908 -4.496 c -.84 0 -.904 .29 -.868 3.899 c .04 4.262 .34 5.574 1.207 5.284 c .404 -.134 .57 -1.494 .57 -4.687 z m -6.758 1.445 c 1.196 -1.194 1.543 -1.917 1.543 -3.209 c 0 -1.315 -.162 -1.634 -.763 -1.517 c -.416 .08 -.92 .759 -1.114 1.505 c -.198 .751 -1.002 1.906 -1.785 2.572 c -1.417 1.194 -1.47 2.191 -.121 2.191 c .384 0 1.393 -.694 2.24 -1.542 z m 14.945 1.05 c .166 -.271 -.339 -1.037 -1.126 -1.699 c -.783 -.666 -1.587 -1.821 -1.784 -2.572 c -.194 -.746 -.699 -1.425 -1.115 -1.505 c -.601 -.117 -.763 .202 -.763 1.517 c 0 2.608 3.747 5.942 4.788 4.259 z m -20.66 -8.146 c 0 -.262 -.635 -.823 -1.41 -1.247 c -5.058 -2.769 -10.984 -7.177 -14.282 -10.612 c -6.435 -6.704 -9.33 -13.385 -9.402 -21.676 c -.044 -5.542 .67 -8.432 3.367 -13.607 c 2.608 -5 5.631 -8.779 13.947 -17.42 c 9.29 -9.648 11.429 -12.195 13.043 -15.53 c 1.147 -2.369 1.296 -3.232 1.458 -8.238 c .197 -6.216 -.182 -10.506 -.929 -10.506 c -.339 0 -.403 1.614 -.21 5.235 c .622 11.593 -1.53 15.19 -14.892 24.88 c -9.2 6.677 -13.422 10.302 -16.612 14.261 c -4.517 5.615 -6.52 10.471 -7.02 17.054 c -1.207 15.868 8.85 29.628 26.591 36.385 c 3.916 1.49 6.35 1.881 6.35 1.021 z m 30.696 -1.287 c 6.1 -2.539 10.738 -5.611 15.11 -10.007 c 6.665 -6.7 9.442 -12.965 9.858 -22.24 c .363 -8.134 -1.405 -13.515 -6.439 -19.61 c -3.447 -4.173 -7.161 -7.16 -17.173 -13.812 c -13.47 -8.95 -16.632 -12.513 -16.632 -18.746 c 0 -1.659 .299 -4.004 .662 -5.219 c .622 -2.066 .606 -3.491 -.02 -1.857 c -.593 1.546 -1.946 .836 -2.676 -1.408 l -.703 -2.156 l .267 2.043 c .94 7.241 1.061 10.272 .641 16.614 c -.56 8.565 -1.614 14.426 -4.505 25.074 c -2.87 10.572 -3.387 14.402 -3.031 22.475 c .298 6.826 1.255 11.932 3.475 18.592 c 2.06 6.188 2.443 6.656 6.23 7.625 c 2.086 .533 4.06 1.433 5.63 2.567 c 1.474 1.066 2.952 1.76 3.78 1.776 c .75 .012 3.237 -.759 5.526 -1.711 z m -1.369 -3.076 c -.565 -.565 -.302 -1.046 1.91 -3.492 c 6.972 -7.697 10.096 -15.645 10.185 -25.906 c .06 -6.995 -1.482 -11.625 -6.197 -18.592 c -2.135 -3.152 -9.636 -11.011 -13.265 -13.893 c -2.664 -2.115 -5.397 -5.72 -5.886 -7.762 c -.496 -2.067 .888 -1.522 2.495 .985 c .787 1.227 2.495 3.027 3.79 4 c 1.297 .977 5.132 3.834 8.523 6.357 c 11.666 8.67 16.858 16.065 18.024 25.668 c .679 5.558 -.395 11.302 -3.108 16.634 c -2.81 5.526 -7.937 11.545 -12.325 14.479 c -2.7 1.8 -3.552 2.115 -4.146 1.522 z m -22.836 .585 c .133 -.343 -1.034 -2.535 -2.592 -4.872 c -4.13 -6.192 -5.926 -9.61 -7.602 -14.454 c -1.413 -4.09 -1.49 -4.646 -1.501 -10.887 c -.016 -9.433 1.005 -12.424 8.49 -24.848 c 7.056 -11.722 8.013 -16.259 7.217 -34.286 c -.286 -6.462 -.61 -11.839 -.718 -11.948 c -.747 -.746 -.904 1.167 -.63 7.665 c .549 12.941 -.287 20.15 -3.016 26.064 c -1.857 4.024 -3.936 7.076 -9.53 14.002 c -7.788 9.64 -9.984 14.75 -9.944 23.125 c .029 5.744 .808 9.276 3.129 14.188 c 2.51 5.316 7.133 10.685 12.926 15.012 c 2.669 1.99 3.391 2.228 3.77 1.239 z" + readonly property string zig: "M 38.484 23.843 l -15.06 18.405 l -7.529 -11.712 z M 38.484 23.843 l -10.876 9.203 l -4.183 9.202 h -5.02 v 42.667 h 7.53 l -9.203 4.183 l -6.693 14.222 H 0 V 23.843 z M 25.935 84.915 L 10.039 103.32 l -6.693 -9.202 z M 46.85 23.843 l 5.02 11.713 l -20.916 6.692 z M 46.85 23.843 h 46.013 v 18.405 H 30.954 L 46.85 32.21 z M 97.046 84.915 L 81.15 103.32 l -5.856 -10.875 z M 97.046 84.915 l -13.386 7.53 l -2.51 10.875 H 35.137 V 84.915 z M 125.49 5.438 L 43.503 103.32 L 2.51 122.562 l 81.987 -98.719 z M 117.96 23.843 l -.836 15.06 l -15.059 4.182 z M 128 23.843 v 79.477 H 88.68 l 11.712 -10.039 l 4.183 -8.366 h 5.02 v -41.83 h -7.53 l 8.366 -7.53 l 7.53 -11.712 z M 104.575 84.915 l 4.183 12.55 l -20.078 5.855 z" + readonly property string nim: "M 64.508 20.135 v .004 s -4.905 3.873 -9.906 7.726 a 70.222 70.222 0 0 0 -20.696 2.975 c -5.028 -3.2 -9.463 -6.715 -9.463 -6.715 s -3.78 6.505 -6.158 10.322 a 52.032 52.032 0 0 0 -10.22 6.776 C 4.393 39.773 .136 37.989 0 37.943 c 4.86 9.806 8.129 19.622 17.016 25.524 c 14.171 -22.35 79.908 -20.294 94.35 -.13 c 9.32 -4.881 12.977 -15.335 16.634 -25.026 c -.402 .132 -5.398 1.804 -8.635 3.039 a 52.521 52.521 0 0 0 -9.08 -6.903 c -2.455 -4.498 -6.03 -10.574 -6.03 -10.574 s -4.237 3.151 -9.142 6.584 a 97.211 97.211 0 0 0 -21.398 -2.342 c -4.572 -3.776 -9.207 -7.98 -9.207 -7.98 z m 59.373 38.468 a 61.161 61.161 0 0 1 -21.028 17.686 a 55.85 55.85 0 0 1 -13.636 3.625 L 64.232 66.97 L 39.09 79.654 a 71.675 71.675 0 0 1 -13.637 -3.492 a 64.347 64.347 0 0 1 -20.424 -17.4 l 11.674 28.275 c 20.274 26.743 72.042 28.603 94.63 .516 c 5.338 -12.037 12.548 -28.95 12.548 -28.95 z" + readonly property string ocaml: "M 65.004 115.355 c -.461 -.894 -1.004 -2.796 -1.356 -3.601 c -.378 -.711 -1.46 -2.692 -1.984 -3.332 c -1.164 -1.332 -1.437 -1.438 -1.809 -3.23 c -.628 -3.067 -2.148 -8.462 -4.042 -12.227 c -1.004 -2 -2.626 -3.606 -4.067 -5.07 c -1.246 -1.247 -4.121 -3.31 -4.668 -3.227 c -4.766 .894 -6.226 5.586 -8.457 9.27 c -1.27 2.062 -2.516 3.769 -3.52 5.937 c -.898 1.98 -.812 4.23 -2.331 5.938 a 15.44 15.44 0 0 0 -3.333 5.855 c -.195 .453 -.546 4.957 -1.003 6.016 l 7.02 -.438 c 6.585 .461 4.687 2.961 14.858 2.438 l 16.098 -.54 a 24.864 24.864 0 0 0 -1.433 -3.792 z M 111.793 8.254 H 16.207 C 7.312 8.23 .086 15.457 .086 24.352 v 35.105 c 2.352 -.812 5.578 -5.75 6.668 -6.934 c 1.789 -2.062 2.16 -4.77 3.059 -6.378 c 2.062 -3.793 2.433 -6.477 7.101 -6.477 c 2.164 0 3.063 .516 4.5 2.516 c .996 1.332 2.79 3.957 3.602 5.668 c 1.004 1.98 2.523 4.582 3.254 5.125 c .515 .351 .972 .722 1.433 .894 c .707 .27 1.356 -.27 1.902 -.629 c .622 -.539 .895 -1.52 1.52 -2.953 c .895 -2.086 1.813 -4.418 2.332 -5.312 c .914 -1.461 1.273 -3.254 2.25 -4.067 c 1.461 -1.246 3.441 -1.355 3.957 -1.437 c 2.98 -.625 4.336 1.437 5.777 2.707 c .973 .894 2.243 2.605 3.246 4.851 c .708 1.793 1.606 3.52 2.067 4.5 c .351 .98 1.266 2.606 1.789 4.582 c .543 1.711 1.809 3.067 2.352 3.961 c 0 0 .812 2.164 5.476 4.145 a 34.992 34.992 0 0 0 4.336 1.52 c 2.066 .734 4.047 .644 6.563 .374 c 1.789 0 2.793 -2.625 3.601 -4.683 c .438 -1.254 .98 -4.774 1.25 -5.758 c .27 -.996 -.437 -1.707 .192 -2.625 c .722 -.977 1.164 -1.082 1.519 -2.332 c .914 -2.793 5.957 -2.875 8.832 -2.875 c 2.414 0 2.063 2.332 6.125 1.52 c 2.336 -.434 4.586 .273 7.023 .995 c 2.063 .543 4.043 1.168 5.204 2.524 c .73 .898 2.629 5.312 .73 5.476 c .164 .188 .36 .645 .625 .817 c -.46 1.707 -2.25 .46 -3.332 .27 c -1.355 -.27 -2.332 0 -3.684 .624 c -2.335 .996 -5.668 .918 -7.726 2.625 c -1.715 1.438 -1.715 4.582 -2.543 6.371 c 0 0 -2.254 5.696 -6.996 9.192 c -1.278 .914 -3.715 3.058 -8.918 3.871 c -2.356 .355 -4.586 .355 -7.024 .27 c -1.164 -.079 -2.332 -.079 -3.52 -.079 c -.706 0 -3.062 -.109 -2.96 .164 l -.27 .645 c .024 .29 .063 .602 .164 .895 c .102 .515 .102 .976 .192 1.437 c 0 .98 -.086 2.063 0 3.066 c .082 2.063 .894 3.957 1.004 6.102 c .078 2.355 1.246 4.875 2.414 6.77 c .46 .707 1.086 .789 1.355 1.71 c .352 .98 0 2.141 .188 3.227 c .625 4.227 1.875 8.73 3.773 12.61 v .078 c 2.332 -.352 4.77 -1.247 7.836 -1.684 c 5.664 -.832 13.5 -.461 18.54 -.914 c 12.796 -1.168 19.706 5.226 31.148 2.601 V 24.336 c -.063 -8.895 -7.293 -16.102 -16.207 -16.102 z M 64.086 83.855 c 0 -.187 0 -.187 0 0 z m -34.457 14.75 c .894 -1.98 1.433 -4.125 2.144 -6.101 c .73 -1.899 1.813 -4.61 3.684 -5.582 c -.246 -.274 -3.957 -.375 -4.934 -.461 c -1.082 -.086 -2.171 -.273 -3.25 -.438 a 135.241 135.241 0 0 1 -6.125 -1.265 c -1.168 -.274 -5.21 -1.715 -6.02 -2.067 c -2.085 -.894 -3.421 -3.52 -4.96 -3.246 c -.977 .188 -1.98 .54 -2.605 1.54 c -.543 .812 -.731 2.242 -1.083 3.226 c -.437 1.086 -1.168 2.164 -1.707 3.25 c -1.277 1.875 -3.332 3.582 -4.23 5.484 c -.191 .457 -.27 .895 -.457 1.356 v 21.683 c 1.082 .188 2.16 .371 3.328 .73 c 8.996 2.438 11.164 2.606 19.98 1.63 l .813 -.11 c .625 -1.437 1.188 -6.207 1.629 -7.644 c .352 -1.164 .812 -2.063 .996 -3.14 c .164 -1.09 0 -2.173 -.102 -3.15 c -.171 -2.628 1.895 -3.519 2.899 -5.69 z m 0 0" + readonly property string fsharp: "M 0 64.5 L 60.7 3.8 v 30.4 L 30.4 64.5 l 30.4 30.4 v 30.4 L 0 64.5 z m 39.1 0 l 21.7 -21.7 v 43.4 L 39.1 64.5 z m 88.9 0 L 65.1 3.8 v 30.4 l 30.4 30.4 l -30.4 30.3 v 30.4 L 128 64.5 z" + readonly property string visualbasic: "M 64 0 A 64 64 0 0 0 0 64 a 64 64 0 0 0 64 64 a 64 64 0 0 0 64 -64 A 64 64 0 0 0 64 0 z m -3.76 38.7 l 6.34 .1 L 48 89.202 h -6.58 L 23.14 38.8 h 6.579 l 14 40 a 23.74 23.74 0 0 1 1.02 4.46 h .141 a 22 22 0 0 1 1.119 -4.56 z m 13.6 .1 h 14.34 A 15.68 15.68 0 0 1 98.54 42 a 10.34 10.34 0 0 1 3.84 8.34 a 12.26 12.26 0 0 1 -2.38 7.44 a 12.52 12.52 0 0 1 -6.4 4.501 v .139 a 12.82 12.82 0 0 1 8.16 3.84 a 11.84 11.84 0 0 1 3.06 8.461 a 13.18 13.18 0 0 1 -4.64 10.48 a 17.28 17.28 0 0 1 -11.7 4 H 73.84 z m 12.7 5.26 l -6.7 .06 V 60.4 h 5.999 a 11.48 11.48 0 0 0 7.58 -2.4 a 8.14 8.14 0 0 0 2.781 -6.6 c 0 -4.893 -3.22 -7.34 -9.66 -7.34 z m -6.7 21.641 v 18.14 h 8 a 12 12 0 0 0 8 -2.46 a 8.42 8.42 0 0 0 2.86 -6.74 c 0 -5.947 -4.053 -8.92 -12.16 -8.92 z" + readonly property string fortran: "M 18.969 0 C 13.25 0 0 11 0 18.66 v 90.453 c 0 5.692 11.21 18.903 18.781 18.903 l 90.551 -.032 c 6.738 -.004 18.688 -9.683 18.688 -18.601 V 18.84 c 0 -6.078 -10.61 -18.832 -18.43 -18.832 L 18.969 0 z m -1.395 13.66 h 93.367 v 41.711 l -10.992 -.164 c -.101 -.098 -.402 -3.047 -.605 -5.758 C 98.19 36.7 95.328 29.363 89.809 26.5 c -2.914 -1.504 -7.457 -1.95 -22.02 -1.953 l -13.57 .004 v 31.273 h 2.41 c 4.066 -.05 9.234 -1.004 10.941 -2.058 c 2.211 -1.356 4.067 -5.27 4.72 -9.989 c .491 -3.445 .87 -6.023 .87 -6.023 h 10.676 v 49.691 H 72.793 v -1.957 c 0 -3.21 -1.508 -10.691 -2.563 -12.949 c -1.656 -3.465 -4.464 -4.668 -12.449 -5.422 l -3.664 -.351 l .203 16.113 c .149 15.308 .25 16.164 1.203 17.469 c 1.207 1.605 2.512 1.906 10.493 2.507 l 5.355 .258 l -.035 10.938 H 17.574 v -10.942 l 4.922 -.304 c 9.988 -.653 9.887 -.602 10.39 -8.43 c .45 -7.43 -.116 -65.598 -.452 -66.762 c -.551 -1.922 -2.618 -3.027 -8.786 -3.023 l -6.074 -.04 V 13.66 z" + readonly property string crystal: "m 127.806 81.328 l -46.325 45.987 c -.185 .185 -.464 .276 -.65 .185 l -63.283 -16.863 c -.279 -.095 -.464 -.28 -.464 -.464 L .035 47.317 c -.09 -.275 0 -.46 .186 -.645 L 46.55 .685 c .184 -.185 .463 -.276 .649 -.185 l 63.28 16.863 c .278 .095 .463 .28 .463 .464 L 127.9 80.682 c .185 .275 .09 .46 -.094 .645 z M 65.726 31.28 L 3.557 47.778 c -.095 0 -.185 .185 -.095 .28 l 45.495 45.156 c .09 .095 .28 .095 .28 -.09 l 16.675 -61.748 c .095 0 -.09 -.185 -.184 -.094 z m 0 0" + readonly property string gleam: "M 56.61 .014 c -.41 .021 -.818 .067 -1.221 .138 v .002 c -3.23 .57 -6.182 2.714 -7.405 6.178 l -9.523 26.98 a 9.599 9.599 0 0 1 -5.553 5.745 L 6.244 49.504 C -.598 52.184 -1.923 61.596 3.91 66.05 l 22.746 17.37 a 9.582 9.582 0 0 1 3.75 7.047 l 1.696 28.553 c .434 7.326 8.978 11.493 15.033 7.323 v -.002 l 23.58 -16.244 a 9.602 9.602 0 0 1 7.87 -1.389 l 27.714 7.2 c 7.116 1.848 13.72 -4.99 11.623 -12.022 l -8.17 -27.412 a 9.579 9.579 0 0 1 1.113 -7.905 l 15.432 -24.103 c 3.958 -6.182 -.503 -14.573 -7.85 -14.752 l -28.63 -.695 a 9.596 9.596 0 0 1 -7.182 -3.499 L 64.459 3.426 C 62.418 .944 59.477 -.137 56.609 .014 z m 30.435 55.513 a 5.082 5.082 0 1 1 .222 10.161 a 5.082 5.082 0 0 1 -.222 -10.16 z M 46.1 62.747 a 5.083 5.083 0 0 1 .992 10.085 a 5.082 5.082 0 1 1 -.992 -10.086 z m 26.283 7.382 a 2.59 2.59 0 0 1 1.812 .799 a 2.59 2.59 0 0 1 .713 1.847 a 6.723 6.723 0 0 1 -2.088 4.721 h -.002 a 6.733 6.733 0 0 1 -2.22 1.41 h -.002 a 6.75 6.75 0 0 1 -2.59 .451 h -.002 a 6.74 6.74 0 0 1 -2.567 -.576 h -.001 a 6.758 6.758 0 0 1 -3.563 -3.736 a 2.594 2.594 0 0 1 .63 -2.805 a 2.59 2.59 0 0 1 1.847 -.715 a 2.595 2.595 0 0 1 2.35 1.655 c .075 .192 .186 .368 .328 .517 v .002 c .143 .149 .312 .266 .5 .35 v .002 h .002 a 1.576 1.576 0 0 0 1.201 .03 l .002 -.003 a 1.552 1.552 0 0 0 .868 -.828 h .002 v -.002 c .083 -.188 .127 -.389 .132 -.594 a 2.593 2.593 0 0 1 1.653 -2.353 c .317 -.122 .654 -.18 .994 -.172 z" + readonly property string julia: "M 0.0 94.2 a 29.1 29.1 0 1 0 58.2 0 a 29.1 29.1 0 1 0 -58.2 0 Z M 69.80000000000001 94.2 a 29.1 29.1 0 1 0 58.2 0 a 29.1 29.1 0 1 0 -58.2 0 Z M 34.9 33.8 a 29.1 29.1 0 1 0 58.2 0 a 29.1 29.1 0 1 0 -58.2 0 Z" + readonly property string objectivec: "M 63.877 125.392 c -32.671 0 -60.37 -27.594 -60.627 -60.469 a 59.94 59.94 0 0 1 17.506 -42.759 a 60.939 60.939 0 0 1 43.279 -18.36 a 60.081 60.081 0 0 1 42.647 17.71 a 60.145 60.145 0 0 1 18.157 42.522 c .151 33.604 -26.864 61.021 -60.469 61.363 h -.493 z m .19 -118.406 a 57.774 57.774 0 0 0 -41.01 17.427 a 56.775 56.775 0 0 0 -16.63 40.484 c .236 31.159 26.495 57.286 57.43 57.286 h .414 c 31.863 -.29 57.504 -26.266 57.385 -58.128 a 56.97 56.97 0 0 0 -17.217 -40.273 A 56.7 56.7 0 0 0 64.068 6.986 z M 16.89 82.383 V 46.865 h 8.64 v 3.183 h -4.583 v 29.218 h 4.584 v 3.183 l -8.642 -.066 z M 46.213 64.272 c 0 6.478 -3.933 10.167 -9.26 10.167 s -8.877 -4.156 -8.877 -9.831 c 0 -5.939 3.722 -10.121 9.167 -10.121 s 8.97 4.36 8.97 9.785 z m -14.415 .29 c 0 3.932 1.973 7.05 5.36 7.05 s 5.333 -3.183 5.333 -7.195 c 0 -3.643 -1.796 -7.083 -5.334 -7.083 s -5.392 3.328 -5.392 7.307 l .033 -.08 z M 49.205 55.158 c 1.69 -.29 3.407 -.434 5.123 -.428 a 9.17 9.17 0 0 1 5.537 1.223 a 4.062 4.062 0 0 1 2.006 3.61 a 4.48 4.48 0 0 1 -3.183 4.183 c 2.269 .46 3.9 2.46 3.9 4.775 a 5.016 5.016 0 0 1 -1.861 3.978 c -1.368 1.21 -3.643 1.796 -7.162 1.796 a 33.966 33.966 0 0 1 -4.327 -.257 l -.033 -18.88 z m 3.499 7.622 h 1.795 c 2.433 0 3.801 -1.145 3.801 -2.782 c 0 -1.638 -1.368 -2.644 -3.61 -2.644 a 9.779 9.779 0 0 0 -2.006 .145 l .02 5.28 z m 0 8.878 c .618 .065 1.243 .092 1.86 .078 c 2.263 0 4.262 -.861 4.262 -3.182 s -1.94 -3.183 -4.373 -3.183 h -1.75 v 6.287 z M 69.54 54.901 h 3.517 v 12.554 c 0 5.334 -2.577 7.116 -6.365 7.116 a 9.313 9.313 0 0 1 -2.973 -.507 l .428 -2.834 c .703 .224 1.44 .335 2.183 .349 c 2.006 0 3.183 -.921 3.183 -4.262 l .026 -12.416 z M 83.067 65.357 v 2.434 h -7.32 v -2.434 h 7.32 z M 100.158 73.63 c -1.585 .632 -3.281 .921 -4.978 .862 c -6.129 0 -9.851 -3.834 -9.851 -9.707 c -.283 -5.353 3.827 -9.923 9.18 -10.206 c .375 -.02 .757 -.02 1.131 .006 a 11.112 11.112 0 0 1 4.775 .862 l -.783 2.801 a 9.476 9.476 0 0 0 -3.788 -.75 c -3.932 0 -6.76 2.467 -6.76 7.116 c 0 4.235 2.499 6.971 6.734 6.971 a 9.806 9.806 0 0 0 3.834 -.717 l .506 2.762 z M 111.2 46.766 v 35.61 h -8.641 v -3.182 h 4.583 V 49.949 h -4.583 v -3.183 h 8.64 z" + readonly property string vala: "m 62.959 32.051 c -7.551 0 -14.777 2.4271 -21.676 7.2832 c -3.0202 2.1912 -5.4913 4.7082 -7.416 7.5508 c -1.8951 2.813 -2.8438 5.745 -2.8438 8.7949 c 0 1.6581 0.20849 3.0486 0.62305 4.1738 c 1.214 3.1091 4.2474 4.6641 9.1035 4.6641 c 0 -0.17764 -0.072563 -0.38417 -0.2207 -0.62109 c -0.68102 -1.2437 -1.0215 -3.0951 -1.0215 -5.5527 c 1e-6 -5.0633 1.1839 -9.2979 3.5527 -12.703 c 2.3984 -3.4052 5.7302 -5.907 9.9941 -7.5059 l 1.4648 62.137 h 13.102 l 25.273 -67.777 h -6.3516 l -19.143 55.697 l -0.80078 -55.963 c -1.1844 -0.11892 -2.3969 -0.17774 -3.6406 -0.17774 z" + readonly property string groovy: "M 57.27 43.147 c -6.273 10.408 -6.633 10.955 -7.504 11.382 c -.78 .383 -.97 .407 -1.287 .164 c -1.296 -.996 -3.031 -.705 -4.248 .712 l -.676 .787 l -.143 -.843 c -.223 -1.318 -.299 -1.505 -.842 -2.092 c -.506 -.545 -.508 -.564 -.214 -2.21 c .364 -2.04 .385 -3.53 .071 -5.01 c -.613 -2.894 -2.139 -4.224 -4.845 -4.224 c -2.341 0 -5.13 1.864 -8.696 5.81 c -2.148 2.378 -5.401 6.847 -6.1 8.382 l -.272 .597 l -11.193 -.141 c -6.967 -.088 -11.117 -.066 -10.99 .059 c .111 .11 4.892 1.949 10.624 4.087 l 10.423 3.887 l .389 .909 c .463 1.081 1.665 2.462 2.696 3.099 l .742 .459 l -.866 .388 c -.644 .288 -.984 .63 -1.325 1.331 c -.673 1.385 -.451 2.176 1.102 3.925 c .698 .787 1.662 2.185 2.141 3.107 c .58 1.114 1.099 1.815 1.548 2.088 c .724 .442 2.059 .544 2.691 .206 c .713 -.382 4.905 -1.438 4.762 -1.201 c -.077 .129 -2.522 3.971 -5.433 8.537 c -2.911 4.566 -5.247 8.347 -5.192 8.403 c .056 .055 8.933 -3.328 19.727 -7.52 l 19.626 -7.62 L 83.553 88.2 c 10.762 4.178 19.648 7.569 19.747 7.536 c .098 -.033 -1.301 -2.376 -3.109 -5.207 l -3.288 -5.147 l 1.135 -.228 c 2.552 -.512 5.431 -2.527 6.98 -4.884 c 2.26 -3.438 2.587 -7.399 1.084 -13.136 c -.302 -1.151 -.499 -2.142 -.438 -2.202 c .06 -.06 4.99 -1.933 10.956 -4.161 c 5.966 -2.229 10.931 -4.135 11.034 -4.236 c .104 -.102 -5.137 -.129 -11.888 -.059 c -10.269 .106 -12.088 .08 -12.169 -.176 a 12.474 12.474 0 0 1 -.215 -.853 c -.141 -.65 -1.085 -1.654 -1.816 -1.933 c -.282 -.108 -1.21 -.21 -2.062 -.227 c -1.377 -.029 -1.631 .031 -2.298 .54 c -.413 .315 -.811 .765 -.886 1 c -.181 .571 -.402 .537 -.751 -.114 c -.812 -1.518 -3.259 -1.842 -4.504 -.596 l -.629 .628 l -1.245 -.617 c -1.536 -.761 -3.42 -.983 -4.504 -.53 c -.53 .221 -.906 .255 -1.279 .114 c -.888 -.337 -2.307 -.065 -2.969 .569 l -.595 .57 l -.976 -.659 c -.537 -.362 -1.246 -.753 -1.576 -.869 c -.496 -.174 -1.676 -2.002 -6.881 -10.66 c -3.455 -5.747 -6.342 -10.45 -6.416 -10.45 c -.074 0 -3.1 4.92 -6.725 10.934 m -18.682 1.788 c 1.109 .776 1.382 2.983 .769 6.212 c -.671 3.539 -1.702 5.813 -3.553 7.838 c -1.213 1.327 -2.574 2.061 -3.858 2.081 c -2.946 .044 -3.694 -2.859 -1.755 -6.813 c .706 -1.438 2.499 -3.906 2.839 -3.906 c .167 0 .677 2.003 .677 2.66 c 0 .224 -.403 1.073 -.895 1.888 c -.717 1.187 -.894 1.681 -.894 2.494 c 0 3.113 2.902 2.729 4.473 -.593 c 1.586 -3.353 2.474 -9.821 1.496 -10.905 c -.522 -.579 -.832 -.566 -2.253 .096 c -2.246 1.045 -6.923 6.488 -8.534 9.931 c -1.583 3.384 -1.245 6.914 .844 8.801 c 2.073 1.872 5.366 1.519 7.788 -.835 c 1.85 -1.799 3.249 -4.447 3.827 -7.244 c .348 -1.68 .86 -1.854 1.037 -.352 c .069 .58 .605 2.529 1.192 4.33 c 1.451 4.455 1.732 5.652 1.73 7.359 c -.003 1.889 -.619 3.24 -2.111 4.629 c -1.427 1.328 -3.429 2.232 -7.167 3.237 c -1.643 .441 -3.359 .933 -3.813 1.093 c -1.106 .389 -1.178 .376 -1.416 -.248 c -.352 -.926 -1.71 -2.943 -2.608 -3.872 l -.86 -.891 l .867 -.233 c 2.86 -.77 7.084 -2.305 9.12 -3.315 c 2.446 -1.212 4.011 -2.464 4.718 -3.773 c .561 -1.039 .526 -3.601 -.067 -4.871 l -.447 -.96 l -.249 .64 c -1.385 3.563 -4.003 6.201 -7.143 7.198 c -1.894 .602 -4.369 .435 -5.898 -.396 c -2.676 -1.457 -3.167 -4.382 -1.37 -8.167 c 2.085 -4.395 8.022 -11.31 10.979 -12.787 c 1.541 -.77 1.846 -.81 2.535 -.326 M 76.54 56.23 c 2.405 1.199 3.94 3.79 3.97 6.703 c .023 2.183 -.502 3.639 -1.768 4.905 c -1.196 1.196 -2.454 1.619 -4.202 1.414 c -2.996 -.352 -4.711 -2.078 -4.944 -4.974 c -.24 -2.992 1.276 -7.128 2.998 -8.178 c .997 -.608 2.57 -.556 3.946 .13 m -10.644 .499 c .364 .259 .951 1.082 1.348 1.887 c .66 1.341 .703 1.564 .703 3.676 c 0 2.11 -.044 2.337 -.7 3.671 c -1.305 2.652 -3.664 4.17 -6.158 3.963 c -1.429 -.118 -2.156 -.616 -2.814 -1.929 c -.925 -1.843 -.586 -5.413 .797 -8.384 c .669 -1.438 2.511 -3.436 3.269 -3.545 c 1.007 -.146 2.921 .21 3.555 .661 m 34.495 -.459 c .127 .086 .641 2.029 1.142 4.317 c .501 2.288 1.255 5.456 1.677 7.04 c .663 2.49 .768 3.212 .776 5.333 c .012 3.058 -.363 4.391 -1.75 6.233 c -2.407 3.196 -5.96 4.412 -11.737 4.015 c -1.324 -.091 -2.474 -.231 -2.554 -.312 c -.08 -.08 .164 -.387 .542 -.683 c 1.093 -.856 1.787 -2.241 1.914 -3.821 c .061 -.758 .152 -1.379 .203 -1.379 c .051 0 1.069 .25 2.264 .554 c 4.753 1.213 7.851 .145 9.344 -3.22 c .605 -1.362 .733 -3.696 .329 -5.974 l -.302 -1.706 l -.03 1.28 c -.016 .704 -.165 1.917 -.33 2.697 c -.566 2.669 -1.612 4.023 -3.109 4.023 c -1.706 0 -2.664 -1.871 -3.303 -6.451 c -1.025 -7.345 -1.366 -8.804 -2.365 -10.122 c -.598 -.788 -.617 -.913 -.22 -1.456 c .275 -.376 .33 -.332 .825 .653 c .292 .581 .938 2.303 1.435 3.828 c 1.344 4.122 1.929 5.129 3.276 5.641 c .736 .28 1.854 -.172 2.367 -.954 c 1.145 -1.748 .933 -4.101 -.659 -7.306 a 257.362 257.362 0 0 1 -1.102 -2.233 c -.094 -.206 1.063 -.203 1.367 .003 m -28.051 .662 c -.669 .94 -.895 1.708 -.902 3.063 c -.016 3.327 3.216 5.348 6.296 3.937 c 2.416 -1.105 2.693 -2.965 .811 -5.44 c -1.151 -1.514 -3.444 -2.585 -4.197 -1.96 c -.55 .456 -.358 1.016 .631 1.842 c 1.139 .953 1.313 1.319 .811 1.7 c -1.219 .925 -3.257 -.351 -3.257 -2.04 c 0 -.489 .1 -1.074 .221 -1.301 c .33 -.617 .082 -.498 -.414 .199 m 15.435 -.311 c 2.714 1.134 4.77 5.84 4.535 10.381 c -.124 2.407 -.676 3.92 -1.784 4.893 c -1.496 1.313 -3.662 .444 -4.89 -1.962 c -.569 -1.116 -1.076 -2.882 -1.915 -6.678 c -.634 -2.868 -1.195 -4.517 -1.799 -5.285 c -.37 -.469 -.378 -.561 -.086 -.977 c .177 -.253 .404 -.46 .504 -.46 c .327 0 1.038 1.771 1.587 3.957 c .622 2.477 .971 3.384 1.619 4.208 c 1.021 1.297 3.093 1.66 4.109 .718 c 1.286 -1.192 1.601 -2.947 .905 -5.042 c -.621 -1.87 -2.84 -3.752 -3.567 -3.025 c -.164 .163 -.129 .383 .122 .766 c .193 .295 .352 .728 .352 .961 c 0 .597 -.448 1.511 -.741 1.511 c -.302 0 -1.067 -.698 -1.514 -1.379 c -.402 -.614 -.456 -2.13 -.091 -2.569 c .336 -.405 1.705 -.414 2.654 -.018 m -24.628 .552 c -.616 .616 -.543 .892 .559 2.118 c .975 1.084 .982 1.102 .587 1.541 c -.22 .244 -.612 .525 -.872 .624 c -.597 .229 -1.706 -.142 -1.986 -.664 c -.307 -.576 -.253 -2.245 .101 -3.092 c .447 -1.07 .002 -.909 -.746 .27 c -1.182 1.863 -1.369 4.031 -.494 5.717 c 1.345 2.592 4.606 2.303 6.705 -.594 c .983 -1.357 .782 -3.022 -.579 -4.806 c -1.096 -1.437 -2.481 -1.908 -3.275 -1.114 M 47.202 58.78 c .263 .803 .591 1.496 .73 1.539 c .329 .104 1.765 -1.2 2.519 -2.289 c .326 -.471 .674 -.857 .773 -.857 c .098 0 .329 .292 .514 .649 c .185 .358 .592 .747 .905 .866 c .515 .196 .65 .129 1.429 -.703 l .861 -.918 l .066 .679 c .125 1.288 -1.479 3.458 -2.902 3.928 c -.653 .216 -.862 .172 -2.008 -.419 c -.966 -.498 -1.374 -.609 -1.667 -.453 c -.572 .307 -.684 1.227 -.319 2.622 c .412 1.574 .843 2.425 1.669 3.296 c .813 .857 1.703 .909 3.244 .191 l 1.07 -.498 l .011 2.413 l .01 2.413 l -.818 .327 c -1.031 .413 -2.359 .419 -3.139 .016 c -.894 -.462 -1.441 -1.869 -2.055 -5.284 c -.711 -3.954 -1.229 -5.526 -2.137 -6.489 c -.398 -.422 -.661 -.876 -.583 -1.008 c .339 -.578 1.198 -1.632 1.27 -1.56 c .044 .043 .295 .736 .557 1.539 M 93.653 73.6 c 0 .117 -.149 .213 -.332 .213 c -.183 0 -.274 -.096 -.201 -.213 c .073 -.117 .222 -.213 .333 -.213 c .11 0 .2 .096 .2 .213" + readonly property string racket: "M 64 .5 a 64.386 64.386 0 0 0 -21.89 3.81 c 25.982 13.525 60.352 57.646 72.246 98.881 C 122.904 92.4 128 78.792 128 64 C 128 28.93 99.347 .5 64 .5 Z M 21.828 16.236 C 8.45 27.876 0 44.961 0 64 c 0 16.01 5.975 30.637 15.83 41.804 c 8.688 -25.896 25.018 -50.855 39.006 -64.799 c -9.985 -10.672 -21.164 -19.146 -33.008 -24.769 Z m 44.8 39.17 C 52.77 70.22 38.963 96.155 33.968 120.085 c 8.957 4.728 19.176 7.415 30.031 7.415 c 11.161 0 21.65 -2.835 30.786 -7.817 c -5.268 -24.14 -15.204 -46.245 -28.157 -64.277 Z m 0 0" + readonly property string haxe: "M 0 0 h 32.3 L 64 16 L 96.3 0 H 128 v 32.6 L 111.8 64 L 128 95.8 V 128 H 95.2 L 64 112.1 L 32.6 128 H 0 V 94.7 L 15.7 64 L 0 31.8 z" + readonly property string purescript: "M 47.397 90.262 h 43.211 l -9.84 -9.14 h -43.21 Z m 43.211 -32.035 h -43.21 l -9.84 9.136 h 43.21 z M 47.397 44.465 h 43.207 l -9.836 -9.14 H 37.561 Z M 33.01 53.512 l -6.5 -6.465 L 2.409 71.012 a 4.535 4.535 0 0 0 -1.34 3.23 c 0 1.223 .477 2.371 1.34 3.23 l 24.101 23.97 l 6.5 -6.462 l -20.847 -20.738 z m 92.747 -5.395 l -24.094 -23.972 l -6.496 6.46 l 20.84 20.739 l -20.84 20.738 l 6.496 6.461 l 24.094 -23.961 a 4.558 4.558 0 0 0 1.347 -3.238 a 4.56 4.56 0 0 0 -1.347 -3.227" + readonly property string delphi: "M 64 0 C 28.656 0 0 28.656 0 64 s 28.656 64 64 64 s 64 -28.656 64 -64 S 99.344 0 64 0 z m 1.512 10.883 c 1.153 .012 2.309 .11 3.449 .234 c 4.332 .453 8.578 1.32 12.672 2.832 c 2.304 .855 4.586 1.778 6.734 2.98 a 110.26 110.26 0 0 1 4.414 2.614 c 1.106 .699 2.164 1.477 3.2 2.262 a 40.911 40.911 0 0 1 2.746 2.258 c .554 .512 .765 1.226 .687 1.988 c -.109 .969 -.562 1.722 -1.457 2.176 c -.074 .035 -.219 .015 -.289 -.032 c -.407 -.32 -.785 -.675 -1.207 -.984 c -1.262 -.918 -2.512 -1.856 -3.813 -2.723 a 46.178 46.178 0 0 0 -3.765 -2.281 c -1.308 -.699 -2.672 -1.301 -4.028 -1.906 a 31.344 31.344 0 0 0 -2.668 -1.074 a 93.06 93.06 0 0 0 -4.523 -1.422 c -1.903 -.543 -3.852 -.876 -5.809 -1.149 c -1.597 -.22 -3.199 -.406 -4.812 -.43 c -.938 -.016 -1.875 -.078 -2.813 -.046 a 90.33 90.33 0 0 0 -4.48 .25 c -1.113 .093 -2.21 .27 -3.32 .425 a 31.675 31.675 0 0 0 -4.543 .977 c -1.253 .367 -2.503 .75 -3.75 1.156 a 27.383 27.383 0 0 0 -2.25 .848 c -1.312 .57 -2.628 1.141 -3.91 1.777 a 54.43 54.43 0 0 0 -3.75 2.051 c -.964 .573 -1.883 1.242 -2.801 1.89 a 38.79 38.79 0 0 0 -2.102 1.579 a 62.68 62.68 0 0 0 -5.125 4.605 c -.613 .618 -1.168 1.282 -1.738 1.938 c -.586 .679 -1.18 1.355 -1.734 2.062 a 44.754 44.754 0 0 0 -1.766 2.407 a 53.159 53.159 0 0 0 -1.719 2.687 c -.516 .875 -.999 1.78 -1.469 2.68 c -.468 .902 -.937 1.793 -1.347 2.718 c -.418 .938 -.78 1.907 -1.153 2.875 c -.293 .77 -.585 1.543 -.835 2.333 c -.3 .968 -.606 1.937 -.82 2.925 c -.39 1.782 -.793 3.575 -1.063 5.38 c -.242 1.632 -.349 3.28 -.442 4.925 c -.07 1.289 -.05 2.582 -.02 3.871 c .032 1.203 .083 2.41 .208 3.61 c .148 1.425 .388 2.843 .586 4.288 c -1.144 .008 -2.129 -.335 -2.73 -1.363 c -.228 -.379 -.334 -.844 -.419 -1.285 a 39.903 39.903 0 0 1 -.449 -2.828 a 66.652 66.652 0 0 1 -.371 -3.734 c -.083 -1.282 -.145 -2.579 -.113 -3.86 c .039 -1.942 .14 -3.88 .383 -5.812 a 62.118 62.118 0 0 1 1.124 -6.344 a 44.585 44.585 0 0 1 1.93 -6.14 c .414 -1.05 .81 -2.114 1.29 -3.141 a 87.998 87.998 0 0 1 2.206 -4.317 a 37.195 37.195 0 0 1 1.844 -2.988 c .773 -1.144 1.598 -2.242 2.426 -3.344 a 40.166 40.166 0 0 1 1.687 -2.105 c .563 -.653 1.16 -1.262 1.75 -1.883 a 53.5 53.5 0 0 1 1.399 -1.418 c .552 -.531 1.101 -1.07 1.68 -1.57 a 89.046 89.046 0 0 1 2.902 -2.43 c .773 -.605 1.594 -1.148 2.406 -1.707 c .688 -.473 1.367 -.957 2.082 -1.387 c .91 -.55 1.843 -1.062 2.773 -1.57 c .8 -.438 1.602 -.859 2.418 -1.254 c .79 -.375 1.59 -.707 2.387 -1.043 c .62 -.269 1.235 -.547 1.871 -.758 c 1.317 -.449 2.646 -.875 3.973 -1.281 c .735 -.227 1.469 -.445 2.219 -.602 c 1.593 -.324 3.187 -.648 4.797 -.898 a 39.693 39.693 0 0 1 3.96 -.418 a 82.44 82.44 0 0 1 5.2 -.074 z m -.06 6.498 c .833 .011 1.665 .041 2.493 .1 c 1.825 .132 3.637 .374 5.45 .632 c .968 .137 1.925 .38 2.878 .625 c 1.532 .4 3.08 .783 4.582 1.274 c 1.313 .434 2.596 .976 3.856 1.539 c 1.384 .617 2.74 1.304 4.09 1.992 c .575 .289 1.118 .633 1.66 .977 c 1.04 .66 2.09 1.312 3.09 2.03 c 1.005 .712 1.957 1.5 2.93 2.255 c .081 .062 .151 .14 .261 .238 c -.875 .582 -1.722 1.125 -2.562 1.7 c -.891 .612 -1.774 1.25 -2.668 1.874 c -.555 .395 -1.117 .778 -1.676 1.172 c -.688 .492 -1.375 .992 -2.063 1.484 c -.793 .58 -1.597 1.141 -2.39 1.715 c -.59 .426 -1.164 .866 -1.746 1.301 c -.688 .503 -1.387 1.004 -2.063 1.531 c -1.16 .891 -2.363 .736 -3.426 .016 c -2.89 -1.952 -6.03 -3.399 -9.367 -4.422 a 31.389 31.389 0 0 0 -7.086 -1.309 c -1.015 -.066 -2.03 -.097 -3.047 -.136 a 22.752 22.752 0 0 0 -4.222 .25 c -1.106 .168 -2.207 .363 -3.309 .562 c -1.449 .26 -2.847 .707 -4.23 1.2 a 30.428 30.428 0 0 0 -2.813 1.164 a 37.46 37.46 0 0 0 -3.054 1.562 c -.95 .543 -1.844 1.188 -2.75 1.805 a 20.467 20.467 0 0 0 -1.668 1.238 c -.788 .665 -1.55 1.363 -2.32 2.055 c -1.376 1.25 -2.587 2.663 -3.712 4.144 c -.703 .934 -1.327 1.918 -1.957 2.906 c -1.043 1.625 -1.886 3.363 -2.605 5.153 c -.883 2.19 -1.559 4.441 -1.965 6.773 c -.273 1.602 -.504 3.206 -.52 4.824 c -.01 1.351 -.023 2.7 .02 4.051 c .031 .793 .167 1.585 .277 2.375 c .172 1.343 -.359 2.426 -1.582 2.98 c -.363 .165 -.812 .22 -1.215 .188 c -1.156 -.097 -2.305 -.27 -3.46 -.41 c -.387 -.046 -.782 -.09 -1.176 -.156 c -.086 -.016 -.207 -.133 -.223 -.219 c -.215 -1.328 -.452 -2.652 -.61 -3.984 c -.127 -1.094 -.16 -2.2 -.23 -3.297 c -.035 -.633 -.074 -1.265 -.066 -1.895 a 56.125 56.125 0 0 1 .086 -3.144 a 60.934 60.934 0 0 1 .32 -3.48 a 29.67 29.67 0 0 1 .441 -2.821 c .333 -1.551 .716 -3.086 1.106 -4.617 a 29.343 29.343 0 0 1 1.457 -4.29 c .418 -.991 .793 -2.003 1.25 -2.988 c .48 -1.035 1 -2.054 1.57 -3.046 c .645 -1.122 1.343 -2.207 2.035 -3.297 a 40.938 40.938 0 0 1 1.391 -2.047 c .391 -.546 .824 -1.05 1.242 -1.57 c .383 -.477 .758 -.95 1.157 -1.407 c .428 -.5 .882 -.98 1.335 -1.46 c .657 -.676 1.313 -1.351 1.985 -2.008 a 25.64 25.64 0 0 1 1.437 -1.325 c .922 -.777 1.848 -1.562 2.817 -2.277 a 83.903 83.903 0 0 1 3.718 -2.574 c .981 -.637 1.997 -1.255 3.043 -1.785 c 1.344 -.688 2.735 -1.308 4.122 -1.915 c .843 -.375 1.71 -.71 2.585 -1.011 a 58.079 58.079 0 0 1 3.407 -1.051 c 1.187 -.324 2.383 -.645 3.59 -.863 c 1.484 -.274 2.984 -.46 4.48 -.645 c 1.035 -.124 2.082 -.222 3.117 -.234 c .834 -.008 1.669 -.014 2.502 -.002 z m -6.007 23.412 a 45.502 45.502 0 0 1 4.317 .227 a 28.014 28.014 0 0 1 7.215 1.722 c 1.648 .625 3.25 1.352 4.78 2.238 a 35.797 35.797 0 0 1 4.907 3.458 c 1.11 .925 2.18 1.895 3.11 3 a 85.415 85.415 0 0 1 3.038 3.769 c .302 .398 .43 .93 .594 1.418 a 129.996 129.996 0 0 1 1.114 3.43 c .457 1.457 .885 2.934 1.343 4.394 c .343 1.106 .707 2.199 1.051 3.309 c .387 1.234 .75 2.472 1.129 3.71 c .258 .852 .53 1.696 .8 2.544 c .294 .945 .583 1.894 .876 2.843 c .27 .872 .55 1.74 .82 2.606 c .211 .688 .406 1.39 .625 2.078 c .191 .628 .4 1.26 .598 1.89 c .52 1.653 1.03 3.3 1.558 4.954 c .48 1.535 .97 3.066 1.461 4.605 c .055 .188 .125 .367 .188 .555 c .113 .352 -.082 .605 -.406 .48 a 16.18 16.18 0 0 1 -1.977 -.89 c -.16 -.09 -.203 -.406 -.281 -.625 c -.375 -1.06 -.735 -2.11 -1.098 -3.156 c -.414 -1.184 -.82 -2.364 -1.23 -3.547 c -.407 -1.157 -.821 -2.313 -1.227 -3.473 c -.492 -1.426 -.987 -2.843 -1.48 -4.27 c -.351 -1 -.715 -2.007 -1.07 -3.007 a 61.587 61.587 0 0 0 -.598 -1.618 c -.34 -.855 -.957 -1.52 -1.579 -2.175 c -.53 -.551 -1.062 -1.105 -1.625 -1.625 a 93.16 93.16 0 0 0 -3.222 -2.864 c -.788 -.66 -1.781 -.89 -2.781 -1.046 c -1.071 -.172 -2.145 -.344 -3.215 -.52 c -1.313 -.218 -2.625 -.449 -3.938 -.668 c -1.183 -.203 -2.364 -.401 -3.543 -.602 c -1.825 -.303 -3.644 -.613 -5.469 -.917 c -1.487 -.258 -2.968 -.528 -4.457 -.778 c -1.043 -.172 -2.093 -.398 -3.148 -.472 c -.582 -.04 -1.2 .136 -1.781 .3 c -.805 .22 -1.508 .68 -2.106 1.254 a 5.903 5.903 0 0 0 -1.445 2.137 c -.645 1.633 -.543 3.234 .039 4.844 c .375 1.05 .91 2.007 1.68 2.82 c .758 .793 1.636 1.367 2.738 1.625 c 1.324 .305 2.633 .687 3.938 1.043 c 2.35 .641 4.7 1.285 7.05 1.934 c 1.492 .41 2.989 .829 4.489 1.242 c 1.492 .419 2.984 .833 4.48 1.25 c 1.41 .395 2.555 1.195 3.305 2.449 c .382 .633 .625 1.352 .902 2.039 c .324 .833 .629 1.68 .93 2.523 c .418 1.2 .832 2.402 1.242 3.602 c .344 1.004 .694 2.012 1.031 3.02 c .296 .867 .578 1.734 .871 2.605 c .293 .848 .597 1.691 .887 2.543 c .344 .993 .668 2 1.012 2.992 c .488 1.452 .988 2.904 1.48 4.352 c .06 .157 .121 .312 .156 .469 c .051 .218 -.079 .352 -.28 .367 c -.892 .11 -1.786 .195 -2.68 .289 c -.45 .05 -.9 .125 -1.352 .176 c -.941 .105 -1.887 .2 -2.828 .304 c -1.008 .121 -2.016 .25 -3.02 .364 c -1.289 .156 -2.58 .3 -3.87 .437 c -.21 .023 -.438 .05 -.641 .031 a .453 .453 0 0 1 -.309 -.199 c -.363 -.676 -.688 -1.371 -1.063 -2.039 a 70.039 70.039 0 0 0 -1.773 -3.094 a 64.824 64.824 0 0 0 -2.133 -3.281 a 47.53 47.53 0 0 0 -2.27 -2.988 c -.96 -1.172 -1.95 -2.328 -3.003 -3.422 a 37.598 37.598 0 0 0 -3.168 -2.945 a 28.744 28.744 0 0 0 -3.344 -2.407 c -1.187 -.726 -2.414 -1.413 -3.75 -1.844 c -1.539 -.489 -3.032 -1.117 -4.582 -1.539 c -1.531 -.41 -3.125 -.626 -4.684 -.949 c -1.472 -.302 -2.972 -.293 -4.468 -.379 c -.66 -.039 -1.336 .015 -2.004 .055 a 44.18 44.18 0 0 0 -2.281 .164 c -.445 .043 -.883 .125 -1.32 .203 c -.844 .164 -1.692 .34 -2.614 .52 c -.273 -.848 -.574 -1.699 -.816 -2.563 a 27.57 27.57 0 0 1 -.625 -2.531 c -.188 -.973 -.313 -1.957 -.446 -2.941 c -.23 -1.821 -.242 -3.653 -.113 -5.477 c .175 -2.55 .602 -5.07 1.375 -7.52 c .508 -1.597 1.062 -3.187 1.832 -4.687 c .512 -.992 1.02 -1.992 1.59 -2.957 c .43 -.723 .949 -1.406 1.449 -2.094 a 26.073 26.073 0 0 1 1.191 -1.578 c .375 -.453 .778 -.883 1.188 -1.3 c .687 -.689 1.375 -1.385 2.09 -2.04 c .488 -.45 1.012 -.848 1.535 -1.25 a 33.322 33.322 0 0 1 1.715 -1.238 c .78 -.51 1.586 -.992 2.394 -1.461 c .72 -.414 1.438 -.836 2.188 -1.176 a 64.286 64.286 0 0 1 3.422 -1.363 c .672 -.25 1.35 -.484 2.047 -.637 c 1.312 -.293 2.628 -.562 3.96 -.754 c 1.117 -.164 2.251 -.277 3.383 -.277 z m -2.742 22.162 c .187 .009 .376 .03 .567 .065 c 1.71 .324 3.438 .598 5.156 .898 c 1.212 .214 2.418 .438 3.629 .645 c 1.425 .237 2.859 .457 4.28 .687 c 1.033 .176 2.06 .363 3.083 .54 c 1.195 .198 2.387 .385 3.582 .585 c 1.039 .18 2.07 .387 3.105 .55 c .676 .098 1.332 .224 1.934 .536 c .285 .157 .586 .32 .828 .539 a 93.038 93.038 0 0 1 2.578 2.383 a 56.576 56.576 0 0 1 2.203 2.234 c .563 .594 .927 1.301 1.157 2.09 c .175 .617 .406 1.211 .62 1.816 c .302 .832 .598 1.672 .895 2.508 c .375 1.055 .755 2.114 1.125 3.168 c .336 .95 .657 1.903 .985 2.844 c .312 .887 .632 1.77 .945 2.652 c .336 .97 .664 1.93 1 2.899 l .492 1.426 c -.019 .015 -.04 .03 -.062 .05 c -.938 -.652 -1.887 -1.289 -2.813 -1.953 c -.422 -.305 -.63 -.785 -.816 -1.262 c -.394 -.972 -.794 -1.957 -1.184 -2.937 c -.379 -.938 -.75 -1.886 -1.125 -2.824 c -.367 -.913 -.75 -1.82 -1.117 -2.73 c -.383 -.938 -.73 -1.895 -1.133 -2.825 c -.469 -1.09 -.75 -2.277 -1.492 -3.234 c -.48 -.625 -1 -1.22 -1.54 -1.793 a 14.223 14.223 0 0 0 -2.647 -2.207 c -.75 -.473 -1.586 -.63 -2.438 -.785 c -2.395 -.434 -4.773 -.883 -7.168 -1.313 c -2.433 -.445 -4.863 -.875 -7.293 -1.312 c -2.07 -.383 -4.152 -.73 -6.207 -1.165 c -1.375 -.285 -2.637 .864 -2.762 2.165 c -.125 1.312 .886 2.43 1.992 2.71 c 2.989 .75 5.96 1.563 8.946 2.352 c 3.523 .938 7.055 1.848 10.566 2.813 c 1.168 .324 2.219 .918 3.184 1.668 c 1.172 .906 2.136 2 2.816 3.304 c .457 .871 .813 1.79 1.184 2.695 a 75.718 75.718 0 0 1 1.074 2.844 c .492 1.426 .97 2.863 1.441 4.293 c .29 .875 .579 1.75 .852 2.625 c .395 1.262 .781 2.53 1.168 3.793 c .261 .87 .527 1.746 .781 2.621 c .34 1.125 .656 2.262 .989 3.395 c .25 .875 .52 1.742 .761 2.617 c .371 1.293 .746 2.586 1.09 3.887 c .172 .676 -.352 1.433 -1.062 1.531 c -1.465 .199 -2.923 .375 -4.383 .559 c -.97 .108 -1.936 .222 -2.906 .328 c -1.175 .125 -2.352 .246 -3.524 .37 c -1.484 .157 -2.968 .325 -4.453 .481 c -.628 .063 -1.26 .164 -1.89 .149 c -1.384 -.016 -2.615 -.453 -3.673 -1.387 c -.766 -.676 -1.187 -1.563 -1.68 -2.406 c -.417 -.72 -.812 -1.442 -1.257 -2.145 c -.687 -1.086 -1.375 -2.168 -2.117 -3.21 c -.711 -.993 -1.492 -1.946 -2.258 -2.895 a 26.025 26.025 0 0 0 -1.219 -1.418 c -1.254 -1.395 -2.578 -2.731 -4.031 -3.926 c -1.11 -.906 -2.234 -1.8 -3.418 -2.598 c -2.875 -1.945 -5.98 -3.414 -9.348 -4.277 c -2.329 -.598 -4.683 -1.035 -7.11 -1 c -1.218 .027 -2.417 .121 -3.608 .375 c -.852 .184 -1.645 .113 -2.352 -.473 c -.387 -.327 -.555 -.77 -.773 -1.187 c -.095 -.176 -.153 -.363 -.235 -.582 c .375 -.106 .758 -.227 1.145 -.313 c .402 -.093 .809 -.176 1.215 -.226 c 1.036 -.117 2.074 -.282 3.109 -.305 c 1.5 -.039 3.008 -.051 4.5 .063 c 1.394 .105 2.789 .324 4.164 .605 a 43.492 43.492 0 0 1 4.32 1.105 c 1.274 .407 2.516 .933 3.75 1.457 c 2.063 .886 3.875 2.168 5.598 3.586 c 1.145 .938 2.23 1.926 3.215 3.028 c 1.199 1.344 2.39 2.691 3.515 4.097 c .801 .997 1.508 2.08 2.235 3.133 c 1.219 1.77 2.292 3.634 3.312 5.524 c .25 .476 .493 .957 .7 1.453 c .343 .812 1.062 1.062 1.816 .972 c 1.019 -.117 2.03 -.25 3.05 -.367 c 1.247 -.145 2.49 -.293 3.731 -.425 c 1.438 -.157 2.875 -.293 4.309 -.458 a 95.975 95.975 0 0 0 3.332 -.425 c .626 -.094 1.074 -.7 1.11 -1.348 c .03 -.574 -.243 -1.062 -.411 -1.582 c -.477 -1.449 -.98 -2.887 -1.477 -4.332 c -.472 -1.398 -.94 -2.801 -1.41 -4.191 c -.496 -1.47 -.976 -2.937 -1.476 -4.407 c -.343 -1 -.699 -1.996 -1.051 -2.996 c -.285 -.832 -.552 -1.668 -.848 -2.492 c -.36 -.988 -.734 -1.977 -1.11 -2.957 c -.198 -.508 -.39 -1.02 -.636 -1.512 c -.574 -1.168 -1.449 -2.062 -2.574 -2.738 c -1.055 -.637 -2.257 -.844 -3.414 -1.168 c -3.405 -.964 -6.816 -1.902 -10.23 -2.844 c -1.46 -.406 -2.93 -.82 -4.395 -1.226 c -1.168 -.325 -2.324 -.689 -3.504 -.95 c -1.375 -.3 -2.309 -1.125 -3.039 -2.269 a 7.07 7.07 0 0 1 -1.074 -3.106 a 4.638 4.638 0 0 1 .636 -2.914 c .571 -.949 1.364 -1.609 2.352 -2.035 a 3.65 3.65 0 0 1 1.621 -.283 z" + readonly property string coffeescript: "M 50.3 29.6 c 11.7 -1 15 -8.5 28.7 -9.8 c 6.7 -.6 11 .8 11.4 3.1 c .4 2.2 -2.9 3.7 -7 4 c -5.6 .6 -8 -1.5 -8.4 -3.4 c -4.1 .4 -4.8 2.2 -4.6 3.5 c .4 2.4 5.5 4.7 14.1 3.9 c 9.8 -.8 13 -4.6 12.2 -8.5 c -1 -5 -8.5 -9.2 -22 -8 c -17.3 1.6 -17.2 9.5 -28.9 10.5 c -4.8 .4 -7.5 -.7 -8 -2.6 c -.3 -1.9 2 -2.8 4.8 -3 c 2.6 -.2 5.7 .2 7.2 1 c 1.1 -.6 1.5 -1.1 1.3 -1.8 c -.4 -1.8 -4 -2.6 -8.5 -2.2 c -8.7 .8 -8.7 4.7 -8.4 6.4 c 1.1 4.7 7.8 7.7 16.1 6.9 z m 58.6 19.8 c -10.8 2.5 -24.6 4.1 -41.2 4.1 c -16.9 0 -30.7 -1.8 -41.5 -4.1 c -9.6 -2.5 -14.8 -5.2 -16.6 -8 c .9 6.3 2.5 12.4 4.6 18.2 c -2.4 1.5 -4.7 3.5 -6.7 6 C 3.7 70.4 2 76 2.3 81.5 c .3 5.5 3 10 7.3 13.5 c 4.5 3.5 9.3 4.5 14.8 3.5 c 2.1 -.3 4.5 -1.5 6.6 -2.1 c -4.5 0 -8.3 -1.5 -12.1 -4.5 c -4.1 -3 -7 -7.3 -7.6 -12.4 c -1 -4.8 0 -9.3 2.7 -13.2 c .6 -.8 1.2 -1.4 1.9 -2 c 1.5 3.8 3.3 7.4 5.2 10.9 c 4.1 6.3 8.3 11.8 12.4 17.7 c 1.8 3.5 3 7 3.8 10.4 c 2.7 3.8 6.6 6.5 11.4 7.9 c 5.9 2.1 12.1 2.9 18.4 2.9 h .7 c 6.3 0 12.9 -1 19 -3 c 4.5 -1.5 8.3 -4 11.1 -8 h .3 c .7 -3 1.8 -6.8 3.5 -10.3 c 4.1 -5.9 8.3 -11.4 12.4 -17.7 c 5.5 -10 9.3 -21.4 11.4 -33.6 c -2.1 3 -7.3 5.8 -16.6 7.9 z m -82.7 -8.7 c 10.8 2.7 24.6 4.1 41.2 4.1 c 16.9 0 30.4 -1.5 41.2 -4.1 c 11.4 -2.7 16.9 -6.3 16.9 -9.6 c 0 -2.5 -2.5 -4.8 -7 -6.6 c 1 .7 1.8 1.8 1.8 3 c 0 3.5 -5.2 6.3 -15.6 8.6 c -9.6 2.1 -22 3.6 -37 3.6 c -14.5 0 -27.4 -1.5 -36.7 -3.5 c -10 -2.5 -15.3 -5.2 -15.3 -8.6 c 0 -1.5 .7 -2.7 2.7 -4.1 c -6.3 2.5 -9.6 4.5 -9.6 7.6 c .3 3.5 5.9 7 17.4 9.6 z" + readonly property string elm: "M 64 60.74 l 25.65 -25.65 h -51.3 L 64 60.74 z M 7.91 4.65 l 25.83 25.84 h 56.17 L 64.07 4.65 H 7.91 z M 67.263 63.993 l 28.08 -28.08 l 27.951 27.953 l -28.08 28.079 z M 123.35 57.42 V 4.65 H 70.58 l 52.77 52.77 z M 60.74 64 L 4.65 7.91 V 120.1 L 60.74 64 z M 98.47 95.21 l 24.88 24.89 V 70.33 L 98.47 95.21 z M 64 67.26 L 7.91 123.35 h 112.18 L 64 67.26 z" + readonly property string awk: "M 74.351 122.47 c .73 -2.936 -2.013 -5.706 -3.838 -3.88 c -2.244 2.264 -3.773 1.341 -3.124 -1.864 c .376 -1.932 .188 -3.021 -.547 -3.021 c -1.933 0 -1.235 -2.83 1.11 -4.488 c 1.235 -.859 4.3 -1.573 6.818 -1.594 c 2.517 0 5.26 -.483 6.081 -1.09 c 1.17 -.816 .902 -.944 -1.18 -.483 c -1.482 .338 -3.94 .129 -5.422 -.44 c -2.04 -.773 -3.462 -.644 -5.77 .505 c -3.838 1.905 -3.774 1.932 -3.774 -.42 c 0 -1.341 -.821 -2.055 -2.603 -2.264 l -2.577 -.338 l 2.201 -2.346 c 2.351 -2.518 4.906 -2.577 11.81 -.354 c 4.508 1.449 4.127 .021 -.715 -2.706 c -4.573 -2.56 -7.697 -6.103 -19.313 -21.847 c -12.373 -16.736 -13.253 -18.894 -13.795 -33.027 l -.461 -11.825 l -6.42 -7.525 c -3.521 -4.133 -6.87 -7.805 -7.44 -8.16 c -.569 -.332 -3.317 -.9 -6.103 -1.234 c -5.727 -.67 -6.081 -1.492 -2.936 -6.817 c 2.47 -4.235 5.743 -4.718 10.483 -1.572 c 1.68 1.11 3.714 1.97 4.552 1.948 c .945 -.037 .644 -.483 -.752 -1.17 c -1.261 -.59 -2.27 -1.406 -2.27 -1.804 c 0 -1.132 13.296 -2.012 17.177 -1.11 c 8.051 1.84 10.628 4.67 11.99 13.123 c .924 5.722 1.53 6.876 5.68 10.778 c 2.539 2.41 7.214 5.62 10.38 7.129 c 3.168 1.508 7.59 4.235 9.856 6.038 c 5.325 4.235 13.672 16.436 16.522 24.112 c 1.235 3.334 2.748 6.646 3.355 7.402 c 1.658 1.992 -1.004 3.355 -5.304 2.684 a 74.022 74.022 0 0 0 -8.577 -.628 c -2.791 -.043 -5.663 -.569 -6.393 -1.154 c -1.868 -1.508 -6.796 -12.684 -7.697 -17.402 c -1.573 -8.39 -4.59 -11.487 -15.201 -15.62 c -7.612 -2.957 -8.175 -3.419 -12.265 -9.796 c -3.758 -5.85 -4.466 -10.252 -1.61 -10.038 c 2.93 .226 2.136 -3.693 -1.031 -5.207 c -2.163 -1.046 -3.586 -1.09 -6.19 -.225 c -2.243 .73 -4.755 .794 -7.546 .161 c -2.287 -.52 -4.17 -.708 -4.17 -.413 c 0 .268 2.345 3.06 5.222 6.183 c 8.384 9.184 9.055 10.671 8.298 18.578 c -.44 4.574 -.268 7.257 .526 8.218 c .65 .779 1.09 1.804 .988 2.265 c -.107 .484 .564 2.56 1.465 4.633 c .924 2.056 1.68 4.444 1.68 5.287 c 0 .859 .71 1.551 1.59 1.551 c 1.18 0 1.384 .59 .858 2.265 c -.606 1.885 -.483 2.072 .714 1.069 c 1.181 -.967 1.68 -.59 2.748 2.034 c .714 1.76 1.74 4.278 2.266 5.599 c .526 1.299 1.825 2.598 2.898 2.871 c 1.085 .269 2.201 1.74 2.555 3.29 c .333 1.53 1.45 3.694 2.453 4.778 c 1.004 1.095 2.158 2.963 2.534 4.154 c .945 2.958 5.496 5.996 9.689 6.48 c 2.64 .316 3.5 .88 3.5 2.372 c 0 1.068 .525 1.84 1.153 1.717 c .65 -.123 1.718 .462 2.416 1.256 c .665 .822 1.696 1.493 2.281 1.493 c .59 0 0 -1.133 -1.299 -2.518 c -1.299 -1.385 -2.093 -2.807 -1.739 -3.167 c .354 -.333 1.847 .8 3.312 2.56 c 2.936 3.479 6.312 5.658 7.3 4.67 c .354 -.332 -1.406 -2.361 -3.88 -4.524 c -2.475 -2.137 -4.155 -4.236 -3.758 -4.654 c .424 -.419 1.89 .333 3.274 1.674 c 6.667 6.458 12.077 10.005 15.325 10.005 c 1.53 0 1.514 -.187 -.107 -1.975 c -.961 -1.068 -1.487 -2.388 -1.149 -2.952 a 1.165 1.165 0 0 0 -.376 -1.616 c -.547 -.338 -3.102 -4.696 -5.684 -9.688 c -2.577 -4.992 -5.534 -9.517 -6.586 -10.086 c -3.752 -2.013 -1.74 -2.641 7.826 -2.453 l 9.747 .214 l 8.385 8.927 c 4.616 4.927 8.223 9.103 8.014 9.313 c -.193 .21 -1.283 -.108 -2.416 -.714 c -2.79 -1.508 -3.06 -1.406 -2.453 .923 c .59 2.244 -.692 2.663 -2.512 .816 c -.67 -.644 -1.556 -.837 -1.975 -.419 c -.419 .44 1.932 3.253 5.244 6.248 c 3.543 3.232 5.62 5.808 5.035 6.313 c -1.326 1.127 -22.797 -.295 -25.438 -1.718 c -1.176 -.633 -3.983 -1.133 -6.248 -1.133 c -3.42 0 -4.106 .333 -4.106 1.986 c 0 4.053 -3.296 10.048 -7.698 14.074 c -2.496 2.266 -4.76 4.134 -5.013 4.134 c -.252 0 -.188 -1.074 .15 -2.351 z m 4.842 -6.565 c -.44 -1.342 -.333 -1.4 .44 -.29 c .816 1.112 1.299 .918 2.56 -1.03 c .838 -1.32 1.675 -3.312 1.863 -4.402 c .274 -1.717 -.145 -1.97 -2.705 -1.68 c -3.602 .419 -6.436 1.804 -8.825 4.278 c -2.163 2.265 -2.222 2.936 -.23 2.936 c .837 0 2.286 .902 3.21 1.992 c 1.969 2.286 4.589 1.009 3.687 -1.804 z m -3.521 -3.247 c 2.517 -2.094 4.17 -2.539 4.17 -1.133 c 0 .816 -4.444 3.226 -5.85 3.167 c -.376 -.021 .359 -.944 1.68 -2.04 z m 34.97 -14.09 c -.354 -.57 -1.09 -1.01 -1.631 -1.01 c -.57 0 -.736 .44 -.376 1.01 c .333 .542 1.068 1.004 1.61 1.004 c .57 0 .736 -.462 .397 -1.004 z M 107.52 85.84 c 2.748 -1.052 1.766 -2.416 -1.3 -1.782 c -1.653 .311 -3.204 .08 -3.585 -.548 c -.945 -1.508 -2.872 -1.406 -2.346 .124 c .231 .697 1.509 1.556 2.85 1.932 c 1.364 .376 2.54 .735 2.62 .773 c .107 .064 .902 -.161 1.76 -.5 z m -9.501 -5.346 c 0 -.483 -.709 -1.181 -1.59 -1.514 c -2.474 -.94 -2.806 -.73 -1.427 .902 c 1.385 1.68 3.022 2.013 3.022 .612 z M 85.082 68.058 c -.73 -.736 -1.192 -.795 -1.192 -.145 c 0 1.342 1.197 2.533 1.868 1.862 c .29 -.29 -.021 -1.068 -.67 -1.717 z m 2.727 -3.275 c -1.74 -1.717 -8.299 -5.593 -8.74 -5.131 c -.316 .29 -.375 .692 -.15 .859 c .231 .166 1.74 1.32 3.334 2.539 c 2.394 1.798 7.112 3.29 5.555 1.739 z M 95 62.228 c 0 -1.213 -3.709 -3.607 -7.085 -4.546 c -2.265 -.655 -2.158 -.424 1.025 2.367 c 3.543 3.124 6.066 4.026 6.066 2.18 z m -9.914 -16.37 c -.735 -.736 -1.197 -.8 -1.197 -.172 c 0 1.363 1.197 2.539 1.868 1.868 c .29 -.269 -.021 -1.047 -.67 -1.702 z M 77.83 40.57 c 0 -.252 -.462 -.735 -1.004 -1.073 c -.569 -.333 -1.009 -.14 -1.009 .445 c 0 .59 .44 1.074 1.01 1.074 c .541 0 1.003 -.194 1.003 -.446 z m -13.564 -4.133 c -2.077 -1.551 -3.231 -1.299 -2.12 .462 c .36 .612 1.428 1.073 2.372 1.052 c 1.487 -.022 1.466 -.215 -.252 -1.508 z m -22.77 -26.71 a 1.053 1.053 0 0 0 -1.073 -1.025 c -.586 0 -.795 .456 -.462 1.025 c .36 .548 .843 1.004 1.073 1.004 c .253 0 .462 -.456 .462 -1.004 z m -19.19 -1.025 c 0 -.548 -1.025 -.988 -2.281 -.988 c -1.825 .022 -1.976 .21 -.757 .988 c 1.97 1.277 3.038 1.277 3.038 0 z M 79.51 90.828 c -.36 -.376 -.671 -1.342 -.671 -2.158 c 0 -1.175 .29 -1.218 1.503 -.215 c .843 .698 1.535 1.664 1.535 2.147 c 0 1.047 -1.406 1.17 -2.367 .226 z m -13.843 -15.39 c -1.905 -1.61 -3.102 -3.311 -2.662 -3.773 c .461 -.461 1.304 -.043 1.868 .967 c .547 1.003 2.093 2.222 3.435 2.742 c 1.342 .505 2.458 1.385 2.458 1.954 c 0 1.718 -1.406 1.192 -5.099 -1.889 z M 8.447 11.403 L 5.2 10.232 l 2.555 -3.296 c 1.889 -2.41 3.397 -3.269 5.62 -3.269 c 1.68 0 2.855 .338 2.646 .751 c -.215 .425 -1.154 2.459 -2.12 4.53 c -.945 2.1 -1.825 3.758 -1.97 3.715 a 453.21 453.21 0 0 1 -3.484 -1.261 z" + readonly property string matlab: "M 123.965 91.902 c -7.246 -18.297 -13.262 -37.058 -20.184 -55.476 c -3.054 -7.84 -6.047 -15.746 -10.215 -23.082 c -1.656 -2.633 -3.238 -5.528 -5.953 -7.215 a 4.013 4.013 0 0 0 -2.222 -.606 c -1.27 .028 -2.536 .594 -3.504 1.415 c -3.645 2.886 -5.805 7.082 -8.227 10.949 c -4.277 7.172 -8.789 14.687 -15.941 19.347 c -3.36 2.371 -7.762 2.63 -11 5.172 c -4.43 3.34 -7.442 8.078 -11.074 12.184 c -.829 .988 -2.11 1.383 -3.227 1.918 C 21.578 60.93 10.738 65.336 0 69.98 c 9.09 7.032 18.777 13.29 28.05 20.079 c 2.544 -.504 5.098 -1.547 7.72 -1.082 c 4.16 1.3 6.597 5.285 8.503 8.93 c 3.875 7.94 6.676 16.323 9.813 24.57 c 5.246 -.375 9.969 -3.079 14.027 -6.258 c 7.809 -6.324 13.758 -14.5 20.305 -22.047 c 3.14 -3.3 6.34 -7.23 11.05 -8.149 c 4.762 -1.152 9.864 .555 13.395 3.836 c 4.957 4.43 9.344 9.551 15.137 12.942 c -.777 -3.836 -2.645 -7.278 -4.035 -10.899 z M 42.96 79.012 c -4.57 2.703 -9.426 4.93 -14.176 7.289 c -7.457 -4.996 -14.723 -10.29 -22.05 -15.465 c 9.878 -4.328 19.91 -8.348 29.917 -12.387 c 4.746 3.703 9.637 7.223 14.383 10.926 c -2.23 3.563 -4.914 6.871 -8.074 9.637 z m 10.168 -12.414 C 48.414 63.058 43.64 59.609 39 55.977 c 2.977 -4.055 6.238 -7.977 10.14 -11.172 c 2.587 -1.657 5.743 -2.117 8.426 -3.61 c 6.368 -3.18 10.711 -9.011 14.86 -14.582 c -5.317 13.805 -10.992 27.664 -19.297 39.985 z m 0 0" + readonly property string solidity: "M 43.322 0 L 22.756 36.576 l 20.566 36.561 l 20.564 -36.561 h 41.143 L 84.465 0 H 43.322 z m 41.342 54.863 L 64.1 91.424 H 22.955 L 43.519 128 h 41.145 l 20.58 -36.576 l -20.58 -36.561 z" + readonly property string wasm: "M .223 .222 v 127.555 h 127.555 V .222 H 78.594 c .014 .227 .036 .455 .036 .686 c 0 8.08 -6.55 14.626 -14.63 14.626 c -8.078 0 -14.625 -6.546 -14.625 -14.626 c 0 -.231 .022 -.459 .031 -.686 z m 29.595 68.746 h 8.445 l 5.782 30.738 h .107 l 6.968 -30.738 h 7.908 l 6.265 31.119 h .106 l 6.597 -31.119 h 8.284 l -10.765 45.156 H 61.12 l -6.213 -30.738 H 54.8 l -6.7 30.738 h -8.557 z m 59.994 0 h 13.334 l 13.284 45.156 h -8.77 l -2.879 -10.051 H 89.59 l -2.212 10.05 h -8.5 Z M 94.895 80.1 l -3.684 16.57 h 11.473 L 98.448 80.1 Z" + readonly property string vim: "M 72.6 80.5 c .2 .2 .6 .5 .9 .5 h 5.3 c .3 0 .7 -.3 .9 -.5 l 1.4 -1.5 c .2 -.2 .3 -.4 .3 -.6 l 1.5 -5.1 c .1 -.5 0 -1 -.3 -1.3 l -1.1 -.9 c -.2 -.2 -.6 -.1 -.9 -.1 h -4.8 l -.2 -.2 l -.1 -.1 c -.2 0 -.4 -.1 -.6 .1 L 73 72 c -.2 0 -.3 .5 -.4 .7 L 71 77.6 c -.2 .5 -.1 1.1 .3 1.5 l 1.3 1.4 z m .8 26.4 l -.4 .1 h -1.2 L 79 85.9 c .2 -.7 -.1 -1.5 -.8 -1.7 l -.4 -.1 H 65.7 c -.5 .1 -.9 .5 -1 1 l -.7 2.5 c -.2 .7 .3 1.3 1 1.5 l .3 -.1 h 1.8 l -7.3 20.9 c -.2 .7 .1 1.6 .8 1.9 l .4 .3 h 11.2 c .6 0 1.1 -.5 1.3 -1.1 l .7 -2.4 c .3 -.7 -.1 -1.5 -.8 -1.7 z m 53.1 -19.7 l -1.9 -2.5 v -.1 c -.3 -.3 -.6 -.6 -1 -.6 h -7.2 c -.4 0 -.7 .4 -1 .6 l -2 2.4 h -3.1 l -2.1 -2.4 v -.1 c -.2 -.3 -.6 -.5 -1 -.5 h -4 l 20.2 -20.2 l -22.6 -22.4 L 121 20.6 v -9 L 118.2 8 H 77.3 L 74 11.5 v 2.9 L 62.7 3 L 55 10.5 L 52.6 8 H 12.2 L 9 11.7 v 9.4 l 3 2.9 h 3 v 26.1 l -14 14 l 14 14 v 32 l 5.2 2.9 h 11.6 l 9.1 -9.5 l 21.6 21.6 L 77 110.6 c .1 .4 .4 .5 .9 .7 l .4 -.2 h 9.4 c .6 0 1.1 -.1 1.2 -.6 l .7 -2 c .2 -.7 -.1 -1.3 -.8 -1.5 l -.4 .1 H 88 l 3.4 -10.7 l 2.3 -2.3 h 5 l -5 15.9 c -.2 .7 .2 1.1 .9 1.4 l .4 -.2 h 9.1 c .5 0 1 -.1 1.2 -.6 l .8 -1.8 c .3 -.7 -.1 -1.3 -.7 -1.6 c -.1 -.1 -.3 0 -.5 0 h -.4 l 4.2 -13 h 6.1 l -5.1 15.9 c -.2 .7 .2 1.1 .9 1.3 l .4 -.3 h 10 c .5 0 1 -.1 1.2 -.6 l .8 -2 c .3 -.7 -.1 -1.3 -.8 -1.5 c -.1 -.1 -.3 .1 -.5 .1 h -.7 l 5.6 -18.5 c .2 -.5 .1 -1.1 -.1 -1.4 z M 62.7 4.9 L 74 16.2 v 4.7 l 3.4 4.1 H 79 L 50 53 V 25 h 3.3 l 2.7 -4.2 v -8.9 l -.2 -.3 l 6.9 -6.7 z M 2.9 64.1 L 15 52 v 24.2 L 2.9 64.1 z m 38.9 38.3 l 58.4 -60 l 21.4 21.5 l -20.2 20.2 h -.1 c -.3 .1 -.5 .3 -.7 .5 L 98.5 87 h -2.9 l -2.2 -2.4 c -.2 -.3 -.6 -.6 -1 -.6 h -8.8 c -.6 0 -1.1 .4 -1.3 1 l -.8 2.5 c -.2 .7 .1 1.3 .8 1.6 h 1.5 L 77.4 108 l -15.1 15.2 l -20.5 -20.8 z" + readonly property string sql: "M 115.95 2.781 c -5.504 -4.906 -12.16 -2.933 -18.738 2.902 a 47.9 47.9 0 0 0 -2.918 2.856 c -11.246 11.93 -21.684 34.02 -24.926 50.895 c 1.262 2.563 2.25 5.832 2.902 8.328 c .325 1.238 .617 2.488 .875 3.746 c 0 0 -.101 -.379 -.515 -1.578 l -.266 -.777 a 8.12 8.12 0 0 0 -.176 -.426 c -.734 -1.707 -2.761 -5.309 -3.656 -6.875 a 172.299 172.299 0 0 0 -2.008 6.27 c 2.582 4.714 4.149 12.8 4.149 12.8 s -.133 -.527 -.782 -2.355 c -.57 -1.617 -3.437 -6.637 -4.117 -7.809 c -1.16 4.29 -1.62 7.18 -1.207 7.883 c .813 1.363 1.578 3.723 2.25 6.324 c 1.528 5.868 2.586 13.016 2.586 13.016 l .094 1.192 c -.203 4.886 -.102 9.781 .297 14.656 c .508 6.113 1.457 11.359 2.668 14.172 l .824 -.45 c -1.781 -5.535 -2.504 -12.792 -2.184 -21.155 c .477 -12.79 3.422 -28.215 8.856 -44.29 c 9.191 -24.261 21.938 -43.733 33.602 -53.034 c -10.63 9.601 -25.023 40.695 -29.332 52.203 C 79.404 74.162 75.99 86.252 73.93 97.84 c 3.555 -10.863 15.043 -15.527 15.043 -15.527 s 5.637 -6.954 12.223 -16.883 c -3.945 .898 -10.426 2.441 -12.598 3.351 c -3.2 1.34 -4.063 1.797 -4.063 1.797 s 10.371 -6.312 19.27 -9.172 c 12.234 -19.27 25.566 -46.645 12.145 -58.625 M 16.896 5.681 c -5.398 .02 -9.77 4.39 -9.785 9.789 v 88.574 c .016 5.398 4.39 9.765 9.785 9.785 h 50.227 a 122.816 122.816 0 0 1 -.277 -14.438 c -.031 -.332 -.059 -.754 -.086 -1.067 a 143.095 143.095 0 0 0 -2.523 -12.684 c -.645 -2.507 -1.465 -4.789 -1.965 -5.636 c -.621 -1.051 -.524 -1.653 -.52 -2.305 c 0 -.64 .082 -1.305 .2 -2.059 c .316 -1.878 .73 -3.738 1.246 -5.574 l 1.156 -.148 c -.09 -.188 -.074 -.348 -.164 -.516 l -.219 -2.031 c .64 -2.137 1.316 -4.262 2.04 -6.371 l 1.066 -.102 c -.043 -.082 -.055 -.203 -.098 -.28 l -.23 -1.685 c 3.363 -17.496 13.8 -39.699 25.601 -52.219 c .352 -.37 .711 -.683 1.055 -1.035 z" + readonly property string json: "M 45.949 63.71 c 0 22.998 17.853 30.038 18.223 30.175 l .016 .005 a .823 .823 0 0 0 -.05 .021 h -.001 v .001 h .001 c 20.306 6.899 40.668 -10.261 40.668 -41.251 c 0 -17.054 -7.595 -37.361 -27.7 -48.894 C 105.528 10.083 126 34.393 126 63.903 c 0 35.822 -30.182 62.003 -61.873 62.003 c -2.078 0 -37.078 -10.935 -37.216 -51.164 c -.103 -30.18 19.208 -40.406 32.72 -38.442 c .009 .003 -13.682 7.806 -13.682 27.41 z M 82.051 64.29 c 0 -22.998 -17.853 -30.038 -18.223 -30.175 l -.016 -.005 a .823 .823 0 0 0 .05 -.021 h .001 v -.001 h -.001 C 43.556 27.19 23.193 44.35 23.193 75.34 c 0 17.054 7.595 37.361 27.7 48.894 C 22.472 117.917 2 93.607 2 64.097 C 2 28.275 32.182 2.094 63.873 2.094 c 2.078 0 37.078 10.935 37.216 51.164 c .103 30.18 -19.207 40.406 -32.72 38.443 c -.009 -.004 13.682 -7.807 13.682 -27.411 z" + readonly property string yaml: "m 0.5 5.6289 l 21.754 34.15 v 21.646 h 13.959 v -21.646 l 22.775 -34.15 h -15.02 l -13.947 21.988 l -13.832 -21.988 h -15.689 z m 63.994 0.13086 l -23.66 55.797 h 11.189 l 5.1387 -12.408 h 25.266 l 4.252 12.408 h 11.957 l -22.699 -55.797 h -11.443 z m 5.9922 11.773 l 7.7441 20.475 h -16.387 l 8.6426 -20.475 z m 16.195 50.139 v 54.451 h 40.818 v -11.635 v -0.00195 h -28.863 v -42.814 h -11.955 z m -64.428 0.011719 v 54.687 h 11.734 v -37.723 l 12.279 25.355 h 9.2344 l 12.699 -26.246 v 38.602 h 11.256 v -54.676 h -15.369 l -13.637 24.732 l -12.986 -24.732 h -15.211 z" + readonly property string xml: "M 23.25 37.459 c -2.661 .114 -2.363 3.885 -2.875 5.797 c -.837 3.125 -1.608 7.1 -1.853 10.324 c -.246 3.138 -.044 6.046 .353 9.219 c .309 2.467 1.02 4.104 .373 4.767 c -.52 .623 -17.199 15.92 -18.543 16.786 c -1.392 .903 -.428 3.13 .37 4.379 c .841 1.317 1.24 1.999 2.99 1.765 c 1.645 -.222 2.167 -2.182 3.127 -3.248 c 1.146 -1.353 12.917 -12.824 13.957 -12.824 l -.026 -.147 c .421 2.923 1.972 5.705 3.598 8.112 c .798 1.19 1.68 2.352 2.699 3.37 c .936 .937 2.145 1.702 3.145 2.409 c 2.352 .123 4.246 -1.618 1.875 -3.59 c -.905 -.757 -3.935 -5.212 -4.961 -7.676 c -.957 -2.303 -3.074 -5.762 -.782 -7.654 c 7.921 -6.6 19.887 -12.132 22.711 -12.406 c -1.377 5.422 -7.257 16.32 -9.408 21.48 c -.741 1.785 -1.233 3.92 -.982 5.864 c .297 2.324 2.046 4.152 3.144 4.326 c 2.229 -.127 3.74 -2.086 5.06 -3.463 c 1.832 -1.908 3.396 -3.672 5.221 -5.588 c 1.812 -1.907 3.593 -3.918 5.2 -5.734 c .988 -1.12 3.6 -3.829 5.152 -3.829 c -.584 3.732 -3.832 8.33 -4.486 12.094 c -.667 3.85 2.844 8.563 6.52 5.131 c 3.336 -3.117 6.72 -6.056 10 -9.22 c 1.162 -1.112 .935 .2 1.196 1.296 c .263 1.114 .945 4.875 1.473 5.89 c .968 1.857 2.653 3.176 4.799 3.212 c 1.951 .032 3.897 -.862 5.258 -2.219 c 1.015 -1.011 2.757 -3.749 .83 -4.463 c -1.23 -.456 -1.159 -.943 -2.65 -.693 c -.925 .158 -1.94 .395 -2.717 .959 c -1.967 -.008 -2.042 -4.756 -1.737 -6.215 c .36 -1.741 1.107 -3.522 .992 -5.34 c -.135 -2.24 -2.554 -3.707 -3.115 -3.865 c -.832 -.41 -1.343 .117 -2.447 1.133 c -.928 .857 -1.947 1.778 -2.863 2.646 c -1.829 1.733 -4.11 4.36 -5.041 4.36 c 1.634 -3.73 3.8 -14.16 -3.442 -13.24 c -3.01 .384 -5.085 2.02 -7.457 3.806 a 252.59 252.59 0 0 1 -5.014 4.674 c -1.269 1.146 -1.99 2.152 -2.654 2.152 c .707 -3.962 9.02 -16.976 4.74 -20.644 c -.858 -.722 -1.678 -1.742 -2.761 -1.864 c -1.511 .186 -3.002 .552 -4.457 1.016 c -3.023 1.043 -14.846 6.9 -22.399 12.306 c -.701 .508 -1.15 -.451 -1.236 -1.558 c -.472 -5.732 1.06 -11.749 2.654 -17.1 c 1.103 -3.732 -2.992 -6.563 -3.531 -6.693 z m 96.842 4.877 c -.889 1.952 -1.067 4.788 -1.332 7.06 v .008 c -2.02 -.238 -4.646 -.495 -6.668 -.617 c 1.273 1.586 3.698 3.149 5.658 3.88 c -.261 1.958 -2.326 3.584 -3.207 5.476 c 2.448 -.679 3.62 -2.514 5.291 -4.403 c 1.935 1.178 4.227 3.363 6.742 3.41 c -.952 -2.022 -1.792 -3.723 -3.525 -5.183 c 1.44 -.94 3.842 -1.927 4.949 -3.387 c -1.844 -.396 -3.832 -.5 -5.629 -.08 c -1.38 -1.523 -1.605 -4.141 -2.28 -6.164 z m -14.733 .387 c -1.233 .113 -3.28 2.633 -4.453 4.144 c -4.323 5.564 -8.265 16.89 -8.9 18.977 c -1.935 6.398 -2.686 14.285 1.502 19.916 c 2.436 3.284 6.728 3.532 10.935 2.324 c 2.17 -.624 4.344 -2.181 5.086 -3.875 c .615 -1.411 .932 -4.037 -2.166 -2.035 c -1.198 .773 -3.395 1.627 -7.334 1.564 c -1.32 -.024 -3.49 -1.92 -4.072 -3.88 c -1.024 -3.194 -.588 -7.458 -.588 -7.458 c .892 -5.505 3.447 -11.47 5.688 -16.474 c .869 -1.932 3.517 -6.748 4.51 -8.283 c 0 .004 1.27 -1.997 .7 -4.123 l -.01 .017 c -.167 -.624 -.487 -.852 -.898 -.814 z m 16.45 15.935 c -.647 1.417 -.774 3.483 -.965 5.133 h -.01 c -1.464 -.17 -3.38 -.358 -4.836 -.45 c .924 1.152 2.686 2.285 4.106 2.817 c -.18 1.416 -1.682 2.606 -2.325 3.975 c 1.781 -.493 2.639 -1.829 3.844 -3.194 c 1.404 .845 3.074 2.436 4.899 2.471 c -.679 -1.471 -1.294 -2.709 -2.555 -3.764 c 1.039 -.678 2.78 -1.408 3.582 -2.459 c -1.333 -.289 -2.789 -.36 -4.082 -.056 c -1.003 -1.107 -1.167 -3 -1.658 -4.473 z m -6.55 12.95 c -.443 .98 -.533 2.4 -.665 3.546 l -.012 -.004 a 62.34 62.34 0 0 0 -3.344 -.308 c .635 .8 1.853 1.58 2.836 1.949 c -.127 .98 -1.16 1.794 -1.605 2.742 c 1.23 -.345 1.82 -1.264 2.658 -2.213 c .979 .595 2.125 1.689 3.387 1.713 c -.472 -1.015 -.894 -1.863 -1.762 -2.598 c .717 -.472 1.92 -.972 2.479 -1.7 c -.927 -.2 -1.927 -.252 -2.823 -.042 c -.694 -.761 -.811 -2.07 -1.148 -3.086 z" + readonly property string html: "M 9.032 2 l 10.005 112.093 l 44.896 12.401 l 45.02 -12.387 L 118.968 2 H 9.032 z m 89.126 26.539 l -.627 7.172 L 97.255 39 H 44.59 l 1.257 14 h 50.156 l -.336 3.471 l -3.233 36.119 l -.238 2.27 L 64 102.609 v .002 l -.034 .018 l -28.177 -7.423 L 33.876 74 h 13.815 l .979 10.919 L 63.957 89 H 64 v -.546 l 15.355 -3.875 L 80.959 67 H 33.261 l -3.383 -38.117 L 29.549 25 h 68.939 l -.33 3.539 z" + readonly property string css: "M 8.76 1 l 10.055 112.883 l 45.118 12.58 l 45.244 -12.626 L 119.24 1 H 8.76 z m 89.591 25.862 l -3.347 37.605 l .01 .203 l -.014 .467 v -.004 l -2.378 26.294 l -.262 2.336 L 64 101.607 v .001 l -.022 .019 l -28.311 -7.888 L 33.75 72 h 13.883 l .985 11.054 l 15.386 4.17 l -.004 .008 v -.002 l 15.443 -4.229 L 81.075 65 H 48.792 l -.277 -3.043 l -.631 -7.129 L 47.553 51 h 34.749 l 1.264 -14 H 30.64 l -.277 -3.041 l -.63 -7.131 L 29.401 23 h 69.281 l -.331 3.862 z" + readonly property string sass: "M 1.219 56.156 c 0 .703 .207 1.167 .323 1.618 c .756 2.933 2.381 5.45 4.309 7.746 c 2.746 3.272 6.109 5.906 9.554 8.383 c 2.988 2.148 6.037 4.248 9.037 6.38 c .515 .366 1.002 .787 1.561 1.236 c -.481 .26 -.881 .489 -1.297 .7 c -3.959 2.008 -7.768 4.259 -11.279 6.986 c -2.116 1.644 -4.162 3.391 -5.607 5.674 c -2.325 3.672 -3.148 7.584 -1.415 11.761 c .506 1.22 1.278 2.274 2.367 3.053 c .353 .252 .749 .502 1.162 .6 c 1.058 .249 2.136 .412 3.207 .609 l 3.033 -.002 c 3.354 -.299 6.407 -1.448 9.166 -3.352 c 4.312 -2.976 7.217 -6.966 8.466 -12.087 c .908 -3.722 .945 -7.448 -.125 -11.153 a 11.696 11.696 0 0 0 -.354 -1.014 c -.13 -.333 -.283 -.657 -.463 -1.072 l 6.876 -3.954 l .103 .088 c -.125 .409 -.258 .817 -.371 1.23 c -.817 2.984 -1.36 6.02 -1.165 9.117 c .208 3.3 1.129 6.389 3.061 9.146 c 1.562 2.23 5.284 2.313 6.944 .075 c .589 -.795 1.16 -1.626 1.589 -2.513 c 1.121 -2.315 2.159 -4.671 3.23 -7.011 l .187 -.428 c -.077 1.108 -.167 2.081 -.208 3.055 c -.064 1.521 .025 3.033 .545 4.48 c .445 1.238 1.202 2.163 2.62 2.326 c .97 .111 1.743 -.333 2.456 -.896 a 10.384 10.384 0 0 0 2.691 -3.199 c 1.901 -3.491 3.853 -6.961 5.576 -10.54 c 1.864 -3.871 3.494 -7.855 5.225 -11.792 l .286 -.698 c .409 1.607 .694 3.181 1.219 4.671 c .61 1.729 1.365 3.417 2.187 5.058 c .389 .775 .344 1.278 -.195 1.928 c -2.256 2.72 -4.473 5.473 -6.692 8.223 c -.491 .607 -.98 1.225 -1.389 1.888 a 3.701 3.701 0 0 0 -.48 1.364 a 1.737 1.737 0 0 0 1.383 1.971 a 9.661 9.661 0 0 0 2.708 .193 c 3.097 -.228 5.909 -1.315 8.395 -3.157 c 3.221 -2.386 4.255 -5.642 3.475 -9.501 c -.211 -1.047 -.584 -2.065 -.947 -3.074 c -.163 -.455 -.174 -.774 .123 -1.198 c 2.575 -3.677 4.775 -7.578 6.821 -11.569 c .081 -.157 .164 -.314 .306 -.482 c .663 3.45 1.661 6.775 3.449 9.792 c -.912 .879 -1.815 1.676 -2.632 2.554 c -1.799 1.934 -3.359 4.034 -4.173 6.595 c -.35 1.104 -.619 2.226 -.463 3.405 c .242 1.831 1.742 3.021 3.543 2.604 c 3.854 -.892 7.181 -2.708 9.612 -5.925 c 1.636 -2.166 1.785 -4.582 1.1 -7.113 c -.188 -.688 -.411 -1.365 -.651 -2.154 c .951 -.295 1.878 -.649 2.837 -.868 c 4.979 -1.136 9.904 -.938 14.702 .86 c 2.801 1.05 5.064 2.807 6.406 5.571 c 1.639 3.379 .733 6.585 -2.452 8.721 c -.297 .199 -.637 .356 -.883 .605 a .869 .869 0 0 0 -.205 .67 c .021 .123 .346 .277 .533 .275 c 1.047 -.008 1.896 -.557 2.711 -1.121 c 2.042 -1.413 3.532 -3.314 3.853 -5.817 l .063 -.188 l -.077 -1.63 c -.031 -.094 .023 -.187 .016 -.258 c -.434 -3.645 -2.381 -6.472 -5.213 -8.688 c -3.28 -2.565 -7.153 -3.621 -11.249 -3.788 a 25.401 25.401 0 0 0 -9.765 1.503 c -.897 .325 -1.786 .71 -2.688 1.073 c -.121 -.219 -.251 -.429 -.358 -.646 c -.926 -1.896 -2.048 -3.708 -2.296 -5.882 c -.176 -1.544 -.392 -3.086 -.025 -4.613 c .353 -1.469 .813 -2.913 1.246 -4.362 c .223 -.746 .066 -1.164 -.646 -1.5 a 2.854 2.854 0 0 0 -.786 -.258 c -1.75 -.254 -3.476 -.109 -5.171 .384 c -.6 .175 -1.036 .511 -1.169 1.175 c -.076 .381 -.231 .746 -.339 1.122 c -.443 1.563 -.757 3.156 -1.473 4.645 c -1.794 3.735 -3.842 7.329 -5.938 10.897 c -.227 .385 -.466 .763 -.752 1.23 c -.736 -1.54 -1.521 -2.922 -1.759 -4.542 c -.269 -1.832 -.481 -3.661 -.025 -5.479 c .339 -1.356 .782 -2.687 1.19 -4.025 c .193 -.636 .104 -.97 -.472 -1.305 c -.291 -.169 -.62 -.319 -.948 -.368 a 11.643 11.643 0 0 0 -5.354 .438 c -.543 .176 -.828 .527 -.994 1.087 c -.488 1.652 -.904 3.344 -1.589 4.915 c -2.774 6.36 -5.628 12.687 -8.479 19.013 c -.595 1.321 -1.292 2.596 -1.963 3.882 c -.17 .326 -.418 .613 -.63 .919 c -.17 -.201 -.236 -.339 -.235 -.477 c .005 -.813 -.092 -1.65 .063 -2.436 a 172.189 172.189 0 0 1 1.578 -7.099 c .47 -1.946 1.017 -3.874 1.538 -5.807 c .175 -.647 .178 -1.252 -.287 -1.796 c -.781 -.911 -2.413 -1.111 -3.381 -.409 l -.428 .242 l .083 -.69 c .204 -1.479 .245 -2.953 -.161 -4.41 c -.506 -1.816 -1.802 -2.861 -3.686 -2.803 c -.878 .027 -1.8 .177 -2.613 .497 c -3.419 1.34 -6.048 3.713 -8.286 6.568 a 2.592 2.592 0 0 1 -.757 .654 c -2.893 1.604 -5.795 3.188 -8.696 4.778 l -3.229 1.769 c -.866 -.826 -1.653 -1.683 -2.546 -2.41 c -2.727 -2.224 -5.498 -4.393 -8.244 -6.592 c -2.434 -1.949 -4.792 -3.979 -6.596 -6.56 c -1.342 -1.92 -2.207 -4.021 -2.29 -6.395 c -.105 -3.025 .753 -5.789 2.293 -8.362 c 1.97 -3.292 4.657 -5.934 7.611 -8.327 c 3.125 -2.53 6.505 -4.678 10.008 -6.639 c 4.901 -2.743 9.942 -5.171 15.347 -6.774 c 5.542 -1.644 11.165 -2.585 16.965 -1.929 c 2.28 .258 4.494 .78 6.527 1.895 c 1.557 .853 2.834 1.97 3.428 3.716 c .586 1.718 .568 3.459 .162 5.204 c -.825 3.534 -2.76 6.447 -5.195 9.05 c -3.994 4.267 -8.866 7.172 -14.351 9.091 a 39.478 39.478 0 0 1 -9.765 2.083 c -2.729 .229 -5.401 -.013 -7.985 -.962 c -1.711 -.629 -3.201 -1.591 -4.399 -2.987 c -.214 -.25 -.488 -.521 -.887 -.287 c -.391 .23 -.46 .602 -.329 .979 c .219 .626 .421 1.278 .762 1.838 c .857 1.405 2.107 2.424 3.483 3.298 c 2.643 1.681 5.597 2.246 8.66 2.377 c 4.648 .201 9.183 -.493 13.654 -1.74 c 6.383 -1.78 11.933 -4.924 16.384 -9.884 c 3.706 -4.13 6.353 -8.791 6.92 -14.419 c .277 -2.747 -.018 -5.438 -1.304 -7.944 c -1.395 -2.715 -3.613 -4.734 -6.265 -6.125 C 68.756 18.179 64.588 17 60.286 17 h -4.31 c -5.21 0 -10.247 1.493 -15.143 3.274 c -3.706 1.349 -7.34 2.941 -10.868 4.703 c -7.683 3.839 -14.838 8.468 -20.715 14.833 c -2.928 3.171 -5.407 6.67 -6.833 10.79 a 40.494 40.494 0 0 0 -1.111 3.746 m 27.839 36.013 c -.333 4.459 -2.354 8.074 -5.657 11.002 c -1.858 1.646 -3.989 2.818 -6.471 3.23 c -.9 .149 -1.821 .185 -2.694 -.188 c -1.245 -.532 -1.524 -1.637 -1.548 -2.814 c -.037 -1.876 .62 -3.572 1.521 -5.186 c 1.176 -2.104 2.9 -3.708 4.741 -5.206 c 2.9 -2.361 6.046 -4.359 9.268 -6.245 l .243 -.1 c .498 1.84 .735 3.657 .597 5.507 z M 54.303 70.98 c -.235 1.424 -.529 2.849 -.945 4.229 c -1.438 4.777 -3.285 9.406 -5.282 13.973 c -.369 .845 -.906 1.616 -1.373 2.417 a 1.689 1.689 0 0 1 -.283 .334 c -.578 .571 -1.126 .541 -1.418 -.206 c -.34 -.868 -.549 -1.797 -.729 -2.716 c -.121 -.617 -.092 -1.265 -.13 -1.897 c .039 -4.494 1.41 -8.578 3.736 -12.38 c .959 -1.568 2.003 -3.062 3.598 -4.054 a 6.27 6.27 0 0 1 1.595 -.706 c .85 -.239 1.372 .154 1.231 1.006 z m 17.164 21.868 l 6.169 -7.203 c .257 2.675 -4.29 8.015 -6.169 7.203 z m 19.703 -4.847 c -.436 .25 -.911 .43 -1.358 .661 c -.409 .212 -.544 -.002 -.556 -.354 a 2.385 2.385 0 0 1 .093 -.721 c .833 -2.938 2.366 -5.446 4.647 -7.486 l .16 -.082 c 1.085 3.035 -.169 6.368 -2.986 7.982 z" + readonly property string markdown: "M 11.95 24.348 c -5.836 0 -10.618 4.867 -10.618 10.681 v 57.942 c 0 5.814 4.782 10.681 10.617 10.681 h 104.102 c 5.835 0 10.617 -4.867 10.617 -10.681 V 35.03 c 0 -5.814 -4.783 -10.681 -10.617 -10.681 H 14.898 l -.002 -.002 H 11.95 z m -.007 9.543 h 104.108 c .625 0 1.076 .423 1.076 1.14 v 57.94 c 0 .717 -.453 1.14 -1.076 1.14 H 11.949 c -.623 0 -1.076 -.423 -1.076 -1.14 V 35.029 c 0 -.715 .451 -1.135 1.07 -1.138 z M 20.721 84.1 V 43.9 H 32.42 l 11.697 14.78 L 55.81 43.9 h 11.696 v 40.2 H 55.81 V 61.044 l -11.694 14.78 l -11.698 -14.78 V 84.1 H 20.722 z m 73.104 0 L 76.28 64.591 h 11.697 V 43.9 h 11.698 v 20.69 h 11.698 z m 0 0" + readonly property string docker: "M 124.8 52.1 c -4.3 -2.5 -10 -2.8 -14.8 -1.4 c -.6 -5.2 -4 -9.7 -8 -12.9 l -1.6 -1.3 l -1.4 1.6 c -2.7 3.1 -3.5 8.3 -3.1 12.3 c .3 2.9 1.2 5.9 3 8.3 c -1.4 .8 -2.9 1.9 -4.3 2.4 c -2.8 1 -5.9 2 -8.9 2 H 79 V 49 H 66 V 24 H 51 v 12 H 26 v 13 H 13 v 14 H 1.8 l -.2 1.5 c -.5 6.4 .3 12.6 3 18.5 l 1.1 2.2 l .1 .2 c 7.9 13.4 21.7 19 36.8 19 c 29.2 0 53.3 -13.1 64.3 -40.6 c 7.4 .4 15 -1.8 18.6 -8.9 l .9 -1.8 l -1.6 -1 z M 28 39 h 10 v 11 H 28 V 39 z m 13.1 44.2 c 0 1.7 -1.4 3.1 -3.1 3.1 c -1.7 0 -3.1 -1.4 -3.1 -3.1 c 0 -1.7 1.4 -3.1 3.1 -3.1 c 1.7 .1 3.1 1.4 3.1 3.1 z M 28 52 h 10 v 11 H 28 V 52 z m -13 0 h 11 v 11 H 15 V 52 z m 27.7 50.2 c -15.8 -.1 -24.3 -5.4 -31.3 -12.4 c 2.1 .1 4.1 .2 5.9 .2 c 1.6 0 3.2 0 4.7 -.1 c 3.9 -.2 7.3 -.7 10.1 -1.5 c 2.3 5.3 6.5 10.2 14 13.8 h -3.4 z M 51 63 H 40 V 52 h 11 v 11 z m 0 -13 H 40 V 39 h 11 v 11 z m 13 13 H 53 V 52 h 11 v 11 z m 0 -13 H 53 V 39 h 11 v 11 z m 0 -13 H 53 V 26 h 11 v 11 z m 13 26 H 66 V 52 h 11 v 11 z M 38.8 81.2 c -.2 -.1 -.5 -.2 -.8 -.2 c -1.2 0 -2.2 1 -2.2 2.2 c 0 1.2 1 2.2 2.2 2.2 s 2.2 -1 2.2 -2.2 c 0 -.3 -.1 -.6 -.2 -.8 c -.2 .3 -.4 .5 -.8 .5 c -.5 0 -.9 -.4 -.9 -.9 c .1 -.4 .3 -.7 .5 -.8 z" + readonly property string latex: "M 29.2 63 H 28 c -.5 5.1 -1.2 11.3 -10 11.3 h -4 c -2.3 0 -2.4 -.3 -2.4 -2 V 45.8 c 0 -1.7 0 -2.4 4.7 -2.4 h 1.6 v -1.5 c -1.9 .1 -6.3 .1 -8.4 .1 c -1.9 0 -5.8 0 -7.5 -.1 v 1.5 h 1.1 c 3.8 0 3.9 .5 3.9 2.3 v 26.1 c 0 1.8 -.1 2.3 -3.9 2.3 H 2 v 1.5 h 25.8 L 29.2 63 z M 28.3 41.8 c -.2 -.6 -.3 -.8 -.9 -.8 s -.8 .2 -1 .8 l -8 20.3 c -.3 .8 -.9 2.4 -4 2.4 v 1.2 h 7.7 v -1.2 c -1.5 0 -2.5 -.7 -2.5 -1.7 c 0 -.2 0 -.3 .1 -.7 l 1.7 -4.3 h 9.9 l 2 5.1 c .1 .2 .2 .4 .2 .6 c 0 1 -1.9 1 -2.8 1 v 1.2 h 9.8 v -1.2 h -.7 c -2.3 0 -2.6 -.3 -2.9 -1.3 l -8.6 -21.4 z m -1.9 3.6 l 4.4 11.3 h -8.9 l 4.5 -11.3 z M 68.2 42.2 H 37.9 L 37 53.3 h 1.2 c .7 -8 1.4 -9.7 9 -9.7 c .9 0 2.2 0 2.7 .1 c 1 .2 1 .7 1 1.9 v 26.1 c 0 1.7 0 2.4 -5.2 2.4 h -2 v 1.5 c 2 -.1 7.1 -.1 9.4 -.1 s 7.4 0 9.5 .1 v -1.5 h -2 c -5.2 0 -5.2 -.7 -5.2 -2.4 v -26 c 0 -1 0 -1.7 .9 -1.9 c .5 -.1 1.9 -.1 2.8 -.1 c 7.5 0 8.2 1.6 8.9 9.7 h 1.2 l -1 -11.2 z M 94.9 74.2 h -1.2 c -1.2 7.6 -2.4 11.3 -10.9 11.3 h -6.6 c -2.3 0 -2.4 -.3 -2.4 -2 V 70.2 h 4.4 c 4.8 0 5.4 1.6 5.4 5.8 h 1.2 V 62.9 h -1.2 c 0 4.2 -.5 5.8 -5.4 5.8 h -4.4 v -12 c 0 -1.6 .1 -2 2.4 -2 h 6.4 c 7.6 0 8.9 2.7 9.7 9.7 h 1.2 l -1.4 -11.2 H 64.2 v 1.5 h 1.1 c 3.8 0 3.9 .5 3.9 2.3 v 26 c 0 1.8 -.1 2.3 -3.9 2.3 h -1.1 V 87 h 28.6 l 2.1 -12.8 z M 109.9 56.6 l 6.8 -10 c 1 -1.6 2.7 -3.2 7.2 -3.2 v -1.5 H 112 v 1.5 c 2 0 3.1 1.1 3.1 2.3 c 0 .5 -.1 .6 -.4 1.1 l -5.7 8.4 l -6.4 -9.6 c -.1 -.1 -.3 -.5 -.3 -.7 c 0 -.6 1.1 -1.4 3.2 -1.5 v -1.5 c -1.7 .1 -5.3 .1 -7.2 .1 c -1.5 0 -4.6 0 -6.5 -.1 v 1.5 h .9 c 2.7 0 3.7 .3 4.6 1.7 l 9.1 13.8 l -8.1 12 c -.7 1 -2.2 3.3 -7.2 3.3 v 1.5 H 103 v -1.5 c -2.3 0 -3.1 -1.4 -3.1 -2.3 c 0 -.4 .1 -.6 .5 -1.2 l 7 -10.4 l 7.9 11.9 c .1 .2 .2 .4 .2 .5 c 0 .6 -1.1 1.4 -3.2 1.5 v 1.5 c 1.7 -.1 5.4 -.1 7.2 -.1 c 2.1 0 4.4 0 6.5 .1 v -1.5 h -.9 c -2.6 0 -3.6 -.2 -4.7 -1.8 l -10.5 -15.8 z" + readonly property string graphql: "M 18.39 96.852 l -4.6 -2.657 L 65.04 5.434 l 4.597 2.656 z m 0 0 M 12.734 87.105 H 115.23 v 5.31 H 12.734 z m 0 0 M 66.031 119.688 L 14.766 90.09 l 2.656 -4.602 l 51.266 29.602 z m 0 0 M 110.566 42.543 L 59.301 12.941 l 2.656 -4.597 l 51.266 29.597 z m 0 0 M 17.434 42.523 l -2.657 -4.601 l 51.27 -29.598 l 2.656 4.598 z m 0 0 M 109.621 96.852 L 58.375 8.09 l 4.598 -2.656 l 51.25 88.761 z m 0 0 M 16.8 34.398 h 5.313 v 59.204 h -5.312 z m 0 0 M 105.887 34.398 h 5.312 v 59.204 h -5.312 z m 0 0 M 65.129 117.441 l -2.32 -4.02 l 44.586 -25.745 l 2.32 4.02 z m 0 0 M 118.238 95.328 c -3.07 5.344 -9.918 7.168 -15.261 4.098 c -5.344 -3.074 -7.168 -9.922 -4.098 -15.266 c 3.074 -5.344 9.922 -7.168 15.266 -4.097 c 5.375 3.105 7.199 9.921 4.093 15.265 M 29.09 43.84 c -3.074 5.344 -9.922 7.168 -15.266 4.097 c -5.344 -3.074 -7.168 -9.921 -4.097 -15.265 c 3.074 -5.344 9.921 -7.168 15.265 -4.098 c 5.344 3.106 7.168 9.922 4.098 15.266 M 9.762 95.328 c -3.075 -5.344 -1.25 -12.16 4.093 -15.266 c 5.344 -3.07 12.16 -1.246 15.266 4.098 c 3.07 5.344 1.246 12.16 -4.098 15.266 c -5.375 3.07 -12.191 1.246 -15.261 -4.098 M 98.91 43.84 c -3.07 -5.344 -1.246 -12.16 4.098 -15.266 c 5.344 -3.07 12.16 -1.246 15.265 4.098 c 3.07 5.344 1.247 12.16 -4.097 15.266 c -5.344 3.07 -12.192 1.246 -15.266 -4.098 M 64 126.656 a 11.158 11.158 0 0 1 -11.168 -11.168 A 11.158 11.158 0 0 1 64 104.32 a 11.158 11.158 0 0 1 11.168 11.168 c 0 6.145 -4.992 11.168 -11.168 11.168 M 64 23.68 a 11.158 11.158 0 0 1 -11.168 -11.168 A 11.158 11.158 0 0 1 64 1.344 a 11.158 11.158 0 0 1 11.168 11.168 A 11.158 11.158 0 0 1 64 23.68" + + function path(language) { + const key = language.toLowerCase() + + switch (key) { + case "c": + return c + case "cpp": + return cpp + case "csharp": + return csharp + case "python": + return python + case "rust": + return rust + case "javascript": + return javascript + case "typescript": + return typescript + case "bash": + return bash + case "zsh": + return zsh + case "powershell": + return powershell + case "cmake": + return cmake + case "lua": + return lua + case "java": + return java + case "kotlin": + return kotlin + case "swift": + return swift + case "go": + return go + case "dart": + return dart + case "php": + return php + case "ruby": + return ruby + case "scala": + return scala + case "haskell": + return haskell + case "elixir": + return elixir + case "erlang": + return erlang + case "clojure": + return clojure + case "r": + return r + case "perl": + return perl + case "zig": + return zig + case "nim": + return nim + case "ocaml": + return ocaml + case "fsharp": + return fsharp + case "visualbasic": + return visualbasic + case "fortran": + return fortran + case "crystal": + return crystal + case "gleam": + return gleam + case "julia": + return julia + case "objectivec": + return objectivec + case "vala": + return vala + case "groovy": + return groovy + case "racket": + return racket + case "haxe": + return haxe + case "purescript": + return purescript + case "delphi": + return delphi + case "coffeescript": + return coffeescript + case "elm": + return elm + case "awk": + return awk + case "matlab": + return matlab + case "solidity": + return solidity + case "wasm": + return wasm + case "vim": + return vim + case "sql": + return sql + case "json": + return json + case "yaml": + return yaml + case "xml": + return xml + case "html": + return html + case "css": + return css + case "sass": + return sass + case "markdown": + return markdown + case "docker": + return docker + case "latex": + return latex + case "graphql": + return graphql + case "cpp": + return cpp + case "cc": + return cpp + case "cxx": + return cpp + case "cs": + return csharp + case "js": + return javascript + case "jsx": + return javascript + case "ts": + return typescript + case "tsx": + return typescript + case "py": + return python + case "sh": + return bash + case "shell": + return bash + case "ps1": + return powershell + case "vb": + return visualbasic + case "objective-c": + return objectivec + case "obj-c": + return objectivec + case "groovyscript": + return groovy + case "pascal": + return delphi + case "coffee": + return coffeescript + case "mysql": + return sql + case "postgres": + return sql + case "postgresql": + return sql + case "sqlite": + return sql + case "yml": + return yaml + case "htm": + return html + case "scss": + return sass + case "md": + return markdown + case "dockerfile": + return docker + case "docker-compose": + return docker + case "tex": + return latex + case "gql": + return graphql + default: + return "" + } + } + + function name(language) { + switch (language.toLowerCase()) { + case "c": + return "C" + case "cpp": + return "C++" + case "csharp": + return "C#" + case "python": + return "Python" + case "rust": + return "Rust" + case "javascript": + return "JavaScript" + case "typescript": + return "TypeScript" + case "bash": + return "Bash" + case "zsh": + return "Zsh" + case "powershell": + return "PowerShell" + case "cmake": + return "CMake" + case "lua": + return "Lua" + case "java": + return "Java" + case "kotlin": + return "Kotlin" + case "swift": + return "Swift" + case "go": + return "Go" + case "dart": + return "Dart" + case "php": + return "PHP" + case "ruby": + return "Ruby" + case "scala": + return "Scala" + case "haskell": + return "Haskell" + case "elixir": + return "Elixir" + case "erlang": + return "Erlang" + case "clojure": + return "Clojure" + case "r": + return "R" + case "perl": + return "Perl" + case "zig": + return "Zig" + case "nim": + return "Nim" + case "ocaml": + return "OCaml" + case "fsharp": + return "F#" + case "visualbasic": + return "Visual Basic" + case "fortran": + return "Fortran" + case "crystal": + return "Crystal" + case "gleam": + return "Gleam" + case "julia": + return "Julia" + case "objectivec": + return "Objective-C" + case "vala": + return "Vala" + case "groovy": + return "Groovy" + case "racket": + return "Racket" + case "haxe": + return "Haxe" + case "purescript": + return "PureScript" + case "delphi": + return "Delphi" + case "coffeescript": + return "CoffeeScript" + case "elm": + return "Elm" + case "awk": + return "AWK" + case "matlab": + return "MATLAB" + case "solidity": + return "Solidity" + case "wasm": + return "Wasm" + case "vim": + return "Vim" + case "sql": + return "SQL" + case "json": + return "JSON" + case "yaml": + return "YAML" + case "xml": + return "XML" + case "html": + return "HTML" + case "css": + return "CSS" + case "sass": + return "Sass" + case "markdown": + return "Markdown" + case "docker": + return "Docker" + case "latex": + return "LaTeX" + case "graphql": + return "GraphQL" + case "cpp": + return "C++" + case "cc": + return "C++" + case "cxx": + return "C++" + case "cs": + return "C#" + case "js": + return "JavaScript" + case "jsx": + return "JavaScript" + case "ts": + return "TypeScript" + case "tsx": + return "TypeScript" + case "py": + return "Python" + case "sh": + return "Bash" + case "shell": + return "Bash" + case "ps1": + return "PowerShell" + case "vb": + return "Visual Basic" + case "objective-c": + return "Objective-C" + case "obj-c": + return "Objective-C" + case "groovyscript": + return "Groovy" + case "pascal": + return "Delphi" + case "coffee": + return "CoffeeScript" + case "mysql": + return "SQL" + case "postgres": + return "SQL" + case "postgresql": + return "SQL" + case "sqlite": + return "SQL" + case "yml": + return "YAML" + case "htm": + return "HTML" + case "scss": + return "Sass" + case "md": + return "Markdown" + case "dockerfile": + return "Docker" + case "docker-compose": + return "Docker" + case "tex": + return "LaTeX" + case "gql": + return "GraphQL" + default: + return language + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml index c937931..61b40fa 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -30,7 +30,7 @@ Item { radius: Tokens.rounding.medium color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth - implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + Tokens.padding.medium * 2 + implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2 anchors.right: root.isUser ? parent.right : undefined // User messages stay a plain editable text field. @@ -92,10 +92,10 @@ Item { id: blocks visible: !root.isUser + anchors.topMargin: Tokens.padding.medium anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right - anchors.margins: Tokens.padding.medium blocks: root.segment.markdown } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml index f9654e7..834b66d 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml @@ -29,6 +29,7 @@ Column { language: modelData.language code: modelData.code anchors.right: parent.right + anchors.margins: Tokens.spacing.small anchors.left: parent.left } } @@ -42,6 +43,7 @@ Column { latex: modelData.latex anchors.right: parent.right anchors.left: parent.left + anchors.margins: Tokens.padding.medium } } @@ -54,6 +56,7 @@ Column { color: Colors.palette.m3onSurface anchors.right: parent.right anchors.left: parent.left + anchors.margins: Tokens.padding.medium text: modelData.text textFormat: Text.MarkdownText font.bold: true @@ -77,6 +80,7 @@ Column { color: Colors.palette.m3onSurface anchors.right: parent.right anchors.left: parent.left + anchors.margins: Tokens.padding.medium text: modelData.text textFormat: Text.MarkdownText font.pointSize: Tokens.font.size.smaller diff --git a/Modules/Settings/Common/DialogRowButton.qml b/Modules/Settings/Common/DialogRowButton.qml index 1d4d547..96ada26 100644 --- a/Modules/Settings/Common/DialogRowButton.qml +++ b/Modules/Settings/Common/DialogRowButton.qml @@ -17,11 +17,14 @@ Item { property int horizontalContentMargin required property string icon required property string label + property string subtext: "" property bool open property real openHeight: Math.min(rootParent.height * 0.8, 600) property real openWidth: Math.min(rootParent.width * 0.8, 400) required property Item rootParent property bool separateContent + property bool first: false + property bool last: false signal accepted signal cancelled @@ -44,8 +47,7 @@ Item { color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer Behavior on color { - CAnim { - } + CAnim {} } } @@ -131,12 +133,13 @@ Item { id: dialogBg anchors.fill: parent - bottomLeftRadius: Tokens.rounding.largeIncreased - bottomRightRadius: Tokens.rounding.largeIncreased + bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + bottomRightRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall deformScale: 0 group: blobGroup opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) - radius: Tokens.rounding.extraSmall } RowButton { @@ -145,9 +148,11 @@ Item { anchors.left: parent.left anchors.right: parent.right color: "transparent" - height: Math.min(implicitHeight, parent.height) // Clamp to parent height due to overshoot anim + height: Math.min(implicitHeight, parent.height) icon: root.icon - last: true + subtext: root.subtext + last: root.last + first: root.first text: root.label transform: Matrix4x4 { diff --git a/Modules/Settings/PageCompRegistry.qml b/Modules/Settings/PageCompRegistry.qml index 22aa679..8fea9b4 100644 --- a/Modules/Settings/PageCompRegistry.qml +++ b/Modules/Settings/PageCompRegistry.qml @@ -11,6 +11,7 @@ import qs.Modules.Settings.Pages.Audio import qs.Modules.Settings.Pages.Apps import qs.Modules.Settings.Pages.Panels import qs.Modules.Settings.Pages.Panels.Bar +import qs.Modules.Settings.Pages.Panels.Sidebar import qs.Modules.Settings.Pages.Services import qs.Services @@ -23,13 +24,11 @@ QtObject { // Wallpaper & style StackPage { Component { - WallpaperPage { - } + WallpaperPage {} } Component { - WallpaperSelect { - } + WallpaperSelect {} } } }, @@ -37,32 +36,27 @@ QtObject { // Screenshot StackPage { Component { - ScreenshotPage { - } + ScreenshotPage {} } } }, // Connectivity Component { - PlaceholderComp { - } + PlaceholderComp {} }, Component { - PlaceholderComp { - } + PlaceholderComp {} }, Component { // Audio StackPage { Component { - AudioPage { - } + AudioPage {} } Component { - AppVolumes { - } + AppVolumes {} } } }, @@ -71,49 +65,45 @@ QtObject { Component { StackPage { Component { - PanelsPage { - } + PanelsPage {} } Component { - BarPanel { - } + BarPanel {} } Component { - DashboardPanel { - } + DashboardPanel {} } Component { - ResourcesPanel { - } + ResourcesPanel {} } Component { - LauncherPanel { - } + LauncherPanel {} } Component { - SidebarPanel { - } + SidebarPanel {} } // Bar sub pages Component { - BarTray { - } + BarTray {} } Component { - BarStatusIcons { - } + BarStatusIcons {} } Component { - BarClock { - } + BarClock {} + } + + // Sidebar sub pages + Component { + SidebarLlm {} } } }, @@ -122,18 +112,15 @@ QtObject { Component { StackPage { Component { - AppsPage { - } + AppsPage {} } Component { - AllApps { - } + AllApps {} } Component { - AppInfo { - } + AppInfo {} } } }, @@ -142,13 +129,11 @@ QtObject { Component { StackPage { Component { - ServicesPage { - } + ServicesPage {} } Component { - NotificationsPage { - } + NotificationsPage {} } } }, @@ -157,15 +142,13 @@ QtObject { Component { StackPage { Component { - AboutPage { - } + AboutPage {} } } } ] readonly property Component placeholderComp: Component { - PlaceholderComp { - } + PlaceholderComp {} } component PlaceholderComp: Item { diff --git a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml index ae17efa..991c1b2 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml @@ -55,6 +55,7 @@ PageBase { enabled: Object.keys(model).length > 0 header: qsTr("Add new entry") icon: "add" + last: true label: qsTr("Add entry") model: { const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id)); diff --git a/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml new file mode 100644 index 0000000..2c91863 --- /dev/null +++ b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml @@ -0,0 +1,95 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Modules.Settings.Common + +PageBase { + id: root + + readonly property var schemes: [ + { + id: "oneDark", + label: qsTr("One Dark") + }, + { + id: "nord", + label: qsTr("Nord") + }, + { + id: "dracula", + label: qsTr("Dracula") + }, + { + id: "githubDark", + label: qsTr("Github Dark") + }, + { + id: "solarizedDark", + label: qsTr("Solarized Dark") + }, + { + id: "monokai", + label: qsTr("Monokai") + }, + { + id: "gruvboxDark", + label: qsTr("Gruvbox Dark") + }, + { + id: "catppuccinMocha", + label: qsTr("Catppuccin Mocha") + }, + { + id: "tokyoNight", + label: qsTr("Tokyo Night") + }, + { + id: "ayuDark", + label: qsTr("Ayu Dark") + }, + { + id: "palenight", + label: qsTr("Pale Night") + } + ] + + isSubPage: true + title: qsTr("Sidebar") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Tokens.spacing.extraSmall / 2 + width: root.cappedWidth + + SectionHeader { + first: true + text: qsTr("Appearance") + } + + DialogSelectButton { + id: selectScheme + + acceptLabel: qsTr("Confirm") + enabled: Object.keys(model).length > 0 + header: qsTr("Select scheme") + first: true + last: true + icon: "add" + label: qsTr("Color scheme") + subtext: qsTr("Select the color scheme used for code blocks") + model: root.schemes + rootParent: root.flickable + + onAccepted: { + if (!selectedItem) + return; + + Config.llm.appearance.scheme = selectedItem; + } + } + } +} diff --git a/Modules/Settings/Pages/Panels/SidebarPanel.qml b/Modules/Settings/Pages/Panels/SidebarPanel.qml index e99a36e..9aef799 100644 --- a/Modules/Settings/Pages/Panels/SidebarPanel.qml +++ b/Modules/Settings/Pages/Panels/SidebarPanel.qml @@ -89,6 +89,7 @@ PageBase { header: qsTr("Add new entry") icon: "add" label: qsTr("Add entry") + last: true model: { const present = new Set(Config.utilities.quickToggles.values.map(item => item.id)); return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({ @@ -108,5 +109,18 @@ PageBase { }); } } + + SectionHeader { + text: qsTr("AI chat") + } + + NavRow { + text: qsTr("AI chat") + icon: "robot_2" + first: true + last: true + + onClicked: root.sState.openSubPage(9) + } } } diff --git a/Plugins/ZShell/Config/llm.hpp b/Plugins/ZShell/Config/llm.hpp index e7859f0..b6a1353 100644 --- a/Plugins/ZShell/Config/llm.hpp +++ b/Plugins/ZShell/Config/llm.hpp @@ -1,9 +1,21 @@ #pragma once #include "configobject.hpp" +#include +#include #include namespace ZShell::config { +class LlmAppearance : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(QString, scheme, QStringLiteral("tokyoNight")) + + public: + explicit LlmAppearance(QObject* parent = nullptr) : ConfigObject(parent) {} +}; + class Llm : public ConfigObject { Q_OBJECT QML_ANONYMOUS @@ -11,12 +23,12 @@ class Llm : public ConfigObject { CFG_PROPERTY(QString, endpoint, "http://localhost:8080") CFG_PROPERTY(QString, model, "") CFG_PROPERTY(double, temperature, 0.7) - // Whether tools are offered to the model. Models without a tool-calling - // template should run with this off. CFG_PROPERTY(bool, tools, true) + CONFIG_SUBOBJECT(LlmAppearance, appearance) public: - explicit Llm(QObject* parent = nullptr) : ConfigObject(parent) {} + explicit Llm(QObject* parent = nullptr) + : ConfigObject(parent), m_appearance(new LlmAppearance(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt index c3a358e..bca7ec0 100644 --- a/Plugins/ZShell/Llm/CMakeLists.txt +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -14,15 +14,253 @@ find_path(JKQTPlotter6_CMAKE_DIR DOC "Directory containing the JKQtPlotter cmake package files") find_package(JKQTMathText6 REQUIRED PATHS "${JKQTPlotter6_CMAKE_DIR}") -# Embed the vendored highlight queries as C++ string literals. -set(HIGHLIGHT_QUERY_LANGS c cpp python javascript typescript bash json rust go yaml toml sql) -set(HIGHLIGHT_QUERIES_HPP "${CMAKE_CURRENT_BINARY_DIR}/highlight-queries.hpp") -set(_hl_header "#pragma once\n\n// Vendored tree-sitter highlight queries (MIT; see the per-file\n// source headers in the highlight-queries/ directory).\nnamespace ZShell::llm::hq {\n") -foreach(_hl_lang IN LISTS HIGHLIGHT_QUERY_LANGS) - file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/${_hl_lang}.scm" _hl_src) - string(APPEND _hl_header "inline constexpr const char* ${_hl_lang} = R\"ZSQUERY(${_hl_src})ZSQUERY\";\n") +# --- tree-sitter grammar discovery (configure time) --- +# +# Discover installed tree-sitter grammars — system packages +# (libtree-sitter-.so) and the parsers Neovim's nvim-treesitter +# installs (~/.local/share/nvim/site/parser/*.so) — and pair each with +# highlight queries. Query sources, in priority order: +# 1. vendored highlight-queries/*.scm (version-pinned; see the +# per-file source headers, MIT), +# 2. Neovim's own queries (version-matched to its parsers), +# 3. tree-sitter/highlighting from GitHub (cached in the build dir). +# The result is embedded as highlight-queries.hpp. Re-run cmake to pick +# up grammars installed later. +# +# Candidate entry points are read from the .so with `nm` rather than +# assumed to be tree_sitter_; a missing entry point just makes that +# candidate fail at runtime. +function(_ts_entry_point out file) + # OUTPUT_VARIABLE + OUTPUT_QUIET loses the output on CMake 4, so + # capture through a temp file. + get_filename_component(_ts_nm_base "${file}" NAME) + set(_ts_nm_file "${CMAKE_CURRENT_BINARY_DIR}/ts-entry-${_ts_nm_base}") + execute_process( + COMMAND nm -D --defined-only "${file}" + RESULT_VARIABLE _ts_nm_rc + OUTPUT_FILE "${_ts_nm_file}" + ERROR_FILE "${_ts_nm_file}.err") + set(_ts_sym "tree_sitter_missing") + if(_ts_nm_rc EQUAL 0 AND EXISTS "${_ts_nm_file}") + file(READ "${_ts_nm_file}" _ts_nm_out) + file(REMOVE "${_ts_nm_file}" "${_ts_nm_file}.err") + # The library also exports the external scanner functions; the + # entry point is the one without the _external suffix. + string(REGEX MATCHALL " T tree_sitter_[A-Za-z0-9_]+" _ts_syms "${_ts_nm_out}") + foreach(_ts_s IN LISTS _ts_syms) + string(REPLACE " T " "" _ts_s "${_ts_s}") + if(NOT _ts_s MATCHES "_external") + set(_ts_sym "${_ts_s}") + break() + endif() + endforeach() + endif() + set(${out} "${_ts_sym}" PARENT_SCOPE) +endfunction() + +# Append a query text to (PARENT_SCOPE) unless it duplicates a +# hash in . The text is written to the build tree right away: +# the s-expression `;` comments would split CMake list items and the +# query `(` `)` break bracket arguments, so query text only ever lives +# in variables and files, never in lists. +# Expand Neovim's "; inherits:" directives by appending the inherited +# query set's highlights file (recursively; a visited set prevents +# cycles). Several languages are stubs that inherit the real query +# (html inherits html_tags, qmljs inherits ecma, ...). +function(_ts_expand_inherits query_dir text outVar) + set(result "${text}") + set(_visited "") + set(_depth 0) + while(_depth LESS 8) + # Do not match the leading `;`: a MATCHALL result that itself + # contains a semicolon is re-split into list items. + string(REGEX MATCHALL "inherits:[ \t]*[A-Za-z0-9_]+" _inh "${result}") + if(NOT _inh) + break() + endif() + set(_added FALSE) + foreach(_entry IN LISTS _inh) + string(REGEX REPLACE "^inherits:[ \t]*" "" _name "${_entry}") + set(_file "${query_dir}/${_name}/highlights.scm") + if(NOT EXISTS "${_file}" OR _file IN_LIST _visited) + continue() + endif() + list(APPEND _visited "${_file}") + file(READ "${_file}" _inh_text) + string(APPEND result "\n${_inh_text}") + set(_added TRUE) + endforeach() + if(NOT _added) + break() + endif() + math(EXPR _depth "${_depth} + 1") + endwhile() + set(${outVar} "${result}" PARENT_SCOPE) +endfunction() + +function(_ts_add_query sid text filesVar hashesVar) + # filesVar/hashesVar hold the caller's variable names. + set(files "${${filesVar}}") + set(hashes "${${hashesVar}}") + string(SHA256 _ts_qh "${text}") + if(_ts_qh IN_LIST hashes) + return() + endif() + list(APPEND hashes "${_ts_qh}") + list(LENGTH files _ts_qi) + set(_ts_qfile "${CMAKE_CURRENT_BINARY_DIR}/ts-queries/${sid}_${_ts_qi}.scm") + file(WRITE "${_ts_qfile}" "${text}") + list(APPEND files "${_ts_qfile}") + set(${filesVar} "${files}" PARENT_SCOPE) + set(${hashesVar} "${hashes}" PARENT_SCOPE) +endfunction() + +set(_ts_candidates "") # entries: ||| +file(GLOB _ts_sys_files + "/usr/lib/libtree-sitter-*.so" "/usr/local/lib/libtree-sitter-*.so") +foreach(_ts_file IN LISTS _ts_sys_files) + get_filename_component(_ts_name "${_ts_file}" NAME) + string(REGEX REPLACE "^libtree-sitter-(.+)\.so$" "\\1" _ts_id "${_ts_name}") + string(REPLACE "-" "_" _ts_sid "${_ts_id}") + _ts_entry_point(_ts_sym "${_ts_file}") + list(APPEND _ts_candidates + "${_ts_id}|${_ts_sid}|libtree-sitter-${_ts_id}.so|${_ts_sym}") endforeach() -string(APPEND _hl_header "}\n") +set(_ts_nvim_parser_dirs + "$ENV{HOME}/.local/share/nvim/site/parser" + "$ENV{HOME}/.local/share/nvim/runtime/parser" + "$ENV{HOME}/.local/share/nvim/lazy/nvim-treesitter/parser") +set(_ts_nvim_query_dirs + "$ENV{HOME}/.local/share/nvim/site/queries" + "$ENV{HOME}/.local/share/nvim/lazy/nvim-treesitter/runtime/queries") +foreach(_ts_dir IN LISTS _ts_nvim_parser_dirs) + file(GLOB _ts_dir_files "${_ts_dir}/*.so") + foreach(_ts_file IN LISTS _ts_dir_files) + get_filename_component(_ts_name "${_ts_file}" NAME) + string(REGEX REPLACE "\\.so$" "" _ts_id "${_ts_name}") + string(REPLACE "-" "_" _ts_sid "${_ts_id}") + _ts_entry_point(_ts_sym "${_ts_file}") + list(APPEND _ts_candidates + "${_ts_id}|${_ts_sid}|${_ts_file}|${_ts_sym}") + endforeach() +endforeach() + +set(_ts_ids "") +foreach(_ts_c IN LISTS _ts_candidates) + string(REPLACE "|" ";" _ts_parts "${_ts_c}") + list(GET _ts_parts 0 _ts_id) + if(NOT _ts_id IN_LIST _ts_ids) + list(APPEND _ts_ids "${_ts_id}") + endif() +endforeach() +list(SORT _ts_ids) + +set(HIGHLIGHT_QUERIES_HPP "${CMAKE_CURRENT_BINARY_DIR}/highlight-queries.hpp") +set(_hl_header +"#pragma once\n\n// Generated by CMake. Discoverd tree-sitter grammars and their\n// highlight queries: vendored highlight-queries/*.scm (MIT), Neovim\n// nvim-treesitter queries, and tree-sitter/highlighting (MIT).\nnamespace ZShell::llm::hq {\nstruct Candidate { const char* lib; const char* symbol; };\nstruct Grammar { const char* id; int nCandidates; const Candidate* candidates; int nQueries; const char* const* queries; };\n") +set(_ts_grammar_rows "") +set(_ts_vendored + c cpp python javascript typescript tsx bash json rust go yaml toml sql) +foreach(_ts_id IN LISTS _ts_ids) + string(REPLACE "-" "_" _ts_sid "${_ts_id}") + + # Query candidates in priority order (deduped; identical texts are + # skipped, the nvim site and plugin copies are usually the same + # file). + set(_ts_qfiles "") + set(_ts_qhashes "") + if(_ts_id STREQUAL "cpp") + # The C++ grammar is a superset of C and its query only covers + # the C++ delta; base C coverage comes from the C query. + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/c.scm" _ts_qa) + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/cpp.scm" _ts_qb) + _ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes) + elseif(_ts_id STREQUAL "typescript" OR _ts_id STREQUAL "tsx") + # The TS grammars reuse the JS node names; the JS query usually + # compiles against them and gives full coverage. + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/javascript.scm" _ts_qa) + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/typescript.scm" _ts_qb) + _ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes) + _ts_add_query(${_ts_sid} "${_ts_qb}" _ts_qfiles _ts_qhashes) + elseif(_ts_id IN_LIST _ts_vendored) + file(READ + "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/${_ts_id}.scm" _ts_q) + _ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes) + endif() + foreach(_ts_qd IN LISTS _ts_nvim_query_dirs) + if(EXISTS "${_ts_qd}/${_ts_id}/highlights.scm") + file(READ "${_ts_qd}/${_ts_id}/highlights.scm" _ts_q) + _ts_expand_inherits("${_ts_qd}" "${_ts_q}" _ts_q) + _ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes) + endif() + endforeach() + if(NOT _ts_qfiles) + # Last resort: fetch from the tree-sitter/highlighting repo. + # Version-skewed against locally installed grammars, so only + # used when nothing local exists. Cached; offline builds simply + # drop the language. + set(_ts_dl "${CMAKE_CURRENT_BINARY_DIR}/ts-query-downloads/${_ts_sid}.scm") + if(NOT EXISTS "${_ts_dl}") + file(DOWNLOAD + "https://raw.githubusercontent.com/tree-sitter/highlighting/main/queries/${_ts_id}/highlight.scm" + "${_ts_dl}" STATUS _ts_dl_status TIMEOUT 30) + list(GET _ts_dl_status 0 _ts_dl_rc) + if(NOT _ts_dl_rc EQUAL 0) + file(REMOVE "${_ts_dl}") + endif() + endif() + if(EXISTS "${_ts_dl}") + file(READ "${_ts_dl}" _ts_q) + _ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes) + endif() + endif() + list(LENGTH _ts_qfiles _ts_nq) + if(_ts_nq EQUAL 0) + continue() + endif() + + # Emit query sources. + set(_ts_qn 0) + set(_ts_q_ptrs "") + foreach(_ts_qfile IN LISTS _ts_qfiles) + file(READ "${_ts_qfile}" _ts_q) + string(APPEND _hl_header + "inline constexpr const char* q_${_ts_sid}_${_ts_qn} = R\"ZSQUERY(${_ts_q})ZSQUERY\";\n") + string(APPEND _ts_q_ptrs "q_${_ts_sid}_${_ts_qn}, ") + math(EXPR _ts_qn "${_ts_qn} + 1") + endforeach() + string(APPEND _hl_header + "inline constexpr const char* const q_${_ts_sid}[] = { ${_ts_q_ptrs} };\n") + + # Emit library candidates (system package first, then Neovim). + string(APPEND _hl_header "inline constexpr Candidate cand_${_ts_sid}[] = {\n") + set(_ts_nc 0) + foreach(_ts_c IN LISTS _ts_candidates) + string(REPLACE "|" ";" _ts_parts "${_ts_c}") + list(GET _ts_parts 0 _ts_cid) + if(_ts_cid STREQUAL _ts_id) + list(GET _ts_parts 2 _ts_lib) + list(GET _ts_parts 3 _ts_sym) + string(APPEND _hl_header + " { R\"ZSLIB(${_ts_lib})ZSLIB\", R\"ZSSYM(${_ts_sym})ZSSYM\" },\n") + math(EXPR _ts_nc "${_ts_nc} + 1") + endif() + endforeach() + string(APPEND _hl_header "};\n") + if(_ts_nc EQUAL 0) + # Queries but no library: pointless, drop the language. + string(APPEND _hl_header "") # (arrays stay; grammar row is skipped) + continue() + endif() + + list(APPEND _ts_grammar_rows + "{ \"${_ts_id}\", ${_ts_nc}, cand_${_ts_sid}, ${_ts_nq}, q_${_ts_sid} },") +endforeach() +string(APPEND _hl_header "inline constexpr Grammar grammars[] = {\n") +foreach(_ts_row IN LISTS _ts_grammar_rows) + string(APPEND _hl_header " ${_ts_row}\n") +endforeach() +string(APPEND _hl_header "};\n}\n") file(WRITE "${HIGHLIGHT_QUERIES_HPP}" "${_hl_header}") # Embed the vendored Latin Modern fonts (GUST Font License; provenance diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp index e8aff1f..73936b2 100644 --- a/Plugins/ZShell/Llm/chatstore.cpp +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -4,14 +4,18 @@ #include "message.hpp" #include "segment.hpp" +#include #include #include #include #include +#include #include #include #include #include +#include +#include #include #include @@ -48,6 +52,33 @@ QString sqlText(const QString& value) { return value; } +// Plain data for one session's messages, fetched on a worker thread +// and turned into the QObject tree on the GUI thread. Messages are +// ordered as the model displays them (newest first). +struct SegmentRow { + QString type; + QString text; + QString name; + QString toolCallId; + QString arguments; + QString result; + int status = 0; + qint64 elapsedMs = 0; + qint64 timestamp = 0; +}; + +struct GenerationRow { + qint64 timestamp = 0; + bool active = false; + QVector segments; +}; + +struct MessageRow { + bool user = false; + qint64 timestamp = 0; + QVector generations; +}; + } // namespace ChatStore::ChatStore(QObject* parent) @@ -69,16 +100,16 @@ QSqlDatabase ChatStore::db() const { } void ChatStore::openDb() { - const QString path = + m_dbPath = QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation) + QStringLiteral("/zshell/chats.sqlite"); - QDir().mkpath(QFileInfo(path).absolutePath()); + QDir().mkpath(QFileInfo(m_dbPath).absolutePath()); QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); - db.setDatabaseName(path); + db.setDatabaseName(m_dbPath); if (!db.open()) { - qWarning() << "ChatStore: failed to open database" << path << ":" + qWarning() << "ChatStore: failed to open database" << m_dbPath << ":" << db.lastError().text(); return; } @@ -250,8 +281,13 @@ void ChatStore::setLlmClient(LlmClient* client) { void ChatStore::persist(ChatSession* session) { if (!session || !m_sessions.contains(session)) return; + if (!session->isLoaded()) { + // Saving now would persist an incomplete model and wipe the + // stored history; run it again when the load lands. + m_pendingPersists.insert(session); + return; + } session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); - session->ensureLoaded(); if (!saveSession(session)) return; sortAndNotify(); @@ -386,90 +422,203 @@ bool ChatStore::saveSession(ChatSession* session) { } return ok; } - void ChatStore::loadMessagesInto(ChatSession* session) { - // Newest first so the model receives rows in display order. - QSqlQuery query(db()); - query.prepare( - "SELECT id, role, timestamp FROM messages WHERE session_id = :id " - "ORDER BY rowid DESC"); - query.bindValue(":id", session->id()); - if (!query.exec()) { - qWarning() << "ChatStore: failed to load messages for" << session->id() - << ":" << query.lastError().text(); + if (!session) return; - } - auto* model = session->messagesModel(); - QList messages; - while (query.next()) { - const int messageId = query.value(0).toInt(); - auto* message = model->createMessage( - query.value(1).toString() == QLatin1String("user") - ? ChatMessage::Role::User - : ChatMessage::Role::Assistant, - query.value(2).toLongLong()); - QSqlQuery generationQuery(db()); - generationQuery.prepare( - "SELECT id, timestamp, is_active FROM generations " - "WHERE message_id = :mid ORDER BY rowid"); - generationQuery.bindValue(":mid", messageId); - int activeIndex = 0; - if (generationQuery.exec()) { - int index = 0; - while (generationQuery.next()) { - auto* generation = message->addGeneration( - generationQuery.value(1).toLongLong()); - QSqlQuery segmentQuery(db()); - segmentQuery.prepare( - "SELECT type, text, name, tool_call_id, arguments, " - "result, status, elapsed_ms, timestamp FROM segments " - "WHERE generation_id = :gid ORDER BY rowid"); - segmentQuery.bindValue( - ":gid", generationQuery.value(0).toInt()); - if (segmentQuery.exec()) { - while (segmentQuery.next()) { - auto* segment = new LlmSegment( - segmentTypeFromName( - segmentQuery.value(0).toString()), - segmentQuery.value(8).toLongLong(), - generation); - segment->setText( - segmentQuery.value(1).toString()); - segment->setName( - segmentQuery.value(2).toString()); - segment->setToolCallId( - segmentQuery.value(3).toString()); - segment->appendArguments( - segmentQuery.value(4).toString()); - segment->setResult( - segmentQuery.value(5).toString()); - segment->setStatus( - static_cast( - segmentQuery.value(6).toInt())); - segment->restore( - segmentQuery.value(7).toLongLong()); - generation->addSegment(segment); + const QString sessionId = session->id(); + const QString path = m_dbPath; + + // SQL on a worker thread (its own connection; QSqlDatabase objects + // are thread-affine). Rows come back as plain data. + QThreadPool::globalInstance()->start( + [store = QPointer(this), + session = QPointer(session), sessionId, path]() { + QList rows; + const QString connName = QUuid::createUuid().toString(); + { + QSqlDatabase db = QSqlDatabase::addDatabase( + QStringLiteral("QSQLITE"), connName); + db.setDatabaseName(path); + if (db.open()) { + // Tolerate the GUI thread writing while we read. + QSqlQuery busy(db); + busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000")); + // Newest first so the model receives rows in + // display order. + QSqlQuery query(db); + query.prepare( + "SELECT id, role, timestamp FROM messages " + "WHERE session_id = :id ORDER BY rowid DESC"); + query.bindValue(":id", sessionId); + if (!query.exec()) { + qWarning() + << "ChatStore: failed to load messages for" + << sessionId << ":" + << query.lastError().text(); + } else { + while (query.next()) { + const int messageId = query.value(0).toInt(); + MessageRow message; + message.user = + query.value(1).toString() == + QLatin1String("user"); + message.timestamp = query.value(2).toLongLong(); + QSqlQuery generationQuery(db); + generationQuery.prepare( + "SELECT id, timestamp, is_active FROM " + "generations WHERE message_id = :mid " + "ORDER BY rowid"); + generationQuery.bindValue(":mid", messageId); + if (generationQuery.exec()) { + while (generationQuery.next()) { + GenerationRow generation; + generation.timestamp = + generationQuery.value(1).toLongLong(); + generation.active = + generationQuery.value(2).toInt() != 0; + QSqlQuery segmentQuery(db); + segmentQuery.prepare( + "SELECT type, text, name, tool_call_id, " + "arguments, result, status, elapsed_ms, " + "timestamp FROM segments WHERE " + "generation_id = :gid ORDER BY rowid"); + segmentQuery.bindValue( + ":gid", + generationQuery.value(0).toInt()); + if (segmentQuery.exec()) { + while (segmentQuery.next()) { + SegmentRow segment; + segment.type = + segmentQuery.value(0).toString(); + segment.text = + segmentQuery.value(1).toString(); + segment.name = + segmentQuery.value(2).toString(); + segment.toolCallId = + segmentQuery.value(3).toString(); + segment.arguments = + segmentQuery.value(4).toString(); + segment.result = + segmentQuery.value(5).toString(); + segment.status = + segmentQuery.value(6).toInt(); + segment.elapsedMs = + segmentQuery.value(7).toLongLong(); + segment.timestamp = + segmentQuery.value(8).toLongLong(); + generation.segments.append(segment); + } + } else { + qWarning() + << "ChatStore: failed to load " + "segments for generation" + << generationQuery.value(0).toInt() + << ":" + << segmentQuery.lastError().text(); + } + message.generations.append(generation); + } + } else { + qWarning() + << "ChatStore: failed to load generations " + "for message" + << messageId << ":" + << generationQuery.lastError().text(); + } + rows.append(message); + } } + db.close(); } else { - qWarning() << "ChatStore: failed to load segments for " - << "generation" - << generationQuery.value(0).toInt() - << ":" - << segmentQuery.lastError().text(); + qWarning() + << "ChatStore: failed to open database for load:" + << db.lastError().text(); } - if (generationQuery.value(2).toInt() != 0) - activeIndex = index; - ++index; } - } else { - qWarning() << "ChatStore: failed to load generations for message" - << messageId << ":" - << generationQuery.lastError().text(); - } - message->setActiveGeneration(activeIndex); - messages.append(message); - } - session->adoptMessages(messages); + // Remove only once every QSqlDatabase copy and query is gone; + // while any reference is alive Qt refuses the removal and the + // connection is left dangling in a broken state. + QSqlDatabase::removeDatabase(connName); + + // Build the object tree on the GUI thread. Deliver through + // the app instance (never destroyed) and re-check the + // pointers there: posting to `store` from the pool thread + // would race with its destruction. + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [store, session, rows = std::move(rows)]() mutable { + ChatStore* st = store; + ChatSession* s = session; + if (!st || !s) + return; + + // Rows fetched from disk; newest first. + auto* model = s->model(); + if (!model) + return; + QList messages; + for (const MessageRow& row : rows) { + auto* message = model->createMessage( + row.user ? ChatMessage::Role::User + : ChatMessage::Role::Assistant, + row.timestamp); + int activeIndex = 0; + for (int i = 0; i < row.generations.size(); ++i) { + const GenerationRow& generationRow = + row.generations.at(i); + auto* generation = + message->addGeneration(generationRow.timestamp); + for (const SegmentRow& segmentRow : + generationRow.segments) { + auto* segment = new LlmSegment( + segmentTypeFromName(segmentRow.type), + segmentRow.timestamp, + generation); + segment->setText(segmentRow.text); + segment->setName(segmentRow.name); + segment->setToolCallId(segmentRow.toolCallId); + segment->appendArguments(segmentRow.arguments); + segment->setResult(segmentRow.result); + segment->setStatus( + static_cast( + segmentRow.status)); + segment->restore(segmentRow.elapsedMs); + generation->addSegment(segment); + } + if (generationRow.active) + activeIndex = i; + } + message->setActiveGeneration(activeIndex); + messages.append(message); + } + // Rows added live while the load was in flight are + // newer than anything on disk; keep them in front. + if (model->rowCount() > 0) { + QList live = messages; + for (int r = 0; r < model->rowCount(); ++r) + live.prepend(model->at(r)); + messages = live; + } + if (!messages.isEmpty() || model->rowCount() > 0) + s->adoptMessages(messages); + + // Mark loaded only once the model holds both the + // fetched history and the rows added live while the + // load ran, so a deferred startGeneration (triggered + // by loaded()) builds its context from the complete + // conversation. + s->markLoaded(); + + if (s->takeClearPending()) { + // Cleared while the load was in flight; drop + // everything now that the model is populated. + s->clear(); + } else if (st->m_pendingPersists.remove(s)) { + st->persist(s); + } + }, + Qt::QueuedConnection); + }); } void ChatStore::load() { diff --git a/Plugins/ZShell/Llm/chatstore.hpp b/Plugins/ZShell/Llm/chatstore.hpp index 472f4a4..f8fe539 100644 --- a/Plugins/ZShell/Llm/chatstore.hpp +++ b/Plugins/ZShell/Llm/chatstore.hpp @@ -3,6 +3,7 @@ #include "session.hpp" #include +#include #include #include #include @@ -38,6 +39,9 @@ class ChatStore : public QObject { void persist(ChatSession* session); void saveMeta(ChatSession* session); + // Loads the session's messages from the database. The SQL runs on a + // worker thread; the object tree is built and the model updated on + // the GUI thread when it arrives (ChatSession::loaded). void loadMessagesInto(ChatSession* session); Q_SIGNALS: @@ -56,6 +60,10 @@ class ChatStore : public QObject { QList m_sessions; LlmClient* m_llmClient = nullptr; QString m_connectionName; + QString m_dbPath; + // Sessions whose persist() ran before their messages finished + // loading; persisted once the load lands. + QSet m_pendingPersists; [[nodiscard]] QSqlDatabase db() const; }; diff --git a/Plugins/ZShell/Llm/codehighlighter.cpp b/Plugins/ZShell/Llm/codehighlighter.cpp index 7f31413..ea99e54 100644 --- a/Plugins/ZShell/Llm/codehighlighter.cpp +++ b/Plugins/ZShell/Llm/codehighlighter.cpp @@ -4,10 +4,17 @@ #include +#include +#include #include #include +#include +#include +#include +#include #include +#include #include namespace ZShell::llm { @@ -57,77 +64,21 @@ const char* roleName(Role role) { using LanguageFn = const TSLanguage* (*)(); -// Query sources, indexed by Grammar::queries. -struct QuerySources { - std::vector sources; - int c, cpp, python, javascript, typescriptExtended, typescript, bash, json, rust, go, yaml, toml, sql, cppExtended; -}; - -const QuerySources& querySources() { - static const QuerySources sources = [] { - QuerySources qs; - // javascript + typescript concatenated: the TS grammar reuses the - // JS node names, so the JS query usually compiles against it and - // gives full coverage; the TS-only query is the fallback. - const std::string tsExtended = - std::string(hq::javascript) + "\n" + std::string(hq::typescript); - // Same for cpp: the C++ grammar is a superset of C, and the C++ - // query only covers the C++-specific delta. Base C coverage - // (keywords, types, calls, strings, comments) comes from the C - // query. - const std::string cppExtended = - std::string(hq::c) + "\n" + std::string(hq::cpp); - qs.sources.reserve(14); - qs.sources.push_back(hq::c); - qs.sources.push_back(hq::cpp); - qs.sources.push_back(hq::python); - qs.sources.push_back(hq::javascript); - qs.sources.push_back(tsExtended); - qs.sources.push_back(hq::typescript); - qs.sources.push_back(hq::bash); - qs.sources.push_back(hq::json); - qs.sources.push_back(hq::rust); - qs.sources.push_back(hq::go); - qs.sources.push_back(hq::yaml); - qs.sources.push_back(hq::toml); - qs.sources.push_back(hq::sql); - qs.sources.push_back(cppExtended); - qs.c = 0; - qs.cpp = 1; - qs.python = 2; - qs.javascript = 3; - qs.typescriptExtended = 4; - qs.typescript = 5; - qs.bash = 6; - qs.json = 7; - qs.rust = 8; - qs.go = 9; - qs.yaml = 10; - qs.toml = 11; - qs.sql = 12; - qs.cppExtended = 13; - return qs; - }(); - return sources; -} - +// Grammar registry generated by CMake from the installed grammars and +// their highlight queries (see CMakeLists.txt). const QHash& grammars() { static const QHash grammars = [] { - const auto& qs = querySources(); QHash map; - map.insert("c", {"libtree-sitter-c.so", "tree_sitter_c", {qs.c}}); - map.insert("cpp", {"libtree-sitter-cpp.so", "tree_sitter_cpp", {qs.cppExtended, qs.cpp}}); - map.insert("python", {"libtree-sitter-python.so", "tree_sitter_python", {qs.python}}); - map.insert("javascript", {"libtree-sitter-javascript.so", "tree_sitter_javascript", {qs.javascript}}); - map.insert("typescript", {"libtree-sitter-typescript.so", "tree_sitter_typescript", {qs.typescriptExtended, qs.typescript}}); - map.insert("tsx", {"libtree-sitter-tsx.so", "tree_sitter_tsx", {qs.typescriptExtended, qs.typescript}}); - map.insert("bash", {"libtree-sitter-bash.so", "tree_sitter_bash", {qs.bash}}); - map.insert("json", {"libtree-sitter-json.so", "tree_sitter_json", {qs.json}}); - map.insert("rust", {"libtree-sitter-rust.so", "tree_sitter_rust", {qs.rust}}); - map.insert("go", {"libtree-sitter-go.so", "tree_sitter_go", {qs.go}}); - map.insert("yaml", {"libtree-sitter-yaml.so", "tree_sitter_yaml", {qs.yaml}}); - map.insert("toml", {"libtree-sitter-toml.so", "tree_sitter_toml", {qs.toml}}); - map.insert("sql", {"libtree-sitter-sql.so", "tree_sitter_sql", {qs.sql}}); + for (const auto& g : hq::grammars) { + CodeHighlighter::Grammar grammar; + for (int i = 0; i < g.nCandidates; ++i) { + grammar.libs.push_back(g.candidates[i].lib); + grammar.symbols.push_back(g.candidates[i].symbol); + } + for (int i = 0; i < g.nQueries; ++i) + grammar.queries.push_back(g.queries[i]); + map.insert(g.id, std::move(grammar)); + } return map; }(); return grammars; @@ -170,6 +121,8 @@ const QHash& CodeHighlighter::aliases() { map.insert("shell-session", "bash"); map.insert("zsh", "bash"); map.insert("console", "bash"); + map.insert("qml", "qmljs"); + map.insert("qmljs", "qmljs"); map.insert("json", "json"); map.insert("jsonc", "json"); map.insert("rust", "rust"); @@ -190,10 +143,6 @@ const QHash& CodeHighlighter::aliases() { return aliases; } -const std::vector& CodeHighlighter::querySources() const { - return hl::querySources().sources; -} - uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) { const QString n = QString::fromUtf8(name, length); if (n == "comment") @@ -204,15 +153,18 @@ uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) { return hl::Role::String; if (n.startsWith("number")) return hl::Role::Number; - if (n.startsWith("constant")) + if (n.startsWith("constant") || n == "boolean" || n == "bool" + || n.startsWith("character")) return hl::Role::Constant; if (n.startsWith("keyword")) return hl::Role::Keyword; if (n == "type" || n.startsWith("type.")) return hl::Role::Type; - if (n == "namespace" || n == "module") + if (n.startsWith("namespace") || n.startsWith("module") + || n == "support.type" || n == "support.namespace") return hl::Role::Type; - if (n.startsWith("function") || n == "constructor") + if (n.startsWith("function") || n == "constructor" + || n.startsWith("support.function")) return hl::Role::Function; if (n == "method" || n == "method.builtin") return hl::Role::Method; @@ -220,7 +172,8 @@ uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) { return hl::Role::Macro; if (n.startsWith("preproc")) return hl::Role::Preproc; - if (n == "operator" || n == "punctuation.operator" || n.startsWith("operator.")) + if (n == "operator" || n == "punctuation.operator" || n.startsWith("operator.") + || n.startsWith("punctuation")) return hl::Role::Operator; if (n == "property" || n == "field" || n.startsWith("property.")) return hl::Role::Property; @@ -228,6 +181,13 @@ uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) { return hl::Role::Label; if (n.startsWith("attribute") || n == "annotation") return hl::Role::Attribute; + // HTML tag names, CSS variables and friends. + if (n == "tag" || (n.startsWith("tag.") && n != "tag.delimiter")) + return hl::Role::Keyword; + if (n.startsWith("variable")) + return hl::Role::Constant; + if (n.startsWith("support")) + return hl::Role::Function; return hl::Role::None; } @@ -235,15 +195,45 @@ const char* CodeHighlighter::roleName(uint8_t role) { return hl::roleName(static_cast(role)); } -QVariantList CodeHighlighter::highlight(const QString& code, const QString& language) const { +void CodeHighlighter::highlight( + const QString& code, const QString& language, QObject* target, int token) { + QThreadPool::globalInstance()->start([this, target, token, code, language]() { + const QVariantList spans = doHighlight(code, language); + // The target item may be long gone by now (delegates are + // recreated constantly while chats load); a destroyed target is + // simply skipped. Deliver through the app instance (never + // destroyed) and re-check there: posting to `target` from the + // pool thread would race with its destruction. + QPointer guard(target); + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [guard, token, spans]() { + if (!guard) + return; + // QML functions are only invokable by their generic + // QVariant overload, so pass untyped arguments. + QMetaObject::invokeMethod( + guard, "onHighlightSpans", + Q_ARG(QVariant, token), Q_ARG(QVariant, spans)); + }, + Qt::QueuedConnection); + }); +} + +QVariantList CodeHighlighter::doHighlight( + const QString& code, const QString& language) const { QVariantList spans; if (code.isEmpty()) return spans; - const QString id = aliases().value(language.trimmed().toLower()); - if (id.isEmpty()) - return spans; + const QString tag = language.trimmed().toLower(); + const QString id = [&] { + const QString alias = aliases().value(tag); + return alias.isEmpty() ? tag : alias; // unknown tags = grammar id + }(); const Grammar& grammar = hl::grammars().value(id); + if (grammar.libs.empty()) + return spans; // Guard against pathological blocks; highlighting is best-effort. static constexpr size_t kMaxBytes = 512 * 1024; @@ -251,59 +241,72 @@ QVariantList CodeHighlighter::highlight(const QString& code, const QString& lang if (static_cast(utf8.size()) > kMaxBytes) return spans; - State& state = m_states[id]; - if (state.bad) - return spans; - - if (!state.lang) { - // Missing library is retriable (it may be installed while the - // shell runs); ABI/query failures below are not. - state.lib = dlopen(grammar.lib.toUtf8().constData(), RTLD_NOW | RTLD_LOCAL); - if (!state.lib) - return spans; - auto* symbol = reinterpret_cast( - dlsym(state.lib, grammar.symbol.toUtf8().constData())); - if (!symbol) { - dlclose(state.lib); - state.lib = nullptr; - return spans; + const TSLanguage* lang = nullptr; + TSQuery* query = nullptr; + { + QMutexLocker locker(&m_stateMutex); + auto& state = m_states[id]; + // A missing library is retriable (it may be installed while the + // shell runs); an ABI mismatch on every candidate is not. Cache + // successes and permanent failures; leave retriable misses out. + if (!state || (!state->lang && !state->bad)) { + std::shared_ptr fresh = std::make_shared(); + bool abiMismatch = false; + for (size_t i = 0; i < grammar.libs.size(); ++i) { + void* lib = dlopen(grammar.libs[i].c_str(), + RTLD_NOW | RTLD_LOCAL); + if (!lib) + continue; + auto* symbol = reinterpret_cast( + dlsym(lib, grammar.symbols[i].c_str())); + if (!symbol) { + dlclose(lib); + continue; + } + const TSLanguage* candidate = symbol(); + const uint32_t version = ts_language_abi_version(candidate); + if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION || + version > TREE_SITTER_LANGUAGE_VERSION) { + dlclose(lib); + abiMismatch = true; + continue; + } + fresh->lib = lib; + fresh->lang = candidate; + break; + } + if (fresh->lang) { + // Candidates in priority order; first that compiles wins. + for (const char* source : grammar.queries) { + TSQueryError errorType = TSQueryErrorNone; + uint32_t errorOffset = 0; + TSQuery* candidate = ts_query_new( + static_cast(fresh->lang), + source, + static_cast(std::strlen(source)), + &errorOffset, + &errorType); + if (!candidate) + continue; + fresh->query = candidate; + break; + } + if (!fresh->query) + fresh->bad = true; + } else if (abiMismatch) { + fresh->bad = true; + } + if (fresh->lang || fresh->bad) + state = std::move(fresh); } - const TSLanguage* lang = symbol(); - const uint32_t version = ts_language_abi_version(lang); - if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION || - version > TREE_SITTER_LANGUAGE_VERSION) { - state.bad = true; + if (!state || state->bad || !state->lang) return spans; - } - state.lang = lang; - } - - if (!state.query) { - // Candidates in priority order; first that compiles wins. - for (const int candidate : grammar.queries) { - const std::string& source = - querySources()[static_cast(candidate)]; - TSQueryError errorType = TSQueryErrorNone; - uint32_t errorOffset = 0; - TSQuery* query = ts_query_new( - static_cast(state.lang), - source.data(), - static_cast(source.size()), - &errorOffset, - &errorType); - if (!query) - continue; - state.query = query; - break; - } - if (!state.query) { - state.bad = true; - return spans; - } + lang = static_cast(state->lang); + query = static_cast(state->query); } TSParser* parser = ts_parser_new(); - ts_parser_set_language(parser, static_cast(state.lang)); + ts_parser_set_language(parser, lang); TSTree* tree = ts_parser_parse_string( parser, nullptr, utf8.constData(), static_cast(utf8.size())); if (!tree) { @@ -311,7 +314,6 @@ QVariantList CodeHighlighter::highlight(const QString& code, const QString& lang return spans; } - TSQuery* query = static_cast(state.query); TSQueryCursor* cursor = ts_query_cursor_new(); ts_query_cursor_exec(cursor, query, ts_tree_root_node(tree)); diff --git a/Plugins/ZShell/Llm/codehighlighter.hpp b/Plugins/ZShell/Llm/codehighlighter.hpp index 659d764..8fb321d 100644 --- a/Plugins/ZShell/Llm/codehighlighter.hpp +++ b/Plugins/ZShell/Llm/codehighlighter.hpp @@ -1,11 +1,13 @@ #pragma once +#include #include #include #include #include #include +#include #include class QQmlEngine; @@ -15,37 +17,51 @@ namespace ZShell::llm { // Syntax highlighting for LLM code blocks via tree-sitter. // -// The tree-sitter runtime is linked. Grammar libraries -// (libtree-sitter-.so) are dlopen()'d lazily, so a missing -// grammar package degrades that language to plain text instead of -// breaking the build or the app. Highlight queries are embedded at -// build time (highlight-queries/*.scm, vendored from the grammar -// repos, MIT). +// The tree-sitter runtime is linked. Grammar libraries are dlopen()'d +// lazily, so a missing grammar degrades that language to plain text +// instead of breaking the build or the app. At configure time CMake +// discovers installed grammars (system packages plus the parsers +// Neovim's nvim-treesitter installs) and pairs each with highlight +// queries (vendored, Neovim's, or fetched from +// tree-sitter/highlighting); both are embedded in the generated +// highlight-queries.hpp. +// +// For each grammar the first loadable (ABI-compatible) library wins +// and the first query that compiles against it wins, so a version +// skew between a library and its query degrades gracefully. // // The fence language the LLM wrote (```cpp, ```python, ...) is mapped -// to a grammar through an alias table. +// to a grammar through an alias table; tags not in the table are used +// as grammar ids as-is. // -// highlight() returns a list of span maps: +// highlight() parses the code off the GUI thread and delivers a list of +// span maps by calling target's "onHighlightSpans(token, spans)" method +// (on the GUI thread): // { "start": int, "length": int, "kind": QString } // where kind is a semantic role (keyword, string, comment, number, // function, type, ...) that QML maps to theme colors. An empty list // means "no highlighting" (unknown language or grammar not installed). +// token is passed back unchanged so the caller can drop results for +// superseded code; a destroyed target is simply skipped. class CodeHighlighter : public QObject { Q_OBJECT QML_ELEMENT QML_SINGLETON public: - Q_INVOKABLE QVariantList highlight(const QString& code, const QString& language) const; + Q_INVOKABLE void highlight( + const QString& code, const QString& language, QObject* target, int token); static CodeHighlighter* create(QQmlEngine*, QJSEngine*); struct Grammar { - QString lib; // soname to dlopen - QString symbol; // tree_sitter_() entry point - // Candidate query sources, first wins. Lets typescript reuse - // the javascript query when it compiles against the grammar. - std::vector queries; // indices into querySources() + // Library candidates in priority order (system package, then + // Neovim copies); libs[i] pairs with symbols[i]. + std::vector libs; + std::vector symbols; + // Candidate query sources in priority order; first that + // compiles against the loaded grammar wins. + std::vector queries; }; private: @@ -57,12 +73,16 @@ class CodeHighlighter : public QObject { }; [[nodiscard]] static const QHash& aliases(); - [[nodiscard]] const std::vector& querySources() const; // Maps a tree-sitter capture name to a role index (0 = unstyled). [[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length); [[nodiscard]] static const char* roleName(uint8_t role); + // The parsing work; runs on worker threads, so the per-language + // state must be initialized under m_stateMutex and is shared as an + // immutable object afterwards. + [[nodiscard]] QVariantList doHighlight(const QString& code, const QString& language) const; - mutable QHash m_states; + mutable QHash> m_states; + mutable QMutex m_stateMutex; static CodeHighlighter* s_instance; }; diff --git a/Plugins/ZShell/Llm/mathtext.cpp b/Plugins/ZShell/Llm/mathtext.cpp index 4b87f30..975f68e 100644 --- a/Plugins/ZShell/Llm/mathtext.cpp +++ b/Plugins/ZShell/Llm/mathtext.cpp @@ -5,9 +5,13 @@ #include #include +#include #include #include #include +#include +#include +#include namespace ZShell::llm { @@ -15,6 +19,9 @@ namespace { // A few px of breathing room around the equation. constexpr int kRenderMargin = 2; constexpr unsigned int kResolutionDpi = 96; +// The render cache is unbounded between clears; cap it so a very long +// session with many distinct equations cannot grow it forever. +constexpr int kCacheLimit = 512; // Registers the embedded Latin Modern faces with the font database // (once per process) and reports which families became available. @@ -65,20 +72,23 @@ const LatinModern& loadLatinModern() { } // namespace LlmMathText::LlmMathText(QObject* parent) -: QObject(parent), m_renderer(parent, /* useFontsForGUI */ true) { +// No parent: the renderer is used from pool threads, and a parented +// QObject would taint children it creates there. +: QObject(parent), m_renderer(std::make_shared( + nullptr, /* useFontsForGUI */ true)) { // Latin Modern is the default font of modern LaTeX; use the embedded // faces instead of whatever the system happens to have installed. const LatinModern& fonts = loadLatinModern(); if (fonts.roman) - m_renderer.setFontRomanAndMath(QStringLiteral("LMRoman10"), - JKQTMathTextFontEncoding::MTFEUnicode); + m_renderer->setFontRomanAndMath(QStringLiteral("LMRoman10"), + JKQTMathTextFontEncoding::MTFEUnicode); if (fonts.math) { // Same pattern as JKQTMathText's useXITS(): the OpenType math // font supplies the math alphabet and operators from its MATH // table. - m_renderer.setFontMathRoman(QStringLiteral("Latin Modern Math"), - JKQTMathTextFontEncoding::MTFEUnicode); - m_renderer.setFallbackFontSymbols( + m_renderer->setFontMathRoman(QStringLiteral("Latin Modern Math"), + JKQTMathTextFontEncoding::MTFEUnicode); + m_renderer->setFallbackFontSymbols( QStringLiteral("Latin Modern Math"), JKQTMathTextFontEncoding::MTFEUnicode); } @@ -112,7 +122,28 @@ void LlmMathText::setDevicePixelRatio(qreal value) { reRender(); } +namespace { + +// Process-wide render cache; the key is the full render state, so +// re-opening a chat reuses already-rendered equations. Accessed on the +// GUI thread only. +struct MathRender { + bool ok = false; + QImage image; + QUrl url; + qreal width = 0; + qreal height = 0; +}; + +QHash& mathCache() { + static QHash cache; + return cache; +} + +} // namespace + void LlmMathText::reRender() { + ++m_requestId; if (m_latex.trimmed().isEmpty()) { m_image = QImage(); m_imageUrl = QUrl(); @@ -123,54 +154,97 @@ void LlmMathText::reRender() { return; } - m_renderer.setFontPointSize(m_fontPointSize); - m_renderer.setFontColor(m_color); - - const bool ok = m_renderer.parse( - m_latex, - JKQTMathText::LatexParser, - JKQTMathText::DefaultParseOptions); - if (!ok) { - m_image = QImage(); - m_imageUrl = QUrl(); - m_width = 0; - m_height = 0; - m_ok = false; + const QString key = m_latex + + QLatin1Char(0x1f) + m_color.name() + + QLatin1Char(0x1f) + QString::number(m_fontPointSize) + + QLatin1Char(0x1f) + QString::number(m_devicePixelRatio); + if (auto it = mathCache().find(key); it != mathCache().end()) { + m_image = it->image; + m_imageUrl = it->url; + m_width = it->width; + m_height = it->height; + m_ok = it->ok; Q_EMIT changed(); return; } - const QImage image = m_renderer.drawIntoImage( - /* drawBoxes */ false, - QColor(Qt::transparent), - kRenderMargin, - m_devicePixelRatio, - kResolutionDpi); - if (image.isNull()) { - m_image = QImage(); - m_imageUrl = QUrl(); - m_width = 0; - m_height = 0; - m_ok = false; - Q_EMIT changed(); + // A render is already running; it re-renders the latest state when + // it completes (id mismatch), so there is nothing to do here. + if (m_inFlight) return; - } + m_inFlight = true; - m_image = image; - QByteArray png; - { - QBuffer buffer(&png); - buffer.open(QIODevice::WriteOnly); - image.save(&buffer, "PNG"); - } - m_imageUrl = QUrl( - QStringLiteral("data:image/png;base64,") + QString::fromLatin1(png.toBase64())); - // drawIntoImage renders at devicePixelRatio; convert back to - // logical pixels. - m_width = image.width() / m_devicePixelRatio; - m_height = image.height() / m_devicePixelRatio; - m_ok = true; - Q_EMIT changed(); + const QString latex = m_latex; + const QColor color = m_color; + const double pointSize = m_fontPointSize; + const qreal dpr = m_devicePixelRatio; + // The worker captures the renderer by value (shared_ptr) so it + // stays alive even if this object is destroyed mid-render; it is + // only ever used by the single in-flight worker (m_inFlight), + // never concurrently. + auto renderer = m_renderer; + QThreadPool::globalInstance()->start([this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() { + MathRender render; + renderer->setFontPointSize(pointSize); + renderer->setFontColor(color); + if (renderer->parse( + latex, JKQTMathText::LatexParser, JKQTMathText::DefaultParseOptions)) { + const QImage image = renderer->drawIntoImage( + /* drawBoxes */ false, + QColor(Qt::transparent), + kRenderMargin, + dpr, + kResolutionDpi); + if (!image.isNull()) { + QByteArray png; + { + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + image.save(&buffer, "PNG"); + } + render.image = image; + render.url = QUrl( + QStringLiteral("data:image/png;base64,") + + QString::fromLatin1(png.toBase64())); + // drawIntoImage renders at devicePixelRatio; convert + // back to logical pixels. + render.width = image.width() / dpr; + render.height = image.height() / dpr; + render.ok = true; + } + } + // Deliver through the app instance (never destroyed) and + // re-check the pointer on the GUI thread: posting to `this` + // from the pool thread would race with its destruction. + QPointer guard(this); + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [guard, id, key, render = std::move(render)]() mutable { + LlmMathText* self = guard; + if (!self) + return; + self->m_inFlight = false; + if (id != self->m_requestId) { + // Superseded while the worker ran; render the + // latest state. + self->reRender(); + return; + } + if (render.ok) { + auto& cache = mathCache(); + if (cache.size() >= kCacheLimit) + cache.clear(); + cache.insert(key, render); + } + self->m_image = render.image; + self->m_imageUrl = render.url; + self->m_width = render.width; + self->m_height = render.height; + self->m_ok = render.ok; + Q_EMIT self->changed(); + }, + Qt::QueuedConnection); + }); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/mathtext.hpp b/Plugins/ZShell/Llm/mathtext.hpp index 3b713fc..ad25613 100644 --- a/Plugins/ZShell/Llm/mathtext.hpp +++ b/Plugins/ZShell/Llm/mathtext.hpp @@ -7,6 +7,8 @@ #include #include +#include + #include namespace ZShell::llm { @@ -14,7 +16,9 @@ namespace ZShell::llm { // QML wrapper around JKQTMathText (JKQtPlotter's LaTeX renderer). // // Parses a display-math string and renders it into a transparent -// QImage at the given device pixel ratio. QML displays the image +// QImage at the given device pixel ratio, off the GUI thread (with a +// process-wide cache keyed on the full render state, so re-opening a +// chat does not re-render the same equations). QML displays the image // (scaling it to the bubble width when needed) and falls back to the // raw LaTeX when parsing fails. class LlmMathText : public QObject { @@ -57,7 +61,9 @@ class LlmMathText : public QObject { private: void reRender(); - JKQTMathText m_renderer; + // Shared so an in-flight worker render keeps the renderer alive if + // this object (and its QML item) is destroyed mid-render. + std::shared_ptr m_renderer; QString m_latex; QColor m_color; double m_fontPointSize = 12.0; @@ -67,6 +73,10 @@ class LlmMathText : public QObject { qreal m_width = 0; qreal m_height = 0; bool m_ok = false; + // Bumps on every reRender; a delivery carrying an older id was + // superseded and is dropped. + int m_requestId = 0; + bool m_inFlight = false; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/messagemodel.cpp b/Plugins/ZShell/Llm/messagemodel.cpp index 534c409..d02c44a 100644 --- a/Plugins/ZShell/Llm/messagemodel.cpp +++ b/Plugins/ZShell/Llm/messagemodel.cpp @@ -2,6 +2,8 @@ #include "session.hpp" +#include + namespace ZShell::llm { ChatMessageModel::ChatMessageModel(ChatSession* session, QObject* parent) @@ -98,7 +100,12 @@ void ChatMessageModel::clear() { void ChatMessageModel::loadMessages(QList messages) { beginResetModel(); - qDeleteAll(m_messages); + // The new list may share rows with the current one (live rows kept + // in front of fetched rows); delete only what is truly gone. + for (ChatMessage* message : m_messages) + if (std::find(messages.begin(), messages.end(), message) + == messages.end()) + delete message; m_messages = std::move(messages); endResetModel(); diff --git a/Plugins/ZShell/Llm/segment.cpp b/Plugins/ZShell/Llm/segment.cpp index c0a66a0..72bf249 100644 --- a/Plugins/ZShell/Llm/segment.cpp +++ b/Plugins/ZShell/Llm/segment.cpp @@ -2,7 +2,10 @@ #include "markdownparser.hpp" +#include #include +#include +#include namespace ZShell::llm { @@ -19,8 +22,10 @@ LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent) &m_markdownTimer, &QTimer::timeout, this, [this]() { if (!m_markdownDirty) return; - m_markdownDirty = false; - parseMarkdown(); + // A parse may still be running; leave the dirty flag set so + // it re-parses the newest text when that one completes. + if (parseMarkdown()) + m_markdownDirty = false; }); } @@ -53,7 +58,8 @@ void LlmSegment::close() { // out the debounce. if (m_type == Type::Content && m_markdownDirty) { m_markdownTimer.stop(); - parseMarkdown(); + if (parseMarkdown()) + m_markdownDirty = false; } } @@ -130,9 +136,33 @@ void LlmSegment::scheduleMarkdown() { m_markdownTimer.start(); } -void LlmSegment::parseMarkdown() { - m_markdown = MarkdownParser::parse(m_text); - Q_EMIT markdownChanged(); +bool LlmSegment::parseMarkdown() { + if (m_parseInFlight) + return false; + m_parseInFlight = true; + const QString text = m_text; + QThreadPool::globalInstance()->start([this, text]() { + const QVariantList blocks = MarkdownParser::parse(text); + // Deliver through qApp (never destroyed) and re-check the + // pointer on the GUI thread: posting to `this` from the pool + // thread would race with its destruction. + QPointer guard(this); + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [guard, blocks]() { + LlmSegment* seg = guard; + if (!seg) + return; + seg->m_parseInFlight = false; + seg->m_markdown = blocks; + Q_EMIT seg->markdownChanged(); + // Text arrived while the worker was running; parse it. + if (seg->m_markdownDirty) + seg->parseMarkdown(); + }, + Qt::QueuedConnection); + }); + return true; } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/segment.hpp b/Plugins/ZShell/Llm/segment.hpp index 9033e32..949f78b 100644 --- a/Plugins/ZShell/Llm/segment.hpp +++ b/Plugins/ZShell/Llm/segment.hpp @@ -92,7 +92,9 @@ class LlmSegment : public QObject { private: void scheduleMarkdown(); - void parseMarkdown(); + // Kicks off an off-thread parse; returns false when one is already + // in flight (the pending change is picked up when it completes). + bool parseMarkdown(); Type m_type; qint64 m_timestamp; @@ -108,6 +110,7 @@ class LlmSegment : public QObject { QVariantList m_markdown; QTimer m_markdownTimer; bool m_markdownDirty = false; + bool m_parseInFlight = false; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp index de12a0a..d6287d8 100644 --- a/Plugins/ZShell/Llm/session.cpp +++ b/Plugins/ZShell/Llm/session.cpp @@ -94,8 +94,8 @@ LlmClient* ChatSession::client() const { } void ChatSession::ensureLoaded() { - if (m_loaded) return; - m_loaded = true; + if (m_loadRequested) return; + m_loadRequested = true; if (auto* store = qobject_cast(parent())) store->loadMessagesInto(this); } @@ -105,6 +105,18 @@ ChatMessageModel* ChatSession::messagesModel() { return m_model; } +void ChatSession::markLoaded() { + if (m_loaded) return; + m_loaded = true; + Q_EMIT loaded(); +} + +bool ChatSession::takeClearPending() { + const bool pending = m_clearPending; + m_clearPending = false; + return pending; +} + void ChatSession::adoptMessages(QList messages) { m_model->loadMessages(std::move(messages)); } @@ -127,9 +139,20 @@ void ChatSession::clearMessages() { } void ChatSession::startGeneration(ChatMessage* target) { - if (auto* generation = target->activeGeneration()) { - if (auto* clientObject = client()) + auto* generation = target->activeGeneration(); + auto* clientObject = client(); + if (generation && clientObject) { + if (isLoaded()) { clientObject->startGeneration(this, generation); + } else { + // The store load is still in flight; the request needs the + // full history, so start once it lands. + connect( + this, &ChatSession::loaded, clientObject, + [this, generation, clientObject]() { + clientObject->startGeneration(this, generation); + }); + } } persist(); } @@ -209,6 +232,11 @@ void ChatSession::clear() { return; } } + if (!isLoaded()) { + // The load lands shortly; drop everything once it does. + m_clearPending = true; + return; + } m_model->clear(); persist(); } diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp index d15d5e1..abe0c0e 100644 --- a/Plugins/ZShell/Llm/session.hpp +++ b/Plugins/ZShell/Llm/session.hpp @@ -43,10 +43,17 @@ class ChatSession : public QObject { [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } [[nodiscard]] bool pinned() const { return m_pinned; } [[nodiscard]] int messageCount() const { return m_messageCount; } - // Loads the messages from the store on first access. + // The messages model; the first access starts the (async) load + // from the store. [[nodiscard]] ChatMessageModel* messagesModel(); void ensureLoaded(); + // True once the async load from the store has finished. [[nodiscard]] bool isLoaded() const { return m_loaded; } + // The model without triggering a load (ChatStore use during the + // load itself). + [[nodiscard]] ChatMessageModel* model() const { return m_model; } + void markLoaded(); + [[nodiscard]] bool takeClearPending(); [[nodiscard]] LlmClient* client() const; [[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; } @@ -80,6 +87,8 @@ class ChatSession : public QObject { void updatedAtChanged(); void pinnedChanged(); void messageCountChanged(); + // The messages finished loading from the store. + void loaded(); private: void onModelRowsChanged(); @@ -96,7 +105,9 @@ class ChatSession : public QObject { bool m_pinned = false; int m_messageCount = 0; ChatMessageModel* m_model = nullptr; + bool m_loadRequested = false; bool m_loaded = false; + bool m_clearPending = false; int m_lastTokenCount = 0; }; diff --git a/SCHEME_LINKS.md b/SCHEME_LINKS.md new file mode 100644 index 0000000..07eef30 --- /dev/null +++ b/SCHEME_LINKS.md @@ -0,0 +1,25 @@ +# Code block color scheme sources + +Canonical sources for the schemes in +`Modules/Notifications/Sidebar/Chat/Content/CodeColors.qml`. + +Rules: + +- Verify one scheme at a time, in order. +- HSL/RGB values from the specs are converted with a Python script, + never by hand: + `python3 -c "import colorsys; r,g,b = colorsys.hls_to_rgb(h/360, l/100, s/100); print('#%02x%02x%02x' % tuple(round(c*255) for c in (r,g,b)))"` + (HLS in Python is h, l, s — note the order.) +- Check the box only after the QML is fixed and verified against the spec. + +- [x] **One Dark** — https://github.com/atom/one-dark-syntax/blob/master/styles/syntax-variables.less +- [x] **Nord** — https://github.com/nordtheme/nord/blob/develop/src/nord.css +- [x] **Dracula** — https://github.com/dracula/draculatheme.com/blob/main/content/spec.mdx +- [x] **Solarized** — https://github.com/altercation/solarized/blob/master/colors/solarized.vim (actual path: vim-colors-solarized/colors/solarized.vim) +- [x] **Gruvbox** — https://github.com/morhetz/gruvbox/blob/master/colors/gruvbox.vim +- [x] **Monokai** — https://github.com/JetBrains/colorSchemeTool/blob/master/intellijThemes/Monokai.icls +- [x] **GitHub Dark** — https://github.com/primer/github-vscode-theme +- [x] **Catppuccin (Mocha)** — https://github.com/catppuccin/nvim +- [x] **Tokyo Night** — https://github.com/folke/tokyonight.nvim/tree/main/lua/tokyonight/groups +- [x] **Ayu (Dark)** — https://github.com/ayu-theme/vscode-ayu +- [x] **Palenight** — https://github.com/drewtempelmeyer/palenight.vim diff --git a/scripts/codeicons.py b/scripts/codeicons.py new file mode 100644 index 0000000..4fdd751 --- /dev/null +++ b/scripts/codeicons.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 + +import json +import re +import sys +from pathlib import Path +from urllib.error import HTTPError +from urllib.request import urlopen +from xml.etree import ElementTree as ET + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +DEVICON_VERSION = "v2.17.0" + +ICONS = { + "c": "c", + "cpp": "cplusplus", + "csharp": "csharp", + "python": "python", + "rust": "rust", + "javascript": "javascript", + "typescript": "typescript", + "bash": "bash", + "zsh": "zsh", + "powershell": "powershell", + "cmake": "cmake", + "lua": "lua", + "java": "java", + "kotlin": "kotlin", + "swift": "swift", + "go": "go", + "dart": "dart", + "php": "php", + "ruby": "ruby", + "scala": "scala", + "haskell": "haskell", + "elixir": "elixir", + "erlang": "erlang", + "clojure": "clojure", + "r": "r", + "perl": "perl", + "zig": "zig", + "nim": "nim", + "ocaml": "ocaml", + "fsharp": "fsharp", + "visualbasic": "visualbasic", + "fortran": "fortran", + "crystal": "crystal", + "gleam": "gleam", + "julia": "julia", + "objectivec": "objectivec", + "vala": "vala", + "groovy": "groovy", + "racket": "racket", + "haxe": "haxe", + "purescript": "purescript", + "delphi": "delphi", + "coffeescript": "coffeescript", + "elm": "elm", + "awk": "awk", + "matlab": "matlab", + "solidity": "solidity", + "wasm": "wasm", + "vim": "vim", + "sql": "sqlite", + "json": "json", + "yaml": "yaml", + "xml": "xml", + "html": "html5", + "css": "css3", + "sass": "sass", + "markdown": "markdown", + "docker": "docker", + "latex": "latex", + "graphql": "graphql", +} + +# Markdown / syntax-highlighter aliases -> Devicon icon name. +ALIASES = { + "cpp": "cpp", + "cc": "cpp", + "cxx": "cpp", + + "cs": "csharp", + + "js": "javascript", + "jsx": "javascript", + + "ts": "typescript", + "tsx": "typescript", + + "py": "python", + + "sh": "bash", + "shell": "bash", + + "ps1": "powershell", + + "vb": "visualbasic", + + "objective-c": "objectivec", + "obj-c": "objectivec", + + "groovyscript": "groovy", + + "pascal": "delphi", + + "coffee": "coffeescript", + + "mysql": "sql", + "postgres": "sql", + "postgresql": "sql", + "sqlite": "sql", + + "yml": "yaml", + + "htm": "html", + + "scss": "sass", + + "md": "markdown", + + "dockerfile": "docker", + "docker-compose": "docker", + + "tex": "latex", + + "gql": "graphql", +} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +ICON_VARIANTS = ( + "plain", + "original", +) + + +def fetch_svg(devicon_name: str) -> str: + for variant in ICON_VARIANTS: + url = ( + f"https://raw.githubusercontent.com/devicons/devicon/" + f"{DEVICON_VERSION}/icons/{devicon_name}/" + f"{devicon_name}-{variant}.svg" + ) + + print(f"Fetching {devicon_name} ({variant}): {url}") + + try: + with urlopen(url) as response: + return response.read().decode("utf-8") + except HTTPError as error: + if error.code != 404: + raise + + raise RuntimeError( + f"No usable SVG variant found for '{devicon_name}'." + ) + + +def element_to_path(element) -> str: + """Convert a basic SVG shape element to equivalent path data.""" + tag = element.tag.rsplit("}", 1)[-1] + attrib = element.attrib + + if tag == "path": + return attrib.get("d", "") + + if tag == "circle": + cx = float(attrib["cx"]) + cy = float(attrib["cy"]) + r = float(attrib["r"]) + return ( + f"M {cx - r},{cy} " + f"a {r},{r} 0 1,0 {2 * r},0 " + f"a {r},{r} 0 1,0 {-2 * r},0 Z" + ) + + if tag == "ellipse": + cx = float(attrib["cx"]) + cy = float(attrib["cy"]) + rx = float(attrib["rx"]) + ry = float(attrib["ry"]) + return ( + f"M {cx - rx},{cy} " + f"a {rx},{ry} 0 1,0 {2 * rx},0 " + f"a {rx},{ry} 0 1,0 {-2 * rx},0 Z" + ) + + if tag == "rect": + x = float(attrib["x"]) + y = float(attrib["y"]) + width = float(attrib["width"]) + height = float(attrib["height"]) + return f"M {x},{y} h {width} v {height} h {-width} Z" + + if tag in ("polygon", "polyline"): + points = attrib["points"].split() + commands = [ + f"{float(points[i])},{float(points[i + 1])}" + for i in range(0, len(points) - 1, 2) + ] + data = "M " + " L ".join(commands) + if tag == "polygon": + data += " Z" + return data + + if tag == "line": + return ( + f"M {float(attrib['x1'])},{float(attrib['y1'])} " + f"L {float(attrib['x2'])},{float(attrib['y2'])}" + ) + + return "" + + +# --------------------------------------------------------------------------- +# SVG path data normalization +# +# Devicon path data crams arc flags against the following coordinate +# (e.g. "a28.78 28.78 0 00-2.65-7.58"), which Qt's PathSvg parser handles +# unreliably. Parse every path and re-emit it with canonical spacing. +# --------------------------------------------------------------------------- + +PATH_COMMANDS = "MmLlHhVvCcSsQqTtAaZz" +_NUMBER = re.compile(r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?") + + +def _skip_separators(data: str, i: int) -> int: + while i < len(data) and data[i] in " \t\r\n,": + i += 1 + return i + + +def _read_number(data: str, i: int) -> tuple[str, int]: + i = _skip_separators(data, i) + match = _NUMBER.match(data, i) + if not match: + raise ValueError( + f"Expected number in path data at {i}: {data[i:i + 24]!r}" + ) + return match.group(0), match.end() + + +def parse_path(data: str) -> list[tuple[str, list[str]]]: + """Parse SVG path data into (command, parameters) tuples.""" + commands: list[tuple[str, list[str]]] = [] + i, length = 0, len(data) + + while i < length: + i = _skip_separators(data, i) + if i >= length: + break + + command = data[i] + if command not in PATH_COMMANDS: + raise ValueError( + f"Unexpected character {command!r} at {i} in {data!r}" + ) + i += 1 + + if command in "Zz": + commands.append((command, [])) + continue + + implicit = command + while i < length: + i = _skip_separators(data, i) + if i >= length or data[i] in PATH_COMMANDS: + break + + if implicit in "Mm": + x, i = _read_number(data, i) + y, i = _read_number(data, i) + commands.append((implicit, [x, y])) + # Subsequent coordinate pairs after a moveto are linetos. + implicit = "L" if implicit == "M" else "l" + elif implicit in "LlTt": + x, i = _read_number(data, i) + y, i = _read_number(data, i) + commands.append((implicit, [x, y])) + elif implicit in "Hh": + x, i = _read_number(data, i) + commands.append((implicit, [x])) + elif implicit in "Vv": + y, i = _read_number(data, i) + commands.append((implicit, [y])) + elif implicit in "Cc": + # Cubic bezier: x1 y1 x2 y2 x y + p = [] + for _ in range(6): + value, i = _read_number(data, i) + p.append(value) + commands.append((implicit, p)) + elif implicit in "SsQq": + # Smooth cubic / quadratic: x1 y1 x y + p = [] + for _ in range(4): + value, i = _read_number(data, i) + p.append(value) + commands.append((implicit, p)) + elif implicit in "Aa": + rx, i = _read_number(data, i) + ry, i = _read_number(data, i) + rotation, i = _read_number(data, i) + # Arc flags are single digits, possibly crammed against + # the following coordinate, so read them positionally. + i = _skip_separators(data, i) + large_arc = data[i] + i += 1 + i = _skip_separators(data, i) + sweep = data[i] + i += 1 + if large_arc not in "01" or sweep not in "01": + raise ValueError( + f"Invalid arc flags near {i - 2} in {data!r}" + ) + x, i = _read_number(data, i) + y, i = _read_number(data, i) + commands.append( + (implicit, [rx, ry, rotation, large_arc, sweep, x, y]) + ) + else: + raise ValueError(f"Unhandled path command {implicit!r}") + + return commands + + +def normalize_path(data: str) -> str: + """Re-emit path data with canonical spacing (Qt PathSvg friendly).""" + return " ".join( + command if not params else command + " " + " ".join(params) + for command, params in parse_path(data) + ) + + +def extract_paths(svg: str) -> str: + root = ET.fromstring(svg) + + paths = [] + + for element in root.iter(): + path = element_to_path(element) + if not path: + continue + + normalized = normalize_path(path) + + # Guard against a tokenizer that changes the path it normalizes. + if parse_path(normalized) != parse_path(path): + raise RuntimeError( + "Path normalization round-trip mismatch:\n" + f" in : {path[:80]}\n out: {normalized[:80]}" + ) + + paths.append(normalized) + + if not paths: + raise RuntimeError("SVG contains no drawable shape elements") + + # Multiple path elements are valid SVG path data when concatenated. + return " ".join(paths) + + +def qml_string(value: str) -> str: + # JSON string escaping is valid for the string syntax we need in QML. + return json.dumps(value, ensure_ascii=False) + + +def qml_identifier(name: str) -> str: + name = re.sub(r"[^A-Za-z0-9_]", "_", name) + + if name and name[0].isdigit(): + name = "_" + name + + return name + + +# --------------------------------------------------------------------------- +# Generation +# --------------------------------------------------------------------------- + +def generate(output: Path) -> None: + icons = {} + + for name, devicon_name in ICONS.items(): + svg = fetch_svg(devicon_name) + icons[name] = extract_paths(svg) + + output.parent.mkdir(parents=True, exist_ok=True) + + with output.open("w", encoding="utf-8") as file: + file.write( + """pragma Singleton + +import Quickshell +import QtQuick + +Singleton { +""" + ) + + # ------------------------------------------------------------------- + # Icon path properties + # ------------------------------------------------------------------- + + for icon, path in icons.items(): + identifier = qml_identifier(icon) + + file.write( + f" readonly property string {identifier}: " + f"{qml_string(path)}\n" + ) + + file.write("\n") + + # ------------------------------------------------------------------- + # Language -> icon lookup + # ------------------------------------------------------------------- + + file.write( + """ function path(language) { + const key = language.toLowerCase() + + switch (key) { +""" + ) + + for icon in icons: + identifier = qml_identifier(icon) + + file.write( + f' case "{icon}":\n' + f" return {identifier}\n" + ) + + for alias, icon in ALIASES.items(): + if icon not in icons: + raise RuntimeError( + f"Alias '{alias}' points to unavailable icon '{icon}'" + ) + + identifier = qml_identifier(icon) + + file.write( + f' case "{alias}":\n' + f" return {identifier}\n" + ) + + file.write( + """ default: + return "" + } + } + + function name(language) { + switch (language.toLowerCase()) { +""" + ) + + # Human-readable names can initially be handled here. + # Add/remove these as your codeblock UI evolves. + names = { + "c": "C", + "cpp": "C++", + "csharp": "C#", + "python": "Python", + "rust": "Rust", + "javascript": "JavaScript", + "typescript": "TypeScript", + "qml": "QML", + "bash": "Bash", + "zsh": "Zsh", + "powershell": "PowerShell", + "cmake": "CMake", + "lua": "Lua", + "java": "Java", + "kotlin": "Kotlin", + "swift": "Swift", + "go": "Go", + "dart": "Dart", + "php": "PHP", + "ruby": "Ruby", + "scala": "Scala", + "haskell": "Haskell", + "elixir": "Elixir", + "erlang": "Erlang", + "clojure": "Clojure", + "r": "R", + "perl": "Perl", + "zig": "Zig", + "nim": "Nim", + "ocaml": "OCaml", + "fsharp": "F#", + "visualbasic": "Visual Basic", + "fortran": "Fortran", + "crystal": "Crystal", + "gleam": "Gleam", + "julia": "Julia", + "objectivec": "Objective-C", + "vala": "Vala", + "groovy": "Groovy", + "racket": "Racket", + "haxe": "Haxe", + "purescript": "PureScript", + "delphi": "Delphi", + "coffeescript": "CoffeeScript", + "elm": "Elm", + "awk": "AWK", + "matlab": "MATLAB", + "solidity": "Solidity", + "wasm": "Wasm", + "vim": "Vim", + "sql": "SQL", + "json": "JSON", + "yaml": "YAML", + "xml": "XML", + "html": "HTML", + "css": "CSS", + "sass": "Sass", + "markdown": "Markdown", + "docker": "Docker", + "latex": "LaTeX", + "graphql": "GraphQL", + } + + for icon in icons: + if icon not in names: + continue + + name = names[icon] + + file.write( + f' case "{icon}":\n' + f' return "{name}"\n' + ) + + for alias, icon in ALIASES.items(): + if icon not in names: + continue + + file.write( + f' case "{alias}":\n' + f' return "{names[icon]}"\n' + ) + + file.write( + """ default: + return language + } + } +} +""" + ) + + print(f"Generated {output}") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} OUTPUT.qml", file=sys.stderr) + sys.exit(1) + +generate(Path(sys.argv[1])) From 78988700be36e8b0ac653921d08946d07117a537 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 26 Aug 2026 02:06:40 +0200 Subject: [PATCH 07/90] async parsing + tex caching --- SCHEME_LINKS.md | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 SCHEME_LINKS.md diff --git a/SCHEME_LINKS.md b/SCHEME_LINKS.md deleted file mode 100644 index 07eef30..0000000 --- a/SCHEME_LINKS.md +++ /dev/null @@ -1,25 +0,0 @@ -# Code block color scheme sources - -Canonical sources for the schemes in -`Modules/Notifications/Sidebar/Chat/Content/CodeColors.qml`. - -Rules: - -- Verify one scheme at a time, in order. -- HSL/RGB values from the specs are converted with a Python script, - never by hand: - `python3 -c "import colorsys; r,g,b = colorsys.hls_to_rgb(h/360, l/100, s/100); print('#%02x%02x%02x' % tuple(round(c*255) for c in (r,g,b)))"` - (HLS in Python is h, l, s — note the order.) -- Check the box only after the QML is fixed and verified against the spec. - -- [x] **One Dark** — https://github.com/atom/one-dark-syntax/blob/master/styles/syntax-variables.less -- [x] **Nord** — https://github.com/nordtheme/nord/blob/develop/src/nord.css -- [x] **Dracula** — https://github.com/dracula/draculatheme.com/blob/main/content/spec.mdx -- [x] **Solarized** — https://github.com/altercation/solarized/blob/master/colors/solarized.vim (actual path: vim-colors-solarized/colors/solarized.vim) -- [x] **Gruvbox** — https://github.com/morhetz/gruvbox/blob/master/colors/gruvbox.vim -- [x] **Monokai** — https://github.com/JetBrains/colorSchemeTool/blob/master/intellijThemes/Monokai.icls -- [x] **GitHub Dark** — https://github.com/primer/github-vscode-theme -- [x] **Catppuccin (Mocha)** — https://github.com/catppuccin/nvim -- [x] **Tokyo Night** — https://github.com/folke/tokyonight.nvim/tree/main/lua/tokyonight/groups -- [x] **Ayu (Dark)** — https://github.com/ayu-theme/vscode-ayu -- [x] **Palenight** — https://github.com/drewtempelmeyer/palenight.vim From c33dbc147f101ca52ccbeed88b4b3825489bc4f6 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 26 Aug 2026 17:10:34 +0200 Subject: [PATCH 08/90] rotated view + wheel inverter --- .../Sidebar/Chat/ChatContent.qml | 134 ++++++++++-------- .../Sidebar/Chat/Content/ContentBubble.qml | 17 ++- .../Sidebar/Chat/Content/MessageDelegate.qml | 93 ++++++++++-- .../Sidebar/Chat/Content/ProcessBlock.qml | 132 +++++++++-------- Plugins/ZShell/Components/CMakeLists.txt | 9 +- Plugins/ZShell/Components/wheelinverter.hpp | 90 ++++++++++++ Plugins/ZShell/Llm/session.hpp | 1 + 7 files changed, 341 insertions(+), 135 deletions(-) create mode 100644 Plugins/ZShell/Components/wheelinverter.hpp diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 4878c9d..dfb647a 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -1,7 +1,8 @@ -import ZShell.Config -import ZShell.Llm import QtQuick import QtQuick.Layouts +import ZShell.Components +import ZShell.Config +import ZShell.Llm import qs.Modules.Notifications.Sidebar.Chat.Content import qs.Components import qs.Services @@ -15,7 +16,7 @@ Item { signal requestClose function scrollToBottom(): void { - Qt.callLater(list.positionViewAtBeginning); + Qt.callLater(() => listLoader.item?.positionViewAtBeginning()); } function send(text: string): void { @@ -26,8 +27,6 @@ Item { input.text = ""; } - Component.onCompleted: input.focus = true - RowLayout { id: header @@ -54,10 +53,8 @@ Item { } } - VerticalFadeListView { - id: list - - property bool userScrolledUp: false + Loader { + id: listLoader anchors.bottom: input.top anchors.bottomMargin: Tokens.spacing.medium @@ -65,58 +62,83 @@ Item { anchors.right: parent.right anchors.top: header.bottom anchors.topMargin: Tokens.spacing.medium - cacheBuffer: height * 20 - clip: true - fadeAmount: 0.05 - fadeThreshold: Tokens.padding.medium - model: root.chatData.messagesModel - spacing: 0 - verticalLayoutDirection: VerticalFadeListView.BottomToTop + active: root.chatData && root.chatData.loaded - add: Transition { - Anim { - from: list.width - property: "x" - to: 0 + sourceComponent: ListView { + id: list + + property bool userScrolledUp: false + + function scrollToBottom(): void { + Qt.callLater(() => positionViewAtBeginning()); } - } - delegate: MessageDelegate {} - displaced: Transition { - Anim { - property: "y" + + cacheBuffer: height * 20 + clip: true + // fadeAmount: 0.05 + // fadeThreshold: Tokens.padding.medium + model: root.chatData.messagesModel + spacing: 0 + rotation: 180 + + // add: Transition { + // Anim { + // from: 10 + // property: "y" + // } + // } + delegate: MessageDelegate { + rotation: 180 } - } - move: Transition { + + // displaced: Transition { + // Anim { + // property: "y" + // } + // } + // move: Transition { + // Anim { + // property: "y" + // } + // } + + Component.onCompleted: { + positionViewAtBeginning(); + forceActiveFocus(); + } + onAtYEndChanged: { + if (atYEnd) + userScrolledUp = false; + } + onContentHeightChanged: { + if (!userScrolledUp && atYEnd) + scrollToBottom(); + } + onCountChanged: { + if (!userScrolledUp) + scrollToBottom(); + } + onMovingChanged: { + if (moving) + userScrolledUp = !atYEnd; + } + Anim { - property: "y" + id: scrollAnim + + property: "contentY" + target: list + type: Anim.DefaultEffects + } + + WheelInverter { + target: list } } - Component.onCompleted: positionViewAtBeginning() - onAtYEndChanged: { - if (atYEnd) - userScrolledUp = false; - } - onContentHeightChanged: { - if (!userScrolledUp && atYEnd) - root.scrollToBottom(); - } - onCountChanged: { - if (!userScrolledUp) - root.scrollToBottom(); - } - onMovingChanged: { - if (moving) - userScrolledUp = !atYEnd; - } - - Anim { - id: scrollAnim - - property: "contentY" - target: list - type: Anim.DefaultEffects - } + // Trigger the load even before we're ready to show the list. + Component.onCompleted: if (root.chatData) + root.chatData.messagesModel } EmptyBackground { @@ -133,7 +155,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter implicitHeight: scrollToBottom.implicitHeight implicitWidth: scrollToBottom.implicitWidth - scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 && list.contentHeight > list.height ? 1 : 0 + scale: !listLoader.item.atYBeginning && listLoader.item.contentHeight > listLoader.item.height ? 1 : 0 visible: scale > 0 Behavior on scale { @@ -157,7 +179,7 @@ Item { type: IconButton.Tonal onClicked: { - list.positionViewAtBeginning(); + listLoader.item.positionViewAtBeginning(); } } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml index 61b40fa..5e93159 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -33,6 +33,12 @@ Item { implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2 anchors.right: root.isUser ? parent.right : undefined + Behavior on implicitHeight { + // enabled: root.segment.running + + Anim {} + } + // User messages stay a plain editable text field. TextEditBase { id: msgText @@ -103,12 +109,13 @@ Item { Actions { id: actionsRow + readonly property bool shouldBeActive: (root.segment.type === LlmSegment.Type.Content) && !root.segment.running && root.repeater.count === (root.index + 1) + anchors.left: parent.left anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right anchors.top: bubble.bottom - visible: { - return (root.segment.type === LlmSegment.Type.Content) && root.segment.status !== LlmSegment.Status.Running && root.repeater.count === (root.index + 1); - } + opacity: shouldBeActive ? 1 : 0 + visible: opacity > 0 anchors.topMargin: Tokens.spacing.medium edit: msgText hovered: root.hovered @@ -116,5 +123,9 @@ Item { segment: root.segment current: root.current message: root.message + + Behavior on opacity { + Anim {} + } } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index 0133a1e..d69f109 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -11,7 +11,7 @@ import qs.Services MouseArea { id: root - readonly property ChatGeneration current: modelData.activeGeneration + property ChatGeneration current: modelData.activeGeneration required property int index readonly property bool isUser: modelData.role === ChatMessage.Role.User required property ChatMessage modelData @@ -60,22 +60,79 @@ MouseArea { } } } + return blocks; } implicitWidth: ListView.view.width hoverEnabled: true - preventStealing: true + preventStealing: false implicitHeight: layout.implicitHeight + Tokens.padding.medium - Binding { - property: "contentY" - restoreMode: Binding.RestoreNone - target: root.ListView.view - value: root.y - Tokens.padding.large * 2 - when: root.reasoningExpanded && root.ListView.view && ((root.y - Tokens.padding.large * 2) < root.ListView.view.contentY) + Behavior on current { + id: changeAnim + + property var object: targetProperty + readonly property bool next: { + const cond = root.modelData.generations.indexOf(current) < root.modelData.generations.indexOf(changeAnim.targetValue); + return cond; + } + + animation: SequentialAnimation { + ParallelAnimation { + Anim { + target: root + property: "x" + to: changeAnim.next ? -root.width / 2 : root.width / 2 + from: 0 + } + + Anim { + target: root + property: "opacity" + from: 1 + to: 0 + } + } + + PropertyAction {} + + ParallelAnimation { + Anim { + target: root + property: "x" + from: changeAnim.next ? root.width / 2 : -root.width / 2 + to: 0 + } + + Anim { + target: root + property: "opacity" + from: 0 + to: 1 + } + } + } } + // Behavior on implicitHeight { + // enabled: !root.isUser && root.current.streaming + // + // Anim {} + // } + + onCurrentChanged: { + console.log(modelData.generations.indexOf(current)); + } + + // Binding { + // property: "contentY" + // restoreMode: Binding.RestoreNone + // target: root.ListView.view + // value: root.y - Tokens.padding.large * 2 + // when: root.reasoningExpanded && root.ListView.view && ((root.y - Tokens.padding.large * 2) < root.ListView.view.contentY) + // } + Anim { id: restoreAnim @@ -91,6 +148,20 @@ MouseArea { anchors.left: parent.left anchors.right: parent.right + // onImplicitHeightChanged: console.log("LAYOUT:", layout.implicitHeight) + + // add: Transition { + // Anim { + // from: 10 + // property: "y" + // } + // } + // move: Transition { + // Anim { + // property: "y" + // } + // } + Repeater { id: segmentRep @@ -105,12 +176,8 @@ MouseArea { roleValue: "process" delegate: ProcessBlock { - required property int index - required property var modelData - - segments: modelData.segments - isActive: index === root.blocks.length - 1 width: root.width + blocks: root.blocks onExpandedChanged: root.handleReasoningToggle(expanded) } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml index 18f7c85..c681bfb 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -5,92 +5,106 @@ import ZShell.Llm import qs.Components import qs.Services -Item { +CustomClippingRect { id: root - required property var segments - required property bool isActive + required property int index + required property var modelData + required property var blocks + readonly property var segments: modelData.segments + readonly property bool isActive: index === blocks.length - 1 property bool expanded: false readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1] readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0) - implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin + implicitHeight: expanded ? expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin * 2 : layout.implicitHeight + Tokens.spacing.small - LoadingIndicator { - id: spinnerReasoning - - anchors.centerIn: expandBtn - implicitSize: collapsedText.implicitHeight - opacity: root.isActive ? 1 : 0 - visible: opacity > 0 - - Behavior on opacity { - Anim {} + Behavior on implicitHeight { + Anim { + type: Anim.DefaultEffects } } - IconButton { - id: expandBtn + RowLayout { + id: layout - anchors.left: parent.left - anchors.verticalCenter: collapsedText.verticalCenter - font.pointSize: Tokens.font.size.large - anchors.topMargin: 0 - icon: "keyboard_arrow_down" - inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline - opacity: root.isActive ? 0 : 1 - rotation: root.expanded ? 180 : 0 - type: IconButton.Text - - Behavior on rotation { - Anim {} - } - - onClicked: root.expanded = !root.expanded - } - - CustomText { - id: collapsedText - - anchors.left: expandBtn.right - anchors.margins: Tokens.padding.medium - anchors.right: parent.right anchors.top: parent.top - anchors.topMargin: 0 - color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small - text: { - if (root.isActive) { - if (root.lastSegment.type === LlmSegment.Type.ToolCall) - return qsTr("Using %1...").arg(root.lastSegment.name); - return qsTr("Thinking..."); + anchors.left: parent.left + anchors.right: parent.right + + Item { + implicitHeight: expandBtn.implicitHeight + implicitWidth: expandBtn.implicitWidth + + LoadingIndicator { + id: spinnerReasoning + + implicitSize: collapsedText.implicitHeight + anchors.centerIn: parent + opacity: root.isActive ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + } + + IconButton { + id: expandBtn + + font.pointSize: Tokens.font.size.large + icon: "keyboard_arrow_down" + inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline + anchors.centerIn: parent + opacity: root.isActive ? 0 : 1 + rotation: root.expanded ? 180 : 0 + type: IconButton.Text + + Behavior on rotation { + Anim {} + } + + onClicked: root.expanded = !root.expanded } - return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1)); } - Behavior on opacity { - Anim {} + CustomText { + id: collapsedText + + color: Colors.palette.m3outline + font.pointSize: Tokens.font.size.small + Layout.fillWidth: true + Layout.leftMargin: Tokens.spacing.medium + text: { + if (root.isActive) { + if (root.lastSegment.type === LlmSegment.Type.ToolCall) + return qsTr("Using %1...").arg(root.lastSegment.name); + return qsTr("Thinking..."); + } + return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1)); + } + + Behavior on opacity { + Anim {} + } } } - CustomClippingRect { + CustomRect { id: expandedRect anchors.left: parent.left anchors.right: parent.right - anchors.top: collapsedText.bottom - anchors.topMargin: Tokens.spacing.medium + anchors.top: layout.bottom + anchors.bottom: parent.bottom + anchors.topMargin: Tokens.spacing.small + anchors.bottomMargin: Tokens.spacing.small color: Colors.palette.m3surfaceContainerLow - implicitHeight: root.expanded ? expandedContent.contentHeight + expandedContent.anchors.margins * 2 : 0 + implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2 opacity: root.expanded ? 1 : 0 radius: Tokens.rounding.medium visible: opacity > 0 - Behavior on implicitHeight { - Anim { - type: Anim.DefaultEffects - } - } Behavior on opacity { Anim {} } diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index 3d3e4a7..5d1cd78 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -1,10 +1,11 @@ qml_module(ZShell-components URI ZShell.Components - SOURCES - lazylistview.hpp lazylistview.cpp + SOURCES + lazylistview.hpp lazylistview.cpp wavyline.hpp wavyline.cpp buttonrow.hpp buttonrow.cpp carouselview.hpp carouselview.cpp - LIBRARIES - Qt::Quick + wheelinverter.hpp + LIBRARIES + Qt::Quick ) diff --git a/Plugins/ZShell/Components/wheelinverter.hpp b/Plugins/ZShell/Components/wheelinverter.hpp new file mode 100644 index 0000000..38aa599 --- /dev/null +++ b/Plugins/ZShell/Components/wheelinverter.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace ZShell::components { + +class WheelInverter : public QQuickItem { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + QQuickItem* target READ target WRITE setTarget NOTIFY targetChanged) + + public: + using QQuickItem::QQuickItem; + + QQuickItem* target() const { return m_target; } + + void setTarget(QQuickItem* target) { + if (m_target == target) return; + + if (m_target) m_target->removeEventFilter(this); + + m_target = target; + + if (m_target) m_target->installEventFilter(this); + + emit targetChanged(); + } + + protected: + class InvertedWheelEvent : public QWheelEvent { + public: + using QWheelEvent::QWheelEvent; + + bool invertedByWheelInverter = true; + }; + + bool eventFilter(QObject* watched, QEvent* event) override { + if (watched != m_target || event->type() != QEvent::Wheel) + return QQuickItem::eventFilter(watched, event); + + auto* wheel = static_cast(event); + + const bool inverted = dynamic_cast(wheel) != + nullptr; + + if (inverted) return false; + + auto* window = m_target ? m_target->window() : nullptr; + if (!window) { + qInfo() << "[WheelInverter] no window"; + return false; + } + + const QPointF windowPos = + window->mapFromGlobal(wheel->globalPosition()); + + auto* invertedEvent = new InvertedWheelEvent( + windowPos, + wheel->globalPosition(), + -wheel->pixelDelta(), + -wheel->angleDelta(), + wheel->buttons(), + wheel->modifiers(), + wheel->phase(), + wheel->inverted(), + wheel->source(), + wheel->pointingDevice()); + + invertedEvent->setTimestamp(wheel->timestamp()); + + QCoreApplication::postEvent(window, invertedEvent); + + return true; + } + + signals: + void targetChanged(); + + private: + QPointer m_target; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp index abe0c0e..5e97a78 100644 --- a/Plugins/ZShell/Llm/session.hpp +++ b/Plugins/ZShell/Llm/session.hpp @@ -23,6 +23,7 @@ class ChatSession : public QObject { Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT) Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged) Q_PROPERTY(bool pinned READ pinned NOTIFY pinnedChanged) + Q_PROPERTY(bool loaded READ isLoaded NOTIFY loaded) Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) Q_PROPERTY( ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT) From 293bf67e099a496e6d5e39364b49292c5bb2fb3c Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 27 Aug 2026 01:47:17 +0200 Subject: [PATCH 09/90] better scrollbar in codeblocks + enter anim for delegates --- .qmlformat.ini | 2 +- Components/CustomMouseArea.qml | 1 + Components/CustomScrollBar.qml | 26 +- Drawers/Windows.qml | 2 +- .../Sidebar/Chat/ChatContent.qml | 167 +++++++++-- .../Notifications/Sidebar/Chat/ChatInput.qml | 2 +- .../Sidebar/Chat/Content/BubbleEdit.qml | 28 ++ .../Sidebar/Chat/Content/CodeBlockView.qml | 52 +++- .../Sidebar/Chat/Content/ContentBubble.qml | 35 ++- .../Sidebar/Chat/Content/MarkdownBlocks.qml | 19 +- .../Sidebar/Chat/Content/MessageDelegate.qml | 44 +-- .../Settings/Common/DialogSelectButton.qml | 9 +- .../Pages/Panels/Sidebar/SidebarLlm.qml | 2 + Plugins/ZShell/Llm/codehighlighter.cpp | 265 ++++++++++-------- Plugins/ZShell/Llm/codehighlighter.hpp | 22 ++ Plugins/ZShell/Llm/markdownparser.cpp | 11 +- 16 files changed, 458 insertions(+), 229 deletions(-) create mode 100644 Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml diff --git a/.qmlformat.ini b/.qmlformat.ini index 3956296..828d0c9 100644 --- a/.qmlformat.ini +++ b/.qmlformat.ini @@ -3,7 +3,7 @@ FunctionsSpacing=true IndentWidth=4 MaxColumnWidth=-1 NewlineType=native -GroupAttributesTogether=true +GroupAttributesTogether=false ObjectsSpacing=true SemicolonRule=always SingleLineEmptyObjects=true diff --git a/Components/CustomMouseArea.qml b/Components/CustomMouseArea.qml index 6d52d16..0737476 100644 --- a/Components/CustomMouseArea.qml +++ b/Components/CustomMouseArea.qml @@ -4,6 +4,7 @@ MouseArea { property int scrollAccumulatedY: 0 function onWheel(event: WheelEvent): void { + event.accepted = false; } onWheel: event => { diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index 581cb11..810dd2e 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -24,13 +24,8 @@ ScrollBar { readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0 enabled: !Visibilities.getForActive().isDrawing - parent: flickable.parent - anchors.left: isHorizontal ? flickable.left : undefined - anchors.right: flickable.right - anchors.top: isHorizontal ? undefined : flickable.top - anchors.bottom: flickable.bottom - implicitWidth: isHorizontal ? 0 : Tokens.padding.extraSmall * 2 - implicitHeight: isHorizontal ? Tokens.padding.extraSmall * 2 : 0 + implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2 + implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0 contentItem: Item {} Behavior on position { @@ -59,6 +54,7 @@ ScrollBar { Loader { anchors.fill: parent + active: root.size < 1 sourceComponent: root.isHorizontal ? horizontalTrack : verticalTrack } @@ -116,6 +112,17 @@ ScrollBar { return visualPos * root.travelScale; } + function onWheel(event: WheelEvent): void { + if (root.horizontal) { + event.accepted = false; + return; + } + + var delta = event.angleDelta.y > 0 ? -0.1 : 0.1; + var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta)); + root.position = newPos; + } + anchors.fill: parent cursorShape: undefined hoverEnabled: true @@ -136,10 +143,5 @@ ScrollBar { updateFromEvent(event); } - onWheel: event => { - var delta = (root.isHorizontal ? event.angleDelta.x : event.angleDelta.y) > 0 ? -0.1 : 0.1; - var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta)); - root.position = newPos; - } } } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index 779f623..b2ecfb6 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -55,7 +55,7 @@ CustomWindow { property color surfaceColor: Colors.tPalette.m3surface WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings || visibilities.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top color: "transparent" contentItem.focus: true diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index dfb647a..2413981 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -12,6 +12,78 @@ Item { property ChatSession chatData property bool following: true + // qmlformat off + readonly property var keys: [ + Qt.Key_A, + Qt.Key_B, + Qt.Key_C, + Qt.Key_D, + Qt.Key_E, + Qt.Key_F, + Qt.Key_G, + Qt.Key_H, + Qt.Key_I, + Qt.Key_J, + Qt.Key_K, + Qt.Key_L, + Qt.Key_M, + Qt.Key_N, + Qt.Key_O, + Qt.Key_P, + Qt.Key_Q, + Qt.Key_R, + Qt.Key_S, + Qt.Key_T, + Qt.Key_U, + Qt.Key_V, + Qt.Key_W, + Qt.Key_X, + Qt.Key_Y, + Qt.Key_Z, + + Qt.Key_Agrave, + Qt.Key_Aacute, + Qt.Key_Acircumflex, + Qt.Key_Atilde, + Qt.Key_Adiaeresis, + Qt.Key_Aring, + Qt.Key_AE, + + Qt.Key_Ccedilla, + + Qt.Key_Egrave, + Qt.Key_Eacute, + Qt.Key_Ecircumflex, + Qt.Key_Ediaeresis, + + Qt.Key_Igrave, + Qt.Key_Iacute, + Qt.Key_Icircumflex, + Qt.Key_Idiaeresis, + + Qt.Key_ETH, + + Qt.Key_Ntilde, + + Qt.Key_Ograve, + Qt.Key_Oacute, + Qt.Key_Ocircumflex, + Qt.Key_Otilde, + Qt.Key_Odiaeresis, + Qt.Key_Ooblique, + + Qt.Key_Ugrave, + Qt.Key_Uacute, + Qt.Key_Ucircumflex, + Qt.Key_Udiaeresis, + + Qt.Key_Yacute, + Qt.Key_ydiaeresis, + + Qt.Key_THORN, + Qt.Key_ssharp + ] + // qmlformat on signal requestClose @@ -27,6 +99,21 @@ Item { input.text = ""; } + function focusInput(): void { + Qt.callLater(() => input.forceActiveFocus()); + } + + onChatDataChanged: { + if (chatData) + focusInput(); + } + Keys.onPressed: e => { + if (root.keys.includes(e.key)) { + input.insert(input.length, e.text); + focusInput(); + } + } + RowLayout { id: header @@ -68,9 +155,11 @@ Item { id: list property bool userScrolledUp: false + property real lastCHeight: 0.0 function scrollToBottom(): void { - Qt.callLater(() => positionViewAtBeginning()); + scrollAnim.to = 0; + scrollAnim.start(); } cacheBuffer: height * 20 @@ -80,47 +169,67 @@ Item { model: root.chatData.messagesModel spacing: 0 rotation: 180 + add: Transition { + Anim { + from: -100 + property: "y" + } + } - // add: Transition { - // Anim { - // from: 10 - // property: "y" - // } - // } + CustomScrollBar.vertical: CustomScrollBar { + id: scrollBar + + flickable: list + parent: list.parent + anchors.top: list.top + anchors.right: list.right + anchors.bottom: list.bottom + + transform: Rotation { + origin.y: list.height / 2 + origin.x: scrollBar.width / 2 + angle: 180 + + axis { + y: 0 + x: 1 + z: 0 + } + } + } delegate: MessageDelegate { rotation: 180 } - // displaced: Transition { - // Anim { - // property: "y" - // } - // } - // move: Transition { - // Anim { - // property: "y" - // } - // } + displaced: Transition { + Anim { + property: "y" + } + } + move: Transition { + Anim { + property: "y" + } + } Component.onCompleted: { positionViewAtBeginning(); - forceActiveFocus(); } - onAtYEndChanged: { - if (atYEnd) + onAtYBeginningChanged: { + if (atYBeginning) userScrolledUp = false; } onContentHeightChanged: { - if (!userScrolledUp && atYEnd) - scrollToBottom(); - } - onCountChanged: { - if (!userScrolledUp) - scrollToBottom(); + if (userScrolledUp && Chat.busy) { + const delta = contentHeight - lastCHeight; + contentY += delta; + } + + lastCHeight = contentHeight; } onMovingChanged: { if (moving) - userScrolledUp = !atYEnd; + userScrolledUp = !atYBeginning; } Anim { @@ -179,7 +288,7 @@ Item { type: IconButton.Tonal onClicked: { - listLoader.item.positionViewAtBeginning(); + listLoader.item.scrollToBottom(); } } } @@ -194,11 +303,13 @@ Item { bg.color: Colors.tPalette.m3surfaceContainerLowest font.pointSize: Tokens.font.size.normal implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) + focus: true placeholderText: qsTr("Send a message") sendIcon.font.pointSize: Tokens.font.size.large sendIcon.icon: "arrow_upward" sendIcon.padding: Tokens.padding.extraSmall + Component.onCompleted: root.focusInput() Keys.onPressed: e => { if (e.key == Qt.Key_Return) { if (!(e.modifiers & Qt.ShiftModifier)) { diff --git a/Modules/Notifications/Sidebar/Chat/ChatInput.qml b/Modules/Notifications/Sidebar/Chat/ChatInput.qml index 4d7216d..058ac31 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatInput.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatInput.qml @@ -31,7 +31,7 @@ TextAreaBase { enabled: !root.activeFocus manualPressOverride: tapHandler.pressed - onClicked: root.focus = true + onClicked: root.forceActiveFocus() } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml b/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml new file mode 100644 index 0000000..e335cf5 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml @@ -0,0 +1,28 @@ +import Quickshell +import QtQuick +import ZShell.Config +import qs.Components +import qs.Services + +TextEditBase { + id: root + + color: Colors.palette.m3onSurface + readOnly: true + anchors.margins: Tokens.padding.medium + textFormat: Text.MarkdownText + font.pointSize: Tokens.font.size.smaller + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + onLinkActivated: link => { + Qt.openUrlExternally(link); + } + + CustomMouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + cursorShape: root.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor + preventStealing: false + hoverEnabled: true + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml index e9f007e..e264c85 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Effects import QtQuick.Shapes import QtQuick.Layouts import Quickshell @@ -7,7 +8,7 @@ import ZShell.Llm import qs.Components import qs.Services -CustomClippingRect { +CustomRect { id: root required property string language @@ -15,16 +16,11 @@ CustomClippingRect { property bool copied: false property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh property color codeHeaderColor: Colors.palette.m3outline - - // Highlighter spans for the current code; refreshed when the code or - // its language changes. Highlighting runs off the GUI thread; the - // token drops results that arrive after the code already changed. property var codeSpans: [] property int highlightToken: 0 function refresh() { const token = ++root.highlightToken; - codeSpans = []; CodeHighlighter.highlight(root.code, root.language, root, token); } @@ -84,10 +80,14 @@ CustomClippingRect { let pos = 0; for (let i = 0; i < spans.length; i++) { const span = spans[i]; - if (span.start > pos) - out += escapeHtml(code.slice(pos, span.start)); - out += `` + escapeHtml(code.slice(span.start, span.start + span.length)) + ""; - pos = span.start + span.length; + const start = Math.min(span.start, code.length); + const end = Math.min(span.start + span.length, code.length); + if (end <= pos) + continue; + if (start > pos) + out += escapeHtml(code.slice(pos, start)); + out += `` + escapeHtml(code.slice(start, end)) + ""; + pos = end; } if (pos < code.length) out += escapeHtml(code.slice(pos)); @@ -100,7 +100,10 @@ CustomClippingRect { color: root.codeBackgroundColor radius: Tokens.rounding.medium - onLanguageChanged: refresh() + onLanguageChanged: { + codeSpans = []; + refresh(); + } onCodeChanged: refresh() RowLayout { @@ -212,7 +215,7 @@ CustomClippingRect { } } - CustomRect { + CustomClippingRect { id: codeRect anchors.left: parent.left @@ -226,6 +229,21 @@ CustomClippingRect { implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2 color: CodeColors.active.bg + CustomText { + id: code + anchors.top: parent.top + anchors.bottom: parent.bottom + x: implicitWidth * (0 - codeFlick.visibleArea.xPosition) + Tokens.padding.small + anchors.margins: Tokens.padding.small + text: root.highlightedHtml(root.code, root.codeSpans) + textFormat: Text.RichText + color: CodeColors.active.normal + layer.enabled: true + clip: false + font.family: Config.appearance.font.family.mono + font.pointSize: Tokens.font.size.small + } + Flickable { id: codeFlick @@ -234,15 +252,23 @@ CustomClippingRect { CustomScrollBar.horizontal: CustomScrollBar { flickable: codeFlick + parent: codeFlick.parent + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right } + TextAreaBase.flickable: TextAreaBase { id: codeText color: CodeColors.active.normal + layer.enabled: true + leftInset: Tokens.padding.small + clip: false font.family: Config.appearance.font.family.mono font.pointSize: Tokens.font.size.small textFormat: Text.RichText - text: root.highlightedHtml(root.code, root.codeSpans) + text: code.text readOnly: true } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml index 5e93159..75666af 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -24,7 +24,7 @@ Item { implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin - CustomRect { + CustomClippingRect { id: bubble radius: Tokens.rounding.medium @@ -33,16 +33,21 @@ Item { implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2 anchors.right: root.isUser ? parent.right : undefined - Behavior on implicitHeight { - // enabled: root.segment.running - - Anim {} - } + // Behavior on implicitHeight { + // enabled: !root.segment.running + // + // Anim { + // type: Anim.DefaultEffects + // } + // } // User messages stay a plain editable text field. TextEditBase { id: msgText + property string cachedText: root.segment.text + property bool cancelled: false + visible: root.isUser anchors.left: parent.left anchors.margins: Tokens.padding.medium @@ -65,27 +70,29 @@ Item { event.accepted = true; } } else if (event.key == Qt.Key_Escape) { - text = root.segment.text; + cancelled = true; readOnly = true; event.accepted = true; } } onEditingFinished: { - const old = root.segment.text; - if (old !== text) - root.edit(text); - readOnly = true; } onReadOnlyChanged: { if (readOnly) { animateCursor = false; - root.forceActiveFocus(); textFormat = CustomText.MarkdownText; + + if (cancelled) { + text = cachedText; + cancelled = false; + return; + } + + root.edit(text); } else { - var raw = root.segment.text; textFormat = CustomText.PlainText; - text = raw; + text = cachedText; forceActiveFocus(); cursorPosition = text.length; animateCursor = true; diff --git a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml index 834b66d..32377ad 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml @@ -1,4 +1,5 @@ import QtQuick +import Quickshell import ZShell.Config import ZShell.Llm import qs.Components @@ -7,7 +8,6 @@ import qs.Services Column { id: root - // Top-level markdown blocks (see MarkdownParser / LlmSegment.markdown). required property var blocks spacing: Tokens.spacing.small @@ -15,8 +15,10 @@ Column { Repeater { id: blockRep - model: root.blocks - + model: ScriptModel { + values: root.blocks + objectProp: "id" + } delegate: DelegateChooser { role: "type" @@ -50,7 +52,7 @@ Column { DelegateChoice { roleValue: LlmMarkdown.Type.Heading - delegate: CustomText { + delegate: TextEditBase { required property var modelData color: Colors.palette.m3onSurface @@ -59,10 +61,11 @@ Column { anchors.margins: Tokens.padding.medium text: modelData.text textFormat: Text.MarkdownText + readOnly: true font.bold: true font.pointSize: { if (modelData.level <= 1) - return Tokens.font.size.larger; + return Tokens.font.size.large; if (modelData.level === 2) return Tokens.font.size.normal; return Tokens.font.size.smaller; @@ -74,17 +77,13 @@ Column { DelegateChoice { roleValue: LlmMarkdown.Type.Text - delegate: CustomText { + delegate: BubbleEdit { required property var modelData - color: Colors.palette.m3onSurface anchors.right: parent.right anchors.left: parent.left anchors.margins: Tokens.padding.medium text: modelData.text - textFormat: Text.MarkdownText - font.pointSize: Tokens.font.size.smaller - wrapMode: Text.WrapAtWordBoundaryOrAnywhere } } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index d69f109..036c061 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -83,8 +83,9 @@ MouseArea { Anim { target: root property: "x" - to: changeAnim.next ? -root.width / 2 : root.width / 2 + to: changeAnim.next ? root.width / 4 : -root.width / 4 from: 0 + type: Anim.FastEffects } Anim { @@ -92,6 +93,7 @@ MouseArea { property: "opacity" from: 1 to: 0 + type: Anim.FastEffects } } @@ -101,7 +103,8 @@ MouseArea { Anim { target: root property: "x" - from: changeAnim.next ? root.width / 2 : -root.width / 2 + from: changeAnim.next ? -root.width / 4 : root.width / 4 + type: Anim.FastEffects to: 0 } @@ -109,30 +112,21 @@ MouseArea { target: root property: "opacity" from: 0 + type: Anim.FastEffects to: 1 } } } } - // Behavior on implicitHeight { - // enabled: !root.isUser && root.current.streaming - // - // Anim {} - // } - - onCurrentChanged: { - console.log(modelData.generations.indexOf(current)); + Binding { + property: "contentY" + restoreMode: Binding.RestoreNone + target: root.ListView.view + value: root.y + root.height + Tokens.padding.large * 2 - root.ListView.view.height + when: root.reasoningExpanded && root.ListView.view && (root.y + root.height + Tokens.padding.large * 2 > root.ListView.view.contentY + root.ListView.view.height) } - // Binding { - // property: "contentY" - // restoreMode: Binding.RestoreNone - // target: root.ListView.view - // value: root.y - Tokens.padding.large * 2 - // when: root.reasoningExpanded && root.ListView.view && ((root.y - Tokens.padding.large * 2) < root.ListView.view.contentY) - // } - Anim { id: restoreAnim @@ -148,20 +142,6 @@ MouseArea { anchors.left: parent.left anchors.right: parent.right - // onImplicitHeightChanged: console.log("LAYOUT:", layout.implicitHeight) - - // add: Transition { - // Anim { - // from: 10 - // property: "y" - // } - // } - // move: Transition { - // Anim { - // property: "y" - // } - // } - Repeater { id: segmentRep diff --git a/Modules/Settings/Common/DialogSelectButton.qml b/Modules/Settings/Common/DialogSelectButton.qml index 3cce148..87c93bd 100644 --- a/Modules/Settings/Common/DialogSelectButton.qml +++ b/Modules/Settings/Common/DialogSelectButton.qml @@ -10,6 +10,7 @@ DialogRowButton { required property var model property var selectedItem + property var initialSelect function keyFor(item: var): string { return item.id; @@ -91,7 +92,11 @@ DialogRowButton { } onOpenChanged: { - if (open) - selectedItem = null; + if (open) { + if (!initialSelect) + selectedItem = null; + else + selectedItem = initialSelect; + } } } diff --git a/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml index 2c91863..ebb3445 100644 --- a/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml +++ b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml @@ -84,6 +84,8 @@ PageBase { model: root.schemes rootParent: root.flickable + initialSelect: Config.llm.appearance.scheme + onAccepted: { if (!selectedItem) return; diff --git a/Plugins/ZShell/Llm/codehighlighter.cpp b/Plugins/ZShell/Llm/codehighlighter.cpp index ea99e54..c926c57 100644 --- a/Plugins/ZShell/Llm/codehighlighter.cpp +++ b/Plugins/ZShell/Llm/codehighlighter.cpp @@ -21,7 +21,6 @@ namespace ZShell::llm { namespace hl { -// Role ids; 0 means "no color". enum Role : uint8_t { None = 0, Comment, @@ -43,29 +42,43 @@ enum Role : uint8_t { const char* roleName(Role role) { switch (role) { - case Comment: return "comment"; - case String: return "string"; - case StringKey: return "string.key"; - case Number: return "number"; - case Constant: return "constant"; - case Keyword: return "keyword"; - case Type: return "type"; - case Function: return "function"; - case Method: return "method"; - case Macro: return "macro"; - case Preproc: return "preproc"; - case Operator: return "operator"; - case Property: return "property"; - case Label: return "label"; - case Attribute: return "attribute"; - default: return ""; + case Comment: + return "comment"; + case String: + return "string"; + case StringKey: + return "string.key"; + case Number: + return "number"; + case Constant: + return "constant"; + case Keyword: + return "keyword"; + case Type: + return "type"; + case Function: + return "function"; + case Method: + return "method"; + case Macro: + return "macro"; + case Preproc: + return "preproc"; + case Operator: + return "operator"; + case Property: + return "property"; + case Label: + return "label"; + case Attribute: + return "attribute"; + default: + return ""; } } using LanguageFn = const TSLanguage* (*)(); -// Grammar registry generated by CMake from the installed grammars and -// their highlight queries (see CMakeLists.txt). const QHash& grammars() { static const QHash grammars = [] { QHash map; @@ -89,8 +102,6 @@ const QHash& grammars() { CodeHighlighter* CodeHighlighter::s_instance = nullptr; const QHash& CodeHighlighter::aliases() { - // Language tags as written in code fences (and common variants) to - // grammar id. static const QHash aliases = [] { QHash map; map.insert("c", "c"); @@ -145,49 +156,38 @@ const QHash& CodeHighlighter::aliases() { uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) { const QString n = QString::fromUtf8(name, length); - if (n == "comment") - return hl::Role::Comment; + if (n == "comment") return hl::Role::Comment; if (n.startsWith("string")) - return n == "string.special.key" ? hl::Role::StringKey : hl::Role::String; - if (n == "escape" || n == "regexp") - return hl::Role::String; - if (n.startsWith("number")) - return hl::Role::Number; - if (n.startsWith("constant") || n == "boolean" || n == "bool" - || n.startsWith("character")) + return n == "string.special.key" ? hl::Role::StringKey + : hl::Role::String; + if (n == "escape" || n == "regexp") return hl::Role::String; + if (n.startsWith("number")) return hl::Role::Number; + if (n.startsWith("constant") || n == "boolean" || n == "bool" || + n.startsWith("character")) return hl::Role::Constant; - if (n.startsWith("keyword")) - return hl::Role::Keyword; - if (n == "type" || n.startsWith("type.")) + if (n.startsWith("keyword")) return hl::Role::Keyword; + if (n == "type" || n.startsWith("type.")) return hl::Role::Type; + if (n.startsWith("namespace") || n.startsWith("module") || + n == "support.type" || n == "support.namespace") return hl::Role::Type; - if (n.startsWith("namespace") || n.startsWith("module") - || n == "support.type" || n == "support.namespace") - return hl::Role::Type; - if (n.startsWith("function") || n == "constructor" - || n.startsWith("support.function")) + if (n.startsWith("function") || n == "constructor" || + n.startsWith("support.function")) return hl::Role::Function; - if (n == "method" || n == "method.builtin") - return hl::Role::Method; - if (n.startsWith("macro")) - return hl::Role::Macro; - if (n.startsWith("preproc")) - return hl::Role::Preproc; - if (n == "operator" || n == "punctuation.operator" || n.startsWith("operator.") - || n.startsWith("punctuation")) + if (n == "method" || n == "method.builtin") return hl::Role::Method; + if (n.startsWith("macro")) return hl::Role::Macro; + if (n.startsWith("preproc")) return hl::Role::Preproc; + if (n == "operator" || n == "punctuation.operator" || + n.startsWith("operator.") || n.startsWith("punctuation")) return hl::Role::Operator; if (n == "property" || n == "field" || n.startsWith("property.")) return hl::Role::Property; - if (n == "label") - return hl::Role::Label; + if (n == "label") return hl::Role::Label; if (n.startsWith("attribute") || n == "annotation") return hl::Role::Attribute; - // HTML tag names, CSS variables and friends. if (n == "tag" || (n.startsWith("tag.") && n != "tag.delimiter")) return hl::Role::Keyword; - if (n.startsWith("variable")) - return hl::Role::Constant; - if (n.startsWith("support")) - return hl::Role::Function; + if (n.startsWith("variable")) return hl::Role::Constant; + if (n.startsWith("support")) return hl::Role::Function; return hl::Role::None; } @@ -195,68 +195,116 @@ const char* CodeHighlighter::roleName(uint8_t role) { return hl::roleName(static_cast(role)); } +QString CodeHighlighter::resolveId(const QString& language) { + const QString tag = language.trimmed().toLower(); + const QString alias = aliases().value(tag); + return alias.isEmpty() ? tag : alias; // unknown tags = grammar id +} + +QString CodeHighlighter::cacheKey(const QString& id, const QString& code) { + return id + QLatin1Char('\x01') + QString::number(code.size()) + + QLatin1Char('\x01') + QString::number(qHash(code)); +} + +QVariantList CodeHighlighter::lookupSpans( + const QString& code, const QString& language) const { + if (code.isEmpty()) return {}; + const QString key = cacheKey(resolveId(language), code); + QMutexLocker locker(&m_cacheMutex); + const auto it = m_spanCache.constFind(key); + if (it == m_spanCache.constEnd() || it->code != code) return {}; + // Most recently used; eviction drops the oldest entries first. + const qsizetype pos = m_spanCacheOrder.indexOf(key); + if (pos >= 0) m_spanCacheOrder.move(pos, m_spanCacheOrder.size() - 1); + return it->spans; +} + +void CodeHighlighter::storeSpans( + const QString& code, + const QString& language, + const QVariantList& spans) const { + if (spans.isEmpty() || code.isEmpty()) return; + static constexpr int kMaxEntries = 32; + static constexpr int kMaxBytes = 1024 * 1024; + const QString key = cacheKey(resolveId(language), code); + const int bytes = static_cast(code.toUtf8().size()); + QMutexLocker locker(&m_cacheMutex); + auto it = m_spanCache.find(key); + if (it != m_spanCache.end()) { + m_spanCacheBytes -= static_cast(it->code.toUtf8().size()); + m_spanCache.erase(it); + m_spanCacheOrder.removeAll(key); + } + while (m_spanCacheOrder.size() >= kMaxEntries || + m_spanCacheBytes + bytes > kMaxBytes) { + if (m_spanCacheOrder.isEmpty()) break; + const QString oldest = m_spanCacheOrder.takeFirst(); + m_spanCacheBytes -= + static_cast(m_spanCache.value(oldest).code.toUtf8().size()); + m_spanCache.remove(oldest); + } + m_spanCache.insert(key, SpanCacheEntry{code, spans}); + m_spanCacheOrder.append(key); + m_spanCacheBytes += bytes; +} + void CodeHighlighter::highlight( const QString& code, const QString& language, QObject* target, int token) { - QThreadPool::globalInstance()->start([this, target, token, code, language]() { - const QVariantList spans = doHighlight(code, language); - // The target item may be long gone by now (delegates are - // recreated constantly while chats load); a destroyed target is - // simply skipped. Deliver through the app instance (never - // destroyed) and re-check there: posting to `target` from the - // pool thread would race with its destruction. - QPointer guard(target); + QPointer targetGuard(target); + const QVariantList cached = lookupSpans(code, language); + if (!cached.isEmpty()) { QMetaObject::invokeMethod( - QCoreApplication::instance(), - [guard, token, spans]() { - if (!guard) - return; - // QML functions are only invokable by their generic - // QVariant overload, so pass untyped arguments. + targetGuard, + "onHighlightSpans", + Qt::DirectConnection, + Q_ARG(QVariant, token), + Q_ARG(QVariant, cached)); + return; + } + QThreadPool::globalInstance()->start( + [this, target, token, code, language]() { + const QVariantList spans = doHighlight(code, language); + storeSpans(code, language, spans); + QPointer guard(target); QMetaObject::invokeMethod( - guard, "onHighlightSpans", - Q_ARG(QVariant, token), Q_ARG(QVariant, spans)); - }, - Qt::QueuedConnection); - }); + QCoreApplication::instance(), + [guard, token, spans]() { + if (!guard) return; + QMetaObject::invokeMethod( + guard, + "onHighlightSpans", + Q_ARG(QVariant, token), + Q_ARG(QVariant, spans)); + }, + Qt::QueuedConnection); + }); } QVariantList CodeHighlighter::doHighlight( const QString& code, const QString& language) const { QVariantList spans; - if (code.isEmpty()) - return spans; + if (code.isEmpty()) return spans; - const QString tag = language.trimmed().toLower(); - const QString id = [&] { - const QString alias = aliases().value(tag); - return alias.isEmpty() ? tag : alias; // unknown tags = grammar id - }(); + const QString id = resolveId(language); const Grammar& grammar = hl::grammars().value(id); - if (grammar.libs.empty()) - return spans; + if (grammar.libs.empty()) return spans; - // Guard against pathological blocks; highlighting is best-effort. static constexpr size_t kMaxBytes = 512 * 1024; const QByteArray utf8 = code.toUtf8(); - if (static_cast(utf8.size()) > kMaxBytes) - return spans; + if (static_cast(utf8.size()) > kMaxBytes) return spans; const TSLanguage* lang = nullptr; TSQuery* query = nullptr; { QMutexLocker locker(&m_stateMutex); auto& state = m_states[id]; - // A missing library is retriable (it may be installed while the - // shell runs); an ABI mismatch on every candidate is not. Cache - // successes and permanent failures; leave retriable misses out. if (!state || (!state->lang && !state->bad)) { std::shared_ptr fresh = std::make_shared(); bool abiMismatch = false; for (size_t i = 0; i < grammar.libs.size(); ++i) { - void* lib = dlopen(grammar.libs[i].c_str(), - RTLD_NOW | RTLD_LOCAL); - if (!lib) - continue; + void* lib = + dlopen(grammar.libs[i].c_str(), RTLD_NOW | RTLD_LOCAL); + if (!lib) continue; auto* symbol = reinterpret_cast( dlsym(lib, grammar.symbols[i].c_str())); if (!symbol) { @@ -266,7 +314,7 @@ QVariantList CodeHighlighter::doHighlight( const TSLanguage* candidate = symbol(); const uint32_t version = ts_language_abi_version(candidate); if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION || - version > TREE_SITTER_LANGUAGE_VERSION) { + version > TREE_SITTER_LANGUAGE_VERSION) { dlclose(lib); abiMismatch = true; continue; @@ -276,7 +324,6 @@ QVariantList CodeHighlighter::doHighlight( break; } if (fresh->lang) { - // Candidates in priority order; first that compiles wins. for (const char* source : grammar.queries) { TSQueryError errorType = TSQueryErrorNone; uint32_t errorOffset = 0; @@ -286,21 +333,17 @@ QVariantList CodeHighlighter::doHighlight( static_cast(std::strlen(source)), &errorOffset, &errorType); - if (!candidate) - continue; + if (!candidate) continue; fresh->query = candidate; break; } - if (!fresh->query) - fresh->bad = true; + if (!fresh->query) fresh->bad = true; } else if (abiMismatch) { fresh->bad = true; } - if (fresh->lang || fresh->bad) - state = std::move(fresh); + if (fresh->lang || fresh->bad) state = std::move(fresh); } - if (!state || state->bad || !state->lang) - return spans; + if (!state || state->bad || !state->lang) return spans; lang = static_cast(state->lang); query = static_cast(state->query); } @@ -317,13 +360,9 @@ QVariantList CodeHighlighter::doHighlight( TSQueryCursor* cursor = ts_query_cursor_new(); ts_query_cursor_exec(cursor, query, ts_tree_root_node(tree)); - // Per-byte winner table: captures arrive in document order and later - // captures overwrite earlier ones (tree-sitter highlight convention). const uint32_t size = static_cast(utf8.size()); std::vector kinds(size, 0); - // QML slices the code by UTF-16 code unit, so spans must be in code - // units, not bytes. cu[b] = code units before byte b. std::vector cu(size + 1, 0); for (uint32_t b = 0; b < size; ++b) { cu[b + 1] = cu[b]; @@ -333,9 +372,9 @@ QVariantList CodeHighlighter::doHighlight( else if (c < 0xC0) ; // continuation byte else if (c < 0xF0) - cu[b + 1] += 1; // 2/3-byte lead -> BMP -> one unit + cu[b + 1] += 1; else - cu[b + 1] += 2; // 4-byte lead -> surrogate pair + cu[b + 1] += 2; } TSQueryMatch match; @@ -343,14 +382,13 @@ QVariantList CodeHighlighter::doHighlight( while (ts_query_cursor_next_capture(cursor, &match, &captureIndex)) { const TSQueryCapture& capture = match.captures[captureIndex]; uint32_t nameLength = 0; - const char* name = ts_query_capture_name_for_id(query, capture.index, &nameLength); + const char* name = + ts_query_capture_name_for_id(query, capture.index, &nameLength); const uint8_t role = roleFor(name, nameLength); - if (role == 0) - continue; + if (role == 0) continue; const uint32_t start = ts_node_start_byte(capture.node); const uint32_t end = ts_node_end_byte(capture.node); - if (end <= start || end > size) - continue; + if (end <= start || end > size) continue; std::fill(kinds.begin() + start, kinds.begin() + end, role); } @@ -378,8 +416,7 @@ QVariantList CodeHighlighter::doHighlight( } CodeHighlighter* CodeHighlighter::create(QQmlEngine*, QJSEngine*) { - if (!s_instance) - s_instance = new CodeHighlighter(); + if (!s_instance) s_instance = new CodeHighlighter(); return s_instance; } diff --git a/Plugins/ZShell/Llm/codehighlighter.hpp b/Plugins/ZShell/Llm/codehighlighter.hpp index 8fb321d..3987379 100644 --- a/Plugins/ZShell/Llm/codehighlighter.hpp +++ b/Plugins/ZShell/Llm/codehighlighter.hpp @@ -43,6 +43,10 @@ namespace ZShell::llm { // means "no highlighting" (unknown language or grammar not installed). // token is passed back unchanged so the caller can drop results for // superseded code; a destroyed target is simply skipped. +// +// Successful results are cached by (language, code). A request for +// unchanged code delivers the cached spans directly, without re-parsing +// — while a segment streams, only the grown tail is ever re-parsed. class CodeHighlighter : public QObject { Q_OBJECT QML_ELEMENT @@ -76,13 +80,31 @@ class CodeHighlighter : public QObject { // Maps a tree-sitter capture name to a role index (0 = unstyled). [[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length); [[nodiscard]] static const char* roleName(uint8_t role); + // Maps a fence language tag to the grammar id (see aliases()). + [[nodiscard]] static QString resolveId(const QString& language); + [[nodiscard]] static QString cacheKey(const QString& id, const QString& code); // The parsing work; runs on worker threads, so the per-language // state must be initialized under m_stateMutex and is shared as an // immutable object afterwards. [[nodiscard]] QVariantList doHighlight(const QString& code, const QString& language) const; + // Exact-match span cache. lookupSpans() runs on the GUI thread, + // storeSpans() on worker threads; both take m_cacheMutex. + [[nodiscard]] QVariantList lookupSpans(const QString& code, const QString& language) const; + void storeSpans(const QString& code, const QString& language, + const QVariantList& spans) const; + + struct SpanCacheEntry { + QString code; // re-compared on lookup; a hash collision can + // never deliver the wrong spans + QVariantList spans; + }; mutable QHash> m_states; mutable QMutex m_stateMutex; + mutable QHash m_spanCache; + mutable QStringList m_spanCacheOrder; // LRU order, oldest first + mutable int m_spanCacheBytes = 0; + mutable QMutex m_cacheMutex; static CodeHighlighter* s_instance; }; diff --git a/Plugins/ZShell/Llm/markdownparser.cpp b/Plugins/ZShell/Llm/markdownparser.cpp index 1203d72..3b9ab7d 100644 --- a/Plugins/ZShell/Llm/markdownparser.cpp +++ b/Plugins/ZShell/Llm/markdownparser.cpp @@ -42,9 +42,18 @@ QVariantList MarkdownParser::parse(const QString& source) { QStringLiteral("\\$\\$(.+?)\\$\\$"), QRegularExpression::DotMatchesEverythingOption); - auto makeBlock = [](LlmMarkdown::Type type) { + auto makeBlock = [&](LlmMarkdown::Type type) { QVariantMap block; block.insert("type", static_cast(type)); + // Stable per-position identity ("index:type") for the QML + // ScriptModel: blocks that survive a re-parse keep their id, so + // their delegates are updated in place instead of recreated + // (which would drop code highlights mid-stream). The type is + // part of the id so a block that changes type is rebuilt. + block.insert("id", + QString::number(static_cast(blocks.size())) + + QLatin1Char(':') + + QString::number(static_cast(type))); return block; }; From 23d2b31c0afa9fc0b7524eea0e7570035fbff44c Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 27 Aug 2026 16:57:02 +0200 Subject: [PATCH 10/90] expandable dialogs for color scheme, fonts and scheduling modes --- Modules/Settings/Common/DialogRowButton.qml | 18 +- .../Settings/Common/DialogSelectButton.qml | 11 +- Modules/Settings/Common/RowButton.qml | 26 +- Modules/Settings/Common/SearchPopup.qml | 128 +++ Modules/Settings/Common/TimeDialogRow.qml | 249 +++++ Modules/Settings/Common/TimeDialogSelect.qml | 50 + .../{OverlayRow.qml => TimeDialogToggle.qml} | 12 +- Modules/Settings/Common/TimeInput.qml | 940 ++++++++---------- Modules/Settings/Common/ToggleRow.qml | 14 +- Modules/Settings/Content.qml | 7 +- Modules/Settings/PageCompRegistry.qml | 4 + Modules/Settings/Pages.qml | 13 + .../Pages/Panels/Bar/BarStatusIcons.qml | 2 + .../Settings/Pages/Panels/SidebarPanel.qml | 4 + .../Settings/Pages/Wallpaper/ColorsFonts.qml | 327 ++++++ Modules/Settings/Pages/WallpaperPage.qml | 107 +- Modules/Settings/PopupManager.qml | 20 - Modules/Settings/PopupOverlay.qml | 47 - Modules/Settings/SettingsState.qml | 1 + cli/src/zshell/subcommands/scheme.py | 7 +- 20 files changed, 1325 insertions(+), 662 deletions(-) create mode 100644 Modules/Settings/Common/SearchPopup.qml create mode 100644 Modules/Settings/Common/TimeDialogRow.qml create mode 100644 Modules/Settings/Common/TimeDialogSelect.qml rename Modules/Settings/Common/{OverlayRow.qml => TimeDialogToggle.qml} (86%) create mode 100644 Modules/Settings/Pages/Wallpaper/ColorsFonts.qml delete mode 100644 Modules/Settings/PopupManager.qml delete mode 100644 Modules/Settings/PopupOverlay.qml diff --git a/Modules/Settings/Common/DialogRowButton.qml b/Modules/Settings/Common/DialogRowButton.qml index 1d4d547..80cc881 100644 --- a/Modules/Settings/Common/DialogRowButton.qml +++ b/Modules/Settings/Common/DialogRowButton.qml @@ -13,15 +13,21 @@ Item { property bool acceptAllowed: true required property string acceptLabel required property Component content + property bool first: false required property string header property int horizontalContentMargin - required property string icon + property string icon + property alias iconLabel: openButton.iconLabel required property string label + property bool last: false property bool open property real openHeight: Math.min(rootParent.height * 0.8, 600) property real openWidth: Math.min(rootParent.width * 0.8, 400) required property Item rootParent + property alias rowButton: openButton property bool separateContent + required property string settingAnchor + property string subtext: "" signal accepted signal cancelled @@ -56,6 +62,7 @@ Item { enabled: false hoverEnabled: enabled parent: root.open ? root.rootParent : root + preventStealing: true onClicked: root.open = false } @@ -75,7 +82,8 @@ Item { backdrop.enabled: true dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased - dialogBg.radius: Tokens.rounding.largeIncreased + dialogBg.topLeftRadius: Tokens.rounding.largeIncreased + dialogBg.topRightRadius: Tokens.rounding.largeIncreased dialogContent.opacity: 1 dialogWrapper.height: root.openHeight dialogWrapper.width: root.openWidth @@ -103,7 +111,7 @@ Item { } Anim { - properties: "opacity,radius,bottomLeftRadius,bottomRightRadius" + properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius" type: Anim.DefaultEffects } @@ -166,6 +174,8 @@ Item { opacity: 0 sourceComponent: MouseArea { + preventStealing: true + onWheel: event => event.accepted = true ColumnLayout { @@ -216,6 +226,7 @@ Item { TextButton { horizontalPadding: Tokens.padding.largeIncreased isRound: true + stateLayer.preventStealing: true text: qsTr("Cancel") type: TextButton.Text verticalPadding: Tokens.padding.small @@ -230,6 +241,7 @@ Item { enabled: root.acceptAllowed horizontalPadding: Tokens.padding.largeIncreased isRound: true + stateLayer.preventStealing: true text: root.acceptLabel type: TextButton.Text verticalPadding: Tokens.padding.small diff --git a/Modules/Settings/Common/DialogSelectButton.qml b/Modules/Settings/Common/DialogSelectButton.qml index 3cce148..a3a9c74 100644 --- a/Modules/Settings/Common/DialogSelectButton.qml +++ b/Modules/Settings/Common/DialogSelectButton.qml @@ -91,7 +91,14 @@ DialogRowButton { } onOpenChanged: { - if (open) - selectedItem = null; + if (open) { + root.rootParent.interactive = false; + if (!initialSelect) + selectedItem = null; + else + selectedItem = initialSelect; + } else { + root.rootParent.interactive = true; + } } } diff --git a/Modules/Settings/Common/RowButton.qml b/Modules/Settings/Common/RowButton.qml index 5d2953d..50d1c3d 100644 --- a/Modules/Settings/Common/RowButton.qml +++ b/Modules/Settings/Common/RowButton.qml @@ -17,6 +17,7 @@ ConnectedRect { property alias subtext: subLabel.text property alias text: label.text property string trailingIcon + property string activeItem signal clicked(event: MouseEvent) @@ -40,15 +41,16 @@ ConnectedRect { spacing: Tokens.spacing.medium Behavior on opacity { - Anim { - } + Anim {} } MaterialIcon { id: iconLabel color: Colors.palette.m3onSurfaceVariant + visible: root.icon !== "" fill: 1 + text: root.icon font.pointSize: Tokens.font.size.normal } @@ -79,6 +81,26 @@ ConnectedRect { } } + Loader { + active: root.activeItem && root.subtext + asynchronous: true + visible: active + Layout.fillHeight: true + + sourceComponent: CustomRect { + color: Colors.palette.m3secondaryContainer + radius: Tokens.rounding.full + implicitWidth: text.implicitWidth + Tokens.padding.medium * 2 + + CustomText { + id: text + anchors.centerIn: parent + color: Colors.palette.m3onSecondaryContainer + text: root.activeItem + } + } + } + Loader { active: root.trailingIcon asynchronous: true diff --git a/Modules/Settings/Common/SearchPopup.qml b/Modules/Settings/Common/SearchPopup.qml new file mode 100644 index 0000000..193fd1a --- /dev/null +++ b/Modules/Settings/Common/SearchPopup.qml @@ -0,0 +1,128 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services + +DialogRowButton { + id: root + + required property var model + property var selectedItem + property var initialSelect + + acceptAllowed: !!selectedItem + horizontalContentMargin: -Tokens.padding.extraSmall + separateContent: true + + content: Component { + ColumnLayout { + VerticalFadeListView { + bottomMargin: Tokens.padding.large + Layout.fillHeight: true + Layout.fillWidth: true + model: ScriptModel { + values: { + const s = searchField.text; + var regex = new RegExp(s, "i"); + + return root.model.filter(n => regex.test(n)); + } + } + spacing: 0 + topMargin: Tokens.padding.large + + delegate: CustomRect { + id: item + + required property var modelData + readonly property bool selected: root.selectedItem === modelData + + anchors.left: ListView.view.contentItem.left + anchors.margins: 1 // Gets cut off for some reason without this + anchors.right: ListView.view.contentItem.right + color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0) + implicitHeight: label.implicitHeight + Tokens.padding.small * 2 + radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium + + Behavior on radius { + Anim { + type: Anim.SlowEffects + } + } + + StateLayer { + id: stateLayer + + onClicked: root.selectedItem = item.modelData + } + + CustomText { + id: label + + anchors.left: parent.left + anchors.margins: Tokens.padding.large + anchors.right: item.selected ? checkIcon.left : parent.right + anchors.rightMargin: item.selected ? Tokens.spacing.medium : anchors.margins + anchors.verticalCenter: parent.verticalCenter + color: item.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + elide: Text.ElideRight + font.pointSize: Tokens.font.size.smaller + text: item.modelData + } + + MaterialIcon { + id: checkIcon + + anchors.margins: Tokens.padding.large + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + color: Colors.palette.m3onTertiaryContainer + font.pointSize: Tokens.font.size.normal + opacity: item.selected ? 1 : 0 + text: "check" + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + } + } + + SearchBar { + id: searchField + + Layout.fillWidth: true + bg.border.color: Colors.palette.m3outlineVariant + bg.color: Colors.tPalette.m3surfaceContainerLowest + clearIcon.font.pointSize: Tokens.font.size.large + clearIcon.padding: Tokens.padding.extraSmall + font.pointSize: Tokens.font.size.small + placeholderText: qsTr("Search...") + searchIcon.anchors.leftMargin: Tokens.padding.large + searchIcon.font.pointSize: Tokens.font.size.large + + Behavior on bg.border.color { + CAnim {} + } + } + } + } + + onOpenChanged: { + if (open) { + root.rootParent.interactive = false; + if (!initialSelect) + selectedItem = null; + else + selectedItem = initialSelect; + } else { + root.rootParent.interactive = true; + } + } +} diff --git a/Modules/Settings/Common/TimeDialogRow.qml b/Modules/Settings/Common/TimeDialogRow.qml new file mode 100644 index 0000000..25bc7cb --- /dev/null +++ b/Modules/Settings/Common/TimeDialogRow.qml @@ -0,0 +1,249 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Blobs +import qs.Components +import ZShell.Config +import qs.Services + +Item { + id: root + + property bool acceptAllowed: true + required property string acceptLabel + required property Component content + required property string header + property int horizontalContentMargin + property string icon + required property string label + property alias iconLabel: openButton.iconText + property alias rowButton: openButton + property alias contentItem: dialogContent.item + property string subtext: "" + property bool open + property real openHeight: Math.min(rootParent.height * 0.8, 400) + property real openWidth: Math.min(rootParent.width * 0.8, 600) + required property Item rootParent + property bool first: false + property bool last: false + required property string settingAnchor + property color openColor: Colors.palette.m3surfaceContainerHighest + + signal accepted + signal cancelled + + function reparentWrapper(): void { + const newParent = open ? rootParent : root; + const pos = dialogWrapper.mapToItem(newParent, 0, 0); + dialogWrapper.parent = newParent; + dialogWrapper.x = pos.x; + dialogWrapper.y = pos.y; + } + + Layout.fillWidth: true + implicitHeight: openButton.implicitHeight + z: open || dialogTransition.running ? 2 : 0 + + BlobGroup { + id: blobGroup + + color: root.open ? root.openColor : Colors.tPalette.m3surfaceContainer + + Behavior on color { + CAnim {} + } + } + + MouseArea { + id: backdrop + + anchors.fill: parent + enabled: false + hoverEnabled: enabled + preventStealing: true + parent: root.open ? root.rootParent : root + + onClicked: root.open = false + } + + Item { + id: dialogWrapper + + height: openButton.implicitHeight + width: root.width + z: 1 + + states: State { + name: "open" + when: root.open + + PropertyChanges { + backdrop.enabled: true + dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased + dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased + dialogBg.topRightRadius: Tokens.rounding.largeIncreased + dialogBg.topLeftRadius: Tokens.rounding.largeIncreased + dialogContent.opacity: 1 + dialogWrapper.height: root.openHeight + dialogWrapper.width: root.openWidth + dialogWrapper.x: (root.rootParent.width - root.openWidth) / 2 + dialogWrapper.y: (root.rootParent.height - root.openHeight) / 2 + elevation.opacity: 1 + openButton.opacity: 0 + } + } + transitions: Transition { + id: dialogTransition + + SequentialAnimation { + ScriptAction { + script: root.reparentWrapper() + } + + Anim { + properties: "x,y" + } + } + + PropertyAction { + property: "enabled" + } + + Anim { + properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius" + type: Anim.DefaultEffects + } + + Anim { + properties: "width,height" + } + } + + Elevation { + id: elevation + + anchors.fill: parent + bottomLeftRadius: dialogBg.bottomLeftRadius + bottomRightRadius: dialogBg.bottomRightRadius + level: 4 + opacity: 0 + radius: dialogBg.radius + + transform: Matrix4x4 { + matrix: dialogBg.deformMatrix + } + } + + BlobRect { + id: dialogBg + + anchors.fill: parent + bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + bottomRightRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + deformScale: 0 + group: blobGroup + opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) + } + + TimeDialogToggle { + id: openButton + + anchors.left: parent.left + anchors.right: parent.right + color: "transparent" + height: Math.min(implicitHeight, parent.height) + subtext: root.subtext + last: root.last + first: root.first + text: root.label + + transform: Matrix4x4 { + matrix: dialogBg.deformMatrix + } + + onClicked: root.open = true + } + + Loader { + id: dialogContent + + active: opacity > 0 + anchors.fill: parent + asynchronous: false + opacity: 0 + + sourceComponent: MouseArea { + onWheel: event => event.accepted = true + preventStealing: true + acceptedButtons: Qt.NoButton + cursorShape: undefined + enabled: dialogContent.opacity > 0 + clip: true + implicitWidth: innerLayout.implicitWidth + innerLayout.anchors.margins * 2 + implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins + innerLayout.anchors.bottomMargin + + ColumnLayout { + id: innerLayout + anchors.bottomMargin: Tokens.padding.largeIncreased + anchors.fill: parent + anchors.margins: Tokens.padding.extraLarge + spacing: 0 + + CustomText { + font.pointSize: Tokens.font.size.large + text: root.header + } + + Loader { + Layout.fillWidth: true + Layout.leftMargin: root.horizontalContentMargin + Layout.topMargin: Tokens.spacing.medium + Layout.bottomMargin: Tokens.spacing.medium + Layout.rightMargin: root.horizontalContentMargin + sourceComponent: root.content + } + + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: Tokens.spacing.extraSmall + + TextButton { + horizontalPadding: Tokens.padding.largeIncreased + isRound: true + text: qsTr("Cancel") + type: TextButton.Text + verticalPadding: Tokens.padding.small + stateLayer.preventStealing: true + + onClicked: { + root.cancelled(); + root.open = false; + } + } + + TextButton { + enabled: root.acceptAllowed + horizontalPadding: Tokens.padding.largeIncreased + isRound: true + text: root.acceptLabel + type: TextButton.Text + stateLayer.preventStealing: true + verticalPadding: Tokens.padding.small + + onClicked: { + root.accepted(); + root.open = false; + } + } + } + } + } + transform: Matrix4x4 { + matrix: dialogBg.deformMatrix + } + } + } +} diff --git a/Modules/Settings/Common/TimeDialogSelect.qml b/Modules/Settings/Common/TimeDialogSelect.qml new file mode 100644 index 0000000..33967dc --- /dev/null +++ b/Modules/Settings/Common/TimeDialogSelect.qml @@ -0,0 +1,50 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import ZShell.Config +import qs.Helpers +import qs.Components +import qs.Services + +TimeDialogRow { + id: root + + required property int start + required property int end + property int endCurrent + property int startCurrent + openColor: Colors.palette.m3surfaceContainer + openHeight: Math.min(rootParent.height * 0.8, contentItem?.implicitHeight ?? 0) + openWidth: Math.min(rootParent.width * 0.8, contentItem?.implicitWidth ?? 0) + + function keyFor(item: var): string { + return item.id; + } + + function labelFor(item: var): string { + return item.label; + } + + horizontalContentMargin: -Tokens.padding.extraSmall + + content: Component { + TimeInput { + id: timeContent + start: root.start + end: root.end + + onEndEdit: value => root.endCurrent = value + onStartEdit: value => root.startCurrent = value + } + } + + onOpenChanged: { + if (open) { + root.rootParent.interactive = false; + root.z = 5; + } else { + root.rootParent.interactive = true; + root.z = 1; + } + } +} diff --git a/Modules/Settings/Common/OverlayRow.qml b/Modules/Settings/Common/TimeDialogToggle.qml similarity index 86% rename from Modules/Settings/Common/OverlayRow.qml rename to Modules/Settings/Common/TimeDialogToggle.qml index f7a6dae..0b540fa 100644 --- a/Modules/Settings/Common/OverlayRow.qml +++ b/Modules/Settings/Common/TimeDialogToggle.qml @@ -10,12 +10,14 @@ ConnectedRect { property alias checked: switchButton.checked property int horizontalPadding: Tokens.padding.largeIncreased - required property Component popup property alias subtext: subtext.text property alias text: text.text + property alias icon: icon + property string iconText: "open_in_new" property int verticalPadding: Tokens.padding.small - signal clicked(checked: bool) + signal clicked + signal toggled(checked: bool) Layout.fillWidth: true implicitHeight: layout.implicitHeight + verticalPadding * 2 @@ -50,11 +52,11 @@ ConnectedRect { anchors.right: switchButton.left anchors.rightMargin: Tokens.spacing.medium anchors.verticalCenter: parent.verticalCenter - text: "open_in_new" + text: root.iconText } StateLayer { - onClicked: PopupManager.requestOpen(root.popup) + onClicked: root.clicked() } CustomSwitch { @@ -64,6 +66,6 @@ ConnectedRect { anchors.rightMargin: root.horizontalPadding anchors.verticalCenter: parent.verticalCenter - onClicked: root.clicked(checked) + onClicked: root.toggled(checked) } } diff --git a/Modules/Settings/Common/TimeInput.qml b/Modules/Settings/Common/TimeInput.qml index 05e8052..c8bfd2b 100644 --- a/Modules/Settings/Common/TimeInput.qml +++ b/Modules/Settings/Common/TimeInput.qml @@ -6,15 +6,13 @@ import qs.Components import qs.Helpers import qs.Services -CustomClippingRect { +ColumnLayout { id: root - required property var object - required property list settings - property bool shouldBeActive: true - - signal applySettings(startTime: int, endTime: int) - signal close + required property int start + required property int end + signal endEdit(time: int) + signal startEdit(time: int) function convertHour(timeValue: int): int { return Math.floor(timeValue / 60); @@ -28,555 +26,459 @@ CustomClippingRect { return hour * 60 + minute; } - color: Colors.palette.m3surfaceContainer - implicitHeight: column.implicitHeight + column.anchors.margins * 2 + buttonRow.implicitHeight + buttonRow.anchors.topMargin - implicitWidth: column.implicitWidth + column.anchors.margins * 2 - radius: Tokens.rounding.largeIncreased + spacing: Tokens.spacing.medium - ColumnLayout { - id: column + RowLayout { + CustomRect { + id: startHourRect - anchors.left: parent.left - anchors.margins: Tokens.padding.largeIncreased - anchors.right: parent.right - anchors.top: parent.top - spacing: Tokens.spacing.medium + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small + + CustomRect { + anchors.fill: parent + border.color: startHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: startHourField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim {} + } + } + + TextFieldBase { + id: startHourField + + function setConfigText(): string { + var val = root.convertHour(root.start); + if (val === 0) { + return "00"; + } + return String(val); + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { + event.accepted = true; + if (startHourField.text.length >= 2) { + startHourField.text = "0" + startHourField.text[0]; + } else if (startHourField.text.length === 1) { + startHourField.text = "0"; + } + return; + } else if (event.key === Qt.Key_Escape) { + event.accepted = true; + startHourField.text = setConfigText(); + startHourField.focus = false; + } else if (event.key === Qt.Key_Return) { + startHourField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + startMinuteField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + endMinuteField.focus = true; + } + + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { + event.accepted = true; + var digit = event.text; + var textLen = startHourField.text.length; + + if (textLen >= 2 && startHourField.text[0] !== '0') { + return; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(startHourField.text + digit); + } else { + val = parseInt(startHourField.text[1] + digit); + } + + val = Math.max(0, Math.min(23, val)); + + if (textLen >= 2 && val < 10) { + startHourField.text = "0" + val; + } else { + startHourField.text = val.toString(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(startMinuteField.text); + const hours = root.convertToMinutes(parseInt(startHourField.text)); + root.startEdit(hours + mins); + } + } + } CustomText { - text: qsTr("Select time") + id: startSeparator + + font.pointSize: Tokens.font.size.extraLarge + text: ":" } - RowLayout { - CustomRect { - id: startHourRect + CustomRect { + id: startMinuteRect - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small - - CustomRect { - anchors.fill: parent - border.color: startHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: startHourField.focus ? 2 : 0 - radius: parent.radius - border.width - - Behavior on border.width { - Anim { - } - } - } - - TextFieldBase { - id: startHourField - - function setConfigText(setting: string): string { - var val = root.convertHour(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); - } - - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[1]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (startHourField.text.length >= 2) { - startHourField.text = "0" + startHourField.text[0]; - } else if (startHourField.text.length === 1) { - startHourField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - startHourField.text = setConfigText(root.settings[1]); - startHourField.focus = false; - } else if (event.key === Qt.Key_Return) { - startHourField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - startMinuteField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - endMinuteField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = startHourField.text.length; - - if (textLen >= 2 && startHourField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(startHourField.text + digit); - } else { - val = parseInt(startHourField.text[1] + digit); - } - - val = Math.max(0, Math.min(23, val)); - - if (textLen >= 2 && val < 10) { - startHourField.text = "0" + val; - } else { - startHourField.text = val.toString(); - } - } - - event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (startHourField.text === "") - return; - var val = parseInt(startHourField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== startHourField.text) - startHourField.text = newText; - } - } - } - - CustomText { - id: startSeparator - - font.pointSize: Tokens.font.size.extraLarge - text: ":" - } + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small CustomRect { - id: startMinuteRect + anchors.fill: parent + border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: startMinuteField.focus ? 2 : 0 + radius: parent.radius - border.width - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small + Behavior on border.width { + Anim {} + } + } - CustomRect { - anchors.fill: parent - border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: startMinuteField.focus ? 2 : 0 - radius: parent.radius - border.width + TextFieldBase { + id: startMinuteField - Behavior on border.width { - Anim { - } + function setConfigText(): string { + var val = root.convertMinute(root.start); + if (val === 0) { + return "00"; } + return String(val); } - TextFieldBase { - id: startMinuteField - - function setConfigText(setting: string): string { - var val = root.convertMinute(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); - } - - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[1]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (startMinuteField.text.length >= 2) { - startMinuteField.text = "0" + startMinuteField.text[0]; - } else if (startMinuteField.text.length === 1) { - startMinuteField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - startMinuteField.text = setConfigText(root.settings[1]); - startMinuteField.focus = false; - } else if (event.key === Qt.Key_Return) { - startMinuteField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - endHourField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - startHourField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = startMinuteField.text.length; - - if (textLen >= 2 && startMinuteField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(startMinuteField.text + digit); - } else { - val = parseInt(startMinuteField.text[1] + digit); - } - - val = Math.max(0, Math.min(59, val)); - - if (textLen >= 2 && val < 10) { - startMinuteField.text = "0" + val; - } else { - startMinuteField.text = val.toString(); - } - } + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (startMinuteField.text === "") - return; - var val = parseInt(startMinuteField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== startMinuteField.text) - startMinuteField.text = newText; - } - } - } - - Item { - id: spacer - - Layout.preferredWidth: Tokens.spacing.largeIncreased - } - - CustomRect { - id: endHourRect - - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: endHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small - - CustomRect { - anchors.fill: parent - border.color: endHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: endHourField.focus ? 2 : 0 - radius: parent.radius - border.width - - Behavior on border.width { - Anim { + if (startMinuteField.text.length >= 2) { + startMinuteField.text = "0" + startMinuteField.text[0]; + } else if (startMinuteField.text.length === 1) { + startMinuteField.text = "0"; } - } - } - - TextFieldBase { - id: endHourField - - function setConfigText(setting: string): string { - var val = root.convertHour(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); - } - - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[2]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (endHourField.text.length >= 2) { - endHourField.text = "0" + endHourField.text[0]; - } else if (endHourField.text.length === 1) { - endHourField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - endHourField.text = setConfigText(root.settings[2]); - endHourField.focus = false; - } else if (event.key === Qt.Key_Return) { - endHourField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - endMinuteField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - startMinuteField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = endHourField.text.length; - - if (textLen >= 2 && endHourField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(endHourField.text + digit); - } else { - val = parseInt(endHourField.text[1] + digit); - } - - val = Math.max(0, Math.min(23, val)); - - if (textLen >= 2 && val < 10) { - endHourField.text = "0" + val; - } else { - endHourField.text = val.toString(); - } - } - + return; + } else if (event.key === Qt.Key_Escape) { event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (endHourField.text === "") - return; - var val = parseInt(endHourField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== endHourField.text) - endHourField.text = newText; - } - } - } - - CustomText { - id: endSeparator - - font.pointSize: Tokens.font.size.extraLarge - text: ":" - } - - CustomRect { - id: endMinuteRect - - Layout.preferredHeight: 72 - Layout.preferredWidth: 96 - color: endMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest - implicitHeight: 72 - implicitWidth: 96 - radius: Tokens.rounding.small - - CustomRect { - anchors.fill: parent - border.color: endMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest - border.width: endMinuteField.focus ? 2 : 0 - radius: parent.radius - border.width - - Behavior on border.width { - Anim { - } - } - } - - TextFieldBase { - id: endMinuteField - - function setConfigText(setting: string): string { - var val = root.convertMinute(root.object[setting]); - if (val === 0) { - return "00"; - } - return String(val); + startMinuteField.text = setConfigText(); + startMinuteField.focus = false; + } else if (event.key === Qt.Key_Return) { + startMinuteField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + endHourField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + startHourField.focus = true; } - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - clip: true - color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface - font.family: "Roboto" - font.letterSpacing: -0.25 - font.pixelSize: 56 - font.weight: 400 - horizontalAlignment: TextInput.AlignHCenter - text: setConfigText(root.settings[2]) - verticalAlignment: TextInput.AlignVCenter - - Keys.onPressed: event => { - if (event.key === Qt.Key_Backspace) { - event.accepted = true; - if (endMinuteField.text.length >= 2) { - endMinuteField.text = "0" + endMinuteField.text[0]; - } else if (endMinuteField.text.length === 1) { - endMinuteField.text = "0"; - } - return; - } else if (event.key === Qt.Key_Escape) { - event.accepted = true; - endMinuteField.text = setConfigText(root.settings[2]); - endMinuteField.focus = false; - } else if (event.key === Qt.Key_Return) { - endMinuteField.focus = false; - return; - } else if (event.key === Qt.Key_Tab) { - startHourField.focus = true; - } else if (event.key === Qt.Key_Backtab) { - endHourField.focus = true; - } - - if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { - event.accepted = true; - var digit = event.text; - var textLen = endMinuteField.text.length; - - if (textLen >= 2 && endMinuteField.text[0] !== '0') { - return; - } - - var val = 0; - if (textLen === 0) { - val = parseInt(digit); - } else if (textLen === 1) { - val = parseInt(endMinuteField.text + digit); - } else { - val = parseInt(endMinuteField.text[1] + digit); - } - - val = Math.max(0, Math.min(59, val)); - - if (textLen >= 2 && val < 10) { - endMinuteField.text = "0" + val; - } else { - endMinuteField.text = val.toString(); - } - } - + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { event.accepted = true; - } - onCursorPositionChanged: cursorPosition = 2 - onTextEdited: { - if (endMinuteField.text === "") + var digit = event.text; + var textLen = startMinuteField.text.length; + + if (textLen >= 2 && startMinuteField.text[0] !== '0') { return; - var val = parseInt(endMinuteField.text); - if (isNaN(val)) - return; - val = Math.max(0, Math.min(23, val)); - var newText = val.toString(); - if (newText !== endMinuteField.text) - endMinuteField.text = newText; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(startMinuteField.text + digit); + } else { + val = parseInt(startMinuteField.text[1] + digit); + } + + val = Math.max(0, Math.min(59, val)); + + if (textLen >= 2 && val < 10) { + startMinuteField.text = "0" + val; + } else { + startMinuteField.text = val.toString(); + } } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(startMinuteField.text); + const hours = root.convertToMinutes(parseInt(startHourField.text)); + root.startEdit(hours + mins); } } } - RowLayout { - CustomText { - Layout.preferredWidth: spacer.x + spacer.width - text: qsTr("Start") + Item { + id: spacer + + Layout.preferredWidth: Tokens.spacing.largeIncreased + } + + CustomRect { + id: endHourRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: endHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small + + CustomRect { + anchors.fill: parent + border.color: endHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: endHourField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim {} + } } - CustomText { - Layout.preferredWidth: endMinuteRect.width + endHourRect.width - text: qsTr("End") + TextFieldBase { + id: endHourField + + function setConfigText(): string { + var val = root.convertHour(root.end); + if (val === 0) { + return "00"; + } + return String(val); + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { + event.accepted = true; + if (endHourField.text.length >= 2) { + endHourField.text = "0" + endHourField.text[0]; + } else if (endHourField.text.length === 1) { + endHourField.text = "0"; + } + return; + } else if (event.key === Qt.Key_Escape) { + event.accepted = true; + endHourField.text = setConfigText(); + endHourField.focus = false; + } else if (event.key === Qt.Key_Return) { + endHourField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + endMinuteField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + startMinuteField.focus = true; + } + + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { + event.accepted = true; + var digit = event.text; + var textLen = endHourField.text.length; + + if (textLen >= 2 && endHourField.text[0] !== '0') { + return; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(endHourField.text + digit); + } else { + val = parseInt(endHourField.text[1] + digit); + } + + val = Math.max(0, Math.min(23, val)); + + if (textLen >= 2 && val < 10) { + endHourField.text = "0" + val; + } else { + endHourField.text = val.toString(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(endMinuteField.text); + const hours = root.convertToMinutes(parseInt(endHourField.text)); + root.endEdit(hours + mins); + } + } + } + + CustomText { + id: endSeparator + + font.pointSize: Tokens.font.size.extraLarge + text: ":" + } + + CustomRect { + id: endMinuteRect + + Layout.preferredHeight: 72 + Layout.preferredWidth: 96 + color: endMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest + implicitHeight: 72 + implicitWidth: 96 + radius: Tokens.rounding.small + + CustomRect { + anchors.fill: parent + border.color: endMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest + border.width: endMinuteField.focus ? 2 : 0 + radius: parent.radius - border.width + + Behavior on border.width { + Anim {} + } + } + + TextFieldBase { + id: endMinuteField + + function setConfigText(): string { + var val = root.convertMinute(root.end); + if (val === 0) { + return "00"; + } + return String(val); + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface + font.family: "Roboto" + font.letterSpacing: -0.25 + font.pixelSize: 56 + font.weight: 400 + horizontalAlignment: TextInput.AlignHCenter + text: setConfigText() + verticalAlignment: TextInput.AlignVCenter + + Keys.onPressed: event => { + if (event.key === Qt.Key_Backspace) { + event.accepted = true; + if (endMinuteField.text.length >= 2) { + endMinuteField.text = "0" + endMinuteField.text[0]; + } else if (endMinuteField.text.length === 1) { + endMinuteField.text = "0"; + } + return; + } else if (event.key === Qt.Key_Escape) { + event.accepted = true; + endMinuteField.text = setConfigText(); + endMinuteField.focus = false; + } else if (event.key === Qt.Key_Return) { + endMinuteField.focus = false; + return; + } else if (event.key === Qt.Key_Tab) { + startHourField.focus = true; + } else if (event.key === Qt.Key_Backtab) { + endHourField.focus = true; + } + + if (event.text.length === 1 && event.text >= "0" && event.text <= "9") { + event.accepted = true; + var digit = event.text; + var textLen = endMinuteField.text.length; + + if (textLen >= 2 && endMinuteField.text[0] !== '0') { + return; + } + + var val = 0; + if (textLen === 0) { + val = parseInt(digit); + } else if (textLen === 1) { + val = parseInt(endMinuteField.text + digit); + } else { + val = parseInt(endMinuteField.text[1] + digit); + } + + val = Math.max(0, Math.min(59, val)); + + if (textLen >= 2 && val < 10) { + endMinuteField.text = "0" + val; + } else { + endMinuteField.text = val.toString(); + } + } + + event.accepted = true; + } + onCursorPositionChanged: cursorPosition = 2 + onTextChanged: { + var mins = parseInt(endMinuteField.text); + const hours = root.convertToMinutes(parseInt(endHourField.text)); + root.endEdit(hours + mins); + } } } } RowLayout { - id: buttonRow - - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.margins: Tokens.padding.largeIncreased - anchors.right: parent.right - anchors.top: column.bottom - anchors.topMargin: Tokens.spacing.medium - - Item { - id: buttonSpacer - - Layout.fillWidth: true + CustomText { + Layout.preferredWidth: spacer.x + spacer.width + text: qsTr("Start") } - ButtonRow { - spacing: Tokens.spacing.medium - - IconTextButton { - font.pointSize: Tokens.font.size.normal - icon: "close" - inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) - inactiveOnColor: Colors.palette.m3onSurfaceVariant - isRound: true - isToggle: false - shapeMorph: true - shapeMorphExpansion: pressed ? 12 : 0 - text: "Cancel" - - onClicked: root.close() - } - - IconTextButton { - font.pointSize: Tokens.font.size.normal - icon: "check" - isRound: true - isToggle: false - shapeMorph: true - shapeMorphExpansion: pressed ? 12 : 0 - text: "Apply" - - onClicked: { - const start = root.convertToMinutes(parseInt(startHourField.text)) + parseInt(startMinuteField.text); - const end = root.convertToMinutes(parseInt(endHourField.text)) + parseInt(endMinuteField.text); - root.applySettings(start, end); - } - } + CustomText { + Layout.preferredWidth: endMinuteRect.width + endHourRect.width + text: qsTr("End") } } } diff --git a/Modules/Settings/Common/ToggleRow.qml b/Modules/Settings/Common/ToggleRow.qml index 09f22f5..36fb583 100644 --- a/Modules/Settings/Common/ToggleRow.qml +++ b/Modules/Settings/Common/ToggleRow.qml @@ -12,6 +12,7 @@ CustomSwitch { property alias last: bg.last property string settingAnchor property string subtext + property string iconText function flashHighlight(): void { bg.flashHighlight(); @@ -63,8 +64,7 @@ CustomSwitch { text: root.text Behavior on opacity { - Anim { - } + Anim {} } } @@ -81,10 +81,16 @@ CustomSwitch { visible: root.subtext Behavior on opacity { - Anim { - } + Anim {} } } + + MaterialIcon { + id: icon + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: root.iconText ?? "" + } } } diff --git a/Modules/Settings/Content.qml b/Modules/Settings/Content.qml index 66b616a..75a05c2 100644 --- a/Modules/Settings/Content.qml +++ b/Modules/Settings/Content.qml @@ -24,8 +24,7 @@ CustomClippingRect { radius: Tokens.rounding.largeIncreased + Tokens.padding.small Behavior on blobColor { - CAnim { - } + CAnim {} } TapHandler { @@ -70,8 +69,4 @@ CustomClippingRect { anchors.top: parent.top sState: root.sState } - - PopupOverlay { - anchors.fill: parent - } } diff --git a/Modules/Settings/PageCompRegistry.qml b/Modules/Settings/PageCompRegistry.qml index 22aa679..444fb1e 100644 --- a/Modules/Settings/PageCompRegistry.qml +++ b/Modules/Settings/PageCompRegistry.qml @@ -31,6 +31,10 @@ QtObject { WallpaperSelect { } } + + Component { + ColorsFonts {} + } } }, Component { diff --git a/Modules/Settings/Pages.qml b/Modules/Settings/Pages.qml index f853699..ad148b1 100644 --- a/Modules/Settings/Pages.qml +++ b/Modules/Settings/Pages.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Components import ZShell.Config +import qs.Services Item { id: root @@ -41,6 +42,18 @@ Item { objectName: "PageContainer" Component.onCompleted: root.loadPage(root.sState.currentPageIdx) + + CustomRect { + anchors.fill: parent + color: Qt.alpha(Colors.palette.m3shadow, 0.3) + opacity: root.sState.dimmed ? 1 : 0 + visible: opacity > 0 + z: 0 + + Behavior on opacity { + Anim {} + } + } } Connections { diff --git a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml index ae17efa..e6e5394 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml @@ -56,6 +56,7 @@ PageBase { header: qsTr("Add new entry") icon: "add" label: qsTr("Add entry") + last: true model: { const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id)); return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({ @@ -64,6 +65,7 @@ PageBase { })); } rootParent: root.flickable + settingAnchor: "panels-bar-status-icons-add-item" onAccepted: { if (!selectedItem) diff --git a/Modules/Settings/Pages/Panels/SidebarPanel.qml b/Modules/Settings/Pages/Panels/SidebarPanel.qml index e99a36e..cef7a3f 100644 --- a/Modules/Settings/Pages/Panels/SidebarPanel.qml +++ b/Modules/Settings/Pages/Panels/SidebarPanel.qml @@ -88,6 +88,10 @@ PageBase { enabled: Object.keys(model).length > 0 header: qsTr("Add new entry") icon: "add" + onOpenChanged: { + root.sState.dimmed = open; + } + settingAnchor: "panels-sidebar-quick-toggles-add-item" label: qsTr("Add entry") model: { const present = new Set(Config.utilities.quickToggles.values.map(item => item.id)); diff --git a/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml b/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml new file mode 100644 index 0000000..44a490e --- /dev/null +++ b/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml @@ -0,0 +1,327 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Paths +import qs.Services +import qs.Modules.Settings.Common + +PageBase { + id: root + + readonly property list fonts: Qt.fontFamilies() + readonly property var schemes: [ + { + id: "onedark", + label: qsTr("One Dark") + }, + { + id: "nord", + label: qsTr("Nord") + }, + { + id: "dracula", + label: qsTr("Dracula") + }, + { + id: "darkgreen", + label: qsTr("Darkgreen") + }, + { + id: "solarized", + label: qsTr("Solarized") + }, + { + id: "rosepine", + label: qsTr("Rosepine") + }, + { + id: "gruvbox", + label: qsTr("Gruvbox") + }, + { + id: "catppuccin", + label: qsTr("Catppuccin") + }, + { + id: "tokyonight", + label: qsTr("Tokyo Night") + }, + { + id: "shadotheme", + label: qsTr("Shadotheme") + } + ] + + isSubPage: true + title: qsTr("Colors & font") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + spacing: Tokens.spacing.extraSmall / 2 + width: root.cappedWidth + + SectionHeader { + first: true + text: qsTr("Colors") + } + + ToggleRow { + text: qsTr("Dynamic colors") + first: true + subtext: qsTr("Automatic color scheme based on wallpaper is %1").arg(Config.general.color.schemeGeneration ? "enabled" : "disabled") + checked: Config.general.color.schemeGeneration + settingAnchor: "colors-fonts-dynamic-colors" + + onToggled: { + Config.general.color.schemeGeneration = checked; + + if (checked) + Colors.setMode(Config.general.color.mode); + } + } + + DialogSelectButton { + settingAnchor: "colors-fonts-preset-scheme" + model: root.schemes + acceptLabel: qsTr("Confirm") + enabled: !Config.general.color.schemeGeneration + header: qsTr("Select scheme") + rowButton.activeItem: root.schemes.find(s => { + var regex = new RegExp(Config.colors.presets.name, "i"); + return regex.test(s.id); + }).label ?? "" + label: qsTr("Preset scheme") + subtext: qsTr("Select preset color scheme") + rootParent: root.flickable + + FileView { + id: view + path: `${Paths.state}/presets.json` + + watchChanges: true + onFileChanged: reload() + + JsonAdapter { + id: adapter + + property var presets + } + } + + initialSelect: Config.colors.presets.name.toLowerCase() + + onAccepted: { + if (!selectedItem) + return; + + const s = selectedItem.toLowerCase(); + let v = ""; + let a = ""; + const existingVariant = Config.colors.presets.variant; + const existingAccent = Config.colors.presets.accent; + const variantKeys = Object.keys(adapter.presets[s].variants); + + if (existingVariant !== "" && variantKeys.some(v => v === existingVariant)) + v = existingVariant; + + if (v !== "" && existingAccent !== "" && adapter.presets[s].variants[v]?.accents.some(a => a === existingAccent)) + a = existingAccent; + + if (a !== "") + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]); + else if (v !== "") + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`]); + else + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${variantKeys[0]}`]); + } + } + + DialogSelectButton { + settingAnchor: "colors-fonts-preset-variant" + // qmlformat off + model: { + const v = adapter.presets[Config.colors.presets.name].variants; + const map = Object.keys(v).map(v => ({ + id: v, + label: qsTr("%1%2") + .arg(v.charAt(0).toUpperCase()) + .arg(v.slice(1).toLowerCase()) + })); + + return map; + } + // qmlformat on + acceptLabel: qsTr("Confirm") + enabled: !Config.general.color.schemeGeneration && model.length > 1 + header: qsTr("Select variant") + rowButton.activeItem: { + const v = Config.colors.presets.variant ?? ""; + if (v === "") + return ""; + return qsTr("%1%2").arg(v.charAt(0).toUpperCase()).arg(v.slice(1).toLowerCase()); + } + label: qsTr("Variant") + subtext: qsTr("Select preset variant") + rootParent: root.flickable + + initialSelect: Config.colors.presets.variant + + onAccepted: { + if (!selectedItem) + return; + + const v = selectedItem.toLowerCase(); + const s = Config.colors.presets.name; + const existingAccent = Config.colors.presets.accent; + let a = ""; + + if (existingAccent !== "" && adapter.presets[s].variants[v]?.accents.some(a => a === existingAccent)) + a = existingAccent; + + if (a !== "") + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]); + else + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`]); + } + } + + DialogSelectButton { + settingAnchor: "colors-fonts-preset-accent" + // qmlformat off + model: { + const a = adapter.presets[Config.colors.presets.name].variants[Config.colors.presets.variant]?.accents ?? []; + + if (a.length === 0) + return a; + + const map = a.map(a => ({ + id: a, + label: qsTr("%1%2") + .arg(a.charAt(0).toUpperCase()) + .arg(a.slice(1).toLowerCase()) + })); + + return map; + } + // qmlformat on + acceptLabel: qsTr("Confirm") + enabled: !Config.general.color.schemeGeneration && model.length > 1 + header: qsTr("Select accent") + rowButton.activeItem: { + const a = Config.colors.presets.accent ?? ""; + if (a === "") + return ""; + return qsTr("%1%2").arg(a.charAt(0).toUpperCase()).arg(a.slice(1).toLowerCase()); + } + label: qsTr("Accent") + last: true + subtext: qsTr("Select preset accent") + rootParent: root.flickable + + initialSelect: Config.colors.presets.accent + + onAccepted: { + if (!selectedItem) + return; + + const a = selectedItem.toLowerCase(); + const v = Config.colors.presets.variant; + const s = Config.colors.presets.name; + + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]); + } + } + + SectionHeader { + text: qsTr("Fonts") + } + + SearchPopup { + id: selectSansFont + + settingAnchor: "colors-fonts-sans-font" + model: root.fonts + acceptLabel: qsTr("Confirm") + enabled: Object.keys(model).length > 0 + separateContent: false + header: qsTr("Select font") + first: true + rowButton.activeItem: root.fonts.find(s => { + var regex = new RegExp(Config.appearance.font.family.sans, "i"); + return regex.test(s); + }) ?? "" + label: qsTr("Sans font") + subtext: qsTr("Select desired sans font") + rootParent: root.flickable + + initialSelect: Config.appearance.font.family.sans + + onAccepted: { + if (!selectedItem) + return; + + Config.appearance.font.family.sans = selectedItem; + } + } + + SearchPopup { + id: selectMonoFont + + acceptLabel: qsTr("Confirm") + enabled: Object.keys(model).length > 0 + settingAnchor: "colors-fonts-mono-font" + separateContent: false + header: qsTr("Select font") + rowButton.activeItem: root.fonts.find(s => { + var regex = new RegExp(Config.appearance.font.family.mono, "i"); + return regex.test(s); + }) ?? "" + label: qsTr("Mono font") + subtext: qsTr("Select desired mono font") + model: root.fonts + rootParent: root.flickable + + initialSelect: Config.appearance.font.family.mono + + onAccepted: { + if (!selectedItem) + return; + + Config.appearance.font.family.mono = selectedItem; + } + } + + SearchPopup { + id: selectClockFont + + acceptLabel: qsTr("Confirm") + enabled: Object.keys(model).length > 0 + separateContent: false + settingAnchor: "colors-fonts-clock-font" + header: qsTr("Select font") + last: true + rowButton.activeItem: root.fonts.find(s => { + var regex = new RegExp(Config.appearance.font.family.clock, "i"); + return regex.test(s); + }) ?? "" + label: qsTr("Clock font") + subtext: qsTr("Select desired clock font") + model: root.fonts + rootParent: root.flickable + + initialSelect: Config.appearance.font.family.clock + + onAccepted: { + if (!selectedItem) + return; + + Config.appearance.font.family.clock = selectedItem; + } + } + } +} diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml index 40274ad..5e7035c 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -33,18 +33,35 @@ PageBase { } } - IconTextButton { + ButtonRow { Layout.alignment: Qt.AlignHCenter - enabled: Config.background.enabled - horizontalPadding: Tokens.padding.extraLarge - icon: "wallpaper" - isRound: true - shapeMorph: true - text: qsTr("Wallpapers") - type: IconTextButton.Tonal - verticalPadding: Tokens.padding.small + spacing: Tokens.spacing.small - onClicked: root.sState.openSubPage(1) // Wallpaper page + IconTextButton { + enabled: Config.background.enabled + horizontalPadding: Tokens.padding.extraLarge + icon: "wallpaper" + isRound: true + shapeMorph: true + text: qsTr("Wallpapers") + type: IconTextButton.Tonal + verticalPadding: Tokens.padding.small + + onClicked: root.sState.openSubPage(1) // Wallpaper page + } + + IconTextButton { + enabled: Config.background.enabled + horizontalPadding: Tokens.padding.extraLarge + icon: "palette" + isRound: true + shapeMorph: true + text: qsTr("Colors & font") + type: IconTextButton.Tonal + verticalPadding: Tokens.padding.small + + onClicked: root.sState.openSubPage(2) // Colors & font page + } } ToggleRow { @@ -76,9 +93,7 @@ PageBase { onToggled: Colors.setMode(checked ? "dark" : "light") } - OverlayRow { - id: darkMode - + TimeDialogSelect { readonly property string endTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); d.setMinutes(Config.general.color.scheduleDarkEnd); @@ -90,35 +105,30 @@ PageBase { return Qt.formatTime(d, "hh:mm AP"); } - checked: Config.general.color.scheduleDark + rowButton.checked: Config.general.color.scheduleDark + rowButton.onToggled: Config.general.color.scheduleDark = rowButton.checked + start: Config.general.color.scheduleDarkStart + end: Config.general.color.scheduleDarkEnd first: true settingAnchor: "style-schedule-dark-mode" + acceptLabel: qsTr("Apply") + rootParent: root.flickable + header: qsTr("Select time") subtext: qsTr("Dark mode will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) - text: qsTr("Schedule dark mode") + label: qsTr("Schedule dark mode") - popup: Component { - TimeInput { - object: Config.general.color - settings: ["scheduleDark", "scheduleDarkStart", "scheduleDarkEnd"] - - onApplySettings: (start, end) => { - Config.general.color.scheduleDarkStart = start; - Config.general.color.scheduleDarkEnd = end; - ModeScheduler.checkStartup(); - PopupManager.requestClose(); - } - onClose: PopupManager.requestClose() - } + onOpenChanged: { + root.sState.dimmed = open; } - onClicked: value => { - Config.general.color.scheduleDark = value; + onAccepted: { + Config.general.color.scheduleDarkStart = startCurrent; + Config.general.color.scheduleDarkEnd = endCurrent; + ModeScheduler.checkStartup(); } } - OverlayRow { - id: hyprsunset - + TimeDialogSelect { readonly property string endTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); d.setMinutes(Config.general.color.scheduleHyprsunsetEnd); @@ -131,29 +141,26 @@ PageBase { } Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing - checked: Config.general.color.scheduleHyprsunset + rowButton.checked: Config.general.color.scheduleHyprsunset + rowButton.onToggled: Config.general.color.scheduleHyprsunset = rowButton.checked + start: Config.general.color.scheduleHyprsunsetStart + end: Config.general.color.scheduleHyprsunsetEnd last: true settingAnchor: "style-schedule-hyprsunset" + acceptLabel: qsTr("Apply") + rootParent: root.flickable + header: qsTr("Select time") subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) - text: qsTr("Schedule hyprsunset") + label: qsTr("Schedule hyprsunset") - popup: Component { - TimeInput { - object: Config.general.color - settings: ["scheduleHyprsunset", "scheduleHyprsunsetStart", "scheduleHyprsunsetEnd"] - - onApplySettings: (start, end) => { - Config.general.color.scheduleHyprsunsetStart = start; - Config.general.color.scheduleHyprsunsetEnd = end; - Hyprsunset.checkStartup(); - PopupManager.requestClose(); - } - onClose: PopupManager.requestClose() - } + onOpenChanged: { + root.sState.dimmed = open; } - onClicked: value => { - Config.general.color.scheduleHyprsunset = value; + onAccepted: { + Config.general.color.scheduleHyprsunsetStart = startCurrent; + Config.general.color.scheduleHyprsunsetEnd = endCurrent; + Hyprsunset.checkStartup(); } } } diff --git a/Modules/Settings/PopupManager.qml b/Modules/Settings/PopupManager.qml deleted file mode 100644 index 4b37c02..0000000 --- a/Modules/Settings/PopupManager.qml +++ /dev/null @@ -1,20 +0,0 @@ -pragma Singleton - -import Quickshell -import QtQuick - -Singleton { - id: root - - property bool closed: true - property Component currentPopup: null - - function requestClose(): void { - closed = true; - } - - function requestOpen(component: Component): void { - currentPopup = component; - closed = false; - } -} diff --git a/Modules/Settings/PopupOverlay.qml b/Modules/Settings/PopupOverlay.qml deleted file mode 100644 index d18440e..0000000 --- a/Modules/Settings/PopupOverlay.qml +++ /dev/null @@ -1,47 +0,0 @@ -import QtQuick -import ZShell.Config -import qs.Components -import qs.Services - -CustomRect { - id: root - - readonly property bool closing: PopupManager.closed - readonly property var currentPopup: PopupManager.currentPopup - property bool shouldBeVisible: loader.status === Loader.Ready - - color: Qt.alpha(Colors.palette.m3shadow, 0.3) - opacity: shouldBeVisible && !closing ? 1 : 0 - visible: opacity > 0 - - Behavior on opacity { - Anim { - } - } - - onVisibleChanged: if (!visible) - PopupManager.currentPopup = null - - CustomMouseArea { - anchors.fill: parent - hoverEnabled: true - preventStealing: true - propagateComposedEvents: false - - onClicked: { - const insideItemWidth = mouseX < loader.x + loader.item.width && mouseX > loader.x; - const insideItemHeight = mouseY < loader.y + loader.item.height && mouseY > loader.y; - if (insideItemHeight && insideItemWidth) - return; - - PopupManager.requestClose(); - } - } - - Loader { - id: loader - - anchors.centerIn: parent - sourceComponent: root.currentPopup - } -} diff --git a/Modules/Settings/SettingsState.qml b/Modules/Settings/SettingsState.qml index 79e80d8..11933e1 100644 --- a/Modules/Settings/SettingsState.qml +++ b/Modules/Settings/SettingsState.qml @@ -16,6 +16,7 @@ QtObject { property string searchText property DesktopEntry selectedApp property BluetoothDevice selectedBtDevice + property bool dimmed property string selectedWallpaperCategory property list subPageIdxStack diff --git a/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index ed60e8b..2a65612 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -94,9 +94,8 @@ def list_presets( if v.accents: entry["accents"] = sorted(v.accents) entry["default_accent"] = sorted(v.accents)[0] - variants[v.id] = entry - out[meta.name] = { - "id": sid, + variants[v.id.lower()] = entry + out[sid.lower()] = { "variants": variants, } print(json.dumps({"presets": out}, indent=2)) @@ -670,7 +669,7 @@ def generate( name = palette_obj.scheme flavor = palette_obj.variant - display_name = schemes[p_scheme].name + display_name = schemes[p_scheme].id config["colors"]["presets"] = { "name": display_name, "variant": p_variant, From 5330ae71d22169751c29bc13421aa7b39aed8f4d Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 31 Aug 2026 13:53:37 +0200 Subject: [PATCH 11/90] add nightlight hyprland plugin + rename hyprsunsetmanager -> nightlightmanager to control both --- Helpers/Hyprsunset.qml | 4 +- Plugins/ZShell/CMakeLists.txt | 8 + Plugins/ZShell/Config/general.hpp | 2 + Plugins/ZShell/HyprPlugins/CMakeLists.txt | 12 + Plugins/ZShell/HyprPlugins/colortemp.hpp | 37 ++ Plugins/ZShell/HyprPlugins/main.cpp | 457 ++++++++++++++++++ Plugins/ZShell/Services/CMakeLists.txt | 4 +- Plugins/ZShell/Services/hyprsunsetmanager.cpp | 148 ------ Plugins/ZShell/Services/hyprsunsetmanager.hpp | 71 --- Plugins/ZShell/Services/nightlightmanager.cpp | 444 +++++++++++++++++ Plugins/ZShell/Services/nightlightmanager.hpp | 122 +++++ 11 files changed, 1087 insertions(+), 222 deletions(-) create mode 100644 Plugins/ZShell/HyprPlugins/CMakeLists.txt create mode 100644 Plugins/ZShell/HyprPlugins/colortemp.hpp create mode 100644 Plugins/ZShell/HyprPlugins/main.cpp delete mode 100644 Plugins/ZShell/Services/hyprsunsetmanager.cpp delete mode 100644 Plugins/ZShell/Services/hyprsunsetmanager.hpp create mode 100644 Plugins/ZShell/Services/nightlightmanager.cpp create mode 100644 Plugins/ZShell/Services/nightlightmanager.hpp diff --git a/Helpers/Hyprsunset.qml b/Helpers/Hyprsunset.qml index ca82be2..540091d 100644 --- a/Helpers/Hyprsunset.qml +++ b/Helpers/Hyprsunset.qml @@ -26,12 +26,14 @@ Singleton { service.toggle(); } - HyprsunsetManager { + NightlightManager { id: service activeAuto: Config.general.color.scheduleHyprsunset endTime: root.end startTime: root.start temp: root.temp + fadeDuration: Config.general.color.nativeFadeDuration / 1000 + useNativeNightlight: Config.general.color.useNativeNightlight } } diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index d260f80..85f7df0 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -79,3 +79,11 @@ add_subdirectory(Services) add_subdirectory(Components) add_subdirectory(Blobs) add_subdirectory(Config) + +pkg_check_modules(HYPRLAND_PROBE hyprland) +if(HYPRLAND_PROBE_FOUND) + add_subdirectory(HyprPlugins) +else() + message(STATUS + "hyprland development headers not found; skipping zshell-nightlight plugin") +endif() diff --git a/Plugins/ZShell/Config/general.hpp b/Plugins/ZShell/Config/general.hpp index 9d0f8fd..9f6a70f 100644 --- a/Plugins/ZShell/Config/general.hpp +++ b/Plugins/ZShell/Config/general.hpp @@ -98,6 +98,8 @@ class ColorSettings : public ConfigObject { CFG_PROPERTY(int, hyprsunsetTemp, 2600) CFG_PROPERTY(QString, mode, QStringLiteral("dark")) CFG_PROPERTY(bool, neovimColors, false) + CFG_PROPERTY(bool, useNativeNightlight, true) + CFG_PROPERTY(int, nativeFadeDuration, 2000) CFG_PROPERTY(bool, scheduleDark, false) CFG_PROPERTY(int, scheduleDarkEnd, 600) CFG_PROPERTY(int, scheduleDarkStart, 1140) diff --git a/Plugins/ZShell/HyprPlugins/CMakeLists.txt b/Plugins/ZShell/HyprPlugins/CMakeLists.txt new file mode 100644 index 0000000..0316838 --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/CMakeLists.txt @@ -0,0 +1,12 @@ +pkg_check_modules(HYPRLAND REQUIRED hyprland) + +add_library(zshell-nightlight SHARED main.cpp) + +target_compile_features(zshell-nightlight PRIVATE cxx_std_23) +target_include_directories(zshell-nightlight PRIVATE ${HYPRLAND_INCLUDE_DIRS}) +target_link_directories(zshell-nightlight PRIVATE ${HYPRLAND_LIBRARY_DIRS}) +target_compile_options(zshell-nightlight PRIVATE ${HYPRLAND_CFLAGS_OTHER}) + +target_compile_options(zshell-nightlight PRIVATE -fno-gnu-unique) + +install(TARGETS zshell-nightlight LIBRARY DESTINATION "${INSTALL_LIBDIR}/plugins") diff --git a/Plugins/ZShell/HyprPlugins/colortemp.hpp b/Plugins/ZShell/HyprPlugins/colortemp.hpp new file mode 100644 index 0000000..25f9c05 --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/colortemp.hpp @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include + +namespace ZShell::services { + +inline std::array kelvinToGain(int kelvin) { + const double t = std::clamp(kelvin, 1000, 40000) / 100.0; + double r, g, b; + + r = t <= 66.0 + ? 1.0 + : std::clamp( + 1.29293618606 * std::pow(t - 60.0, -0.1332047592), 0.0, 1.0); + g = t <= 66.0 + ? std::clamp(0.39008157876 * std::log(t) - 0.63184144378, 0.0, 1.0) + : std::clamp( + 1.12989086089 * std::pow(t - 60.0, -0.0755148492), 0.0, 1.0); + b = t >= 66.0 + ? 1.0 + : (t <= 19.0 + ? 0.0 + : std::clamp( + 0.54320678911 * std::log(t - 10.0) - 1.19625408914, + 0.0, + 1.0)); + + return {static_cast(r), static_cast(g), static_cast(b)}; +} + +inline float easeInOutCubic(float x) { + return x < 0.5f ? 4.f * x * x * x + : 1.f - std::pow(-2.f * x + 2.f, 3.f) / 2.f; +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/HyprPlugins/main.cpp b/Plugins/ZShell/HyprPlugins/main.cpp new file mode 100644 index 0000000..ad2080e --- /dev/null +++ b/Plugins/ZShell/HyprPlugins/main.cpp @@ -0,0 +1,457 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define private public +#include +#include +#include +#undef private + +#include + +extern "C" { +#include +#include +} + +#include "colortemp.hpp" + +using ZShell::services::easeInOutCubic; +using ZShell::services::kelvinToGain; + +using Render::IFramebuffer; +using Render::GL::CHyprOpenGLImpl; +using Render::GL::g_pHyprOpenGL; + +inline HANDLE PHANDLE = nullptr; + +static std::filesystem::path g_runtimeDir{"/tmp"}; +static std::filesystem::path g_cacheBase{"/tmp/.cache"}; + +static void initEnvPaths() { + if (const char* env = getenv("XDG_RUNTIME_DIR")) + g_runtimeDir = env; + + if (const char* env = getenv("XDG_CACHE_HOME")) { + if (*env) + g_cacheBase = env; + } else if (const char* home = getenv("HOME")) { + g_cacheBase = std::filesystem::path(home) / ".cache"; + } +} + +static std::ofstream& debugLog() { + static std::ofstream log( + g_runtimeDir / "zshell-nightlight-debug.log", + std::ios::app); + return log; +} + +static std::mutex g_logMutex; +static std::atomic g_logSeq{0}; + +static void dlog(const std::string& msg) { + std::lock_guard lk(g_logMutex); + auto& log = debugLog(); + log << "[" << g_logSeq++ << "][tid=" << std::this_thread::get_id() << "] " + << msg << std::endl; +} + +static constexpr int BAKED_STEPS = 24; + +static std::string glslFloat(float v) { + std::ostringstream ss; + ss << std::fixed << std::setprecision(6) << v; + return ss.str(); +} + +static std::filesystem::path cacheDir() { + return g_cacheBase / "zshell" / "night-light"; +} + +static std::filesystem::path shaderPath() { + return cacheDir() / "nightlight.frag"; +} + +static std::filesystem::path bakedShaderPath(const std::array& gain) { + return cacheDir() / + ("nightlight-" + glslFloat(gain[0]) + "-" + glslFloat(gain[1]) + + "-" + glslFloat(gain[2]) + ".frag"); +} + +static std::string defaultShaderSource() { + return "#version 100\n" + "precision highp float;\n" + "varying vec2 v_texcoord;\n" + "uniform sampler2D tex;\n" + "uniform vec3 tint;\n" + "void main() {\n" + " vec4 pixColor = texture2D(tex, v_texcoord);\n" + " gl_FragColor = vec4(pixColor.rgb * tint, pixColor.a);\n" + "}\n"; +} + +static std::string bakedShaderSource(const std::array& gain) { + std::ostringstream src; + src << "#version 100\n" + "precision highp float;\n" + "varying vec2 v_texcoord;\n" + "uniform sampler2D tex;\n" + "const vec3 GAIN = vec3(" + << glslFloat(gain[0]) << "," << glslFloat(gain[1]) << "," + << glslFloat(gain[2]) + << ");\n" + "void main() {\n" + " vec4 pixColor = texture2D(tex, v_texcoord);\n" + " gl_FragColor = vec4(pixColor.rgb * GAIN, pixColor.a);\n" + "}\n"; + return src.str(); +} + +static void ensureShaderFile( + const std::filesystem::path& path, const std::string& source) { + std::error_code ec; + if (std::filesystem::is_regular_file(path, ec)) return; + std::filesystem::create_directories(path.parent_path(), ec); + std::ofstream out(path, std::ios::trunc); + out << source; +} + +static bool g_active = false; +static bool g_uniformMode = false; +static bool g_modeDecided = false; +static std::array g_currentGain{1.f, 1.f, 1.f}; + +static int g_currentKelvin = 6500; +static int g_lastKelvin = 6500; + +struct STransition { + bool active = false; + std::chrono::steady_clock::time_point start; + float durationSec = 0.4f; + std::array fromGain{1.f, 1.f, 1.f}; + std::array toGain{1.f, 1.f, 1.f}; + int lastBakedStep = -1; +}; +static STransition g_transition; + +static void postStateEvent(bool enabled, bool animating); + +static bool gainIsNeutral(const std::array& gain) { + for (float v : gain) + if (std::abs(v - 1.f) > 0.001f) return false; + return true; +} + +static CFunctionHook* g_pBeginHook = nullptr; +using origBegin = void (*)( + CHyprOpenGLImpl*, + PHLMONITOR, + const CRegion&, + SP, + std::optional); + +static CFunctionHook* g_pSaveMirrorHook = nullptr; +using origSaveMirror = bool (*)(CHyprOpenGLImpl*, const CBox&); + +static bool hkSaveBufferForMirror(CHyprOpenGLImpl* thisptr, const CBox& box) { + const bool saved = thisptr->m_applyFinalShader; + thisptr->m_applyFinalShader = false; + const bool result = (*reinterpret_cast( + g_pSaveMirrorHook->m_original))(thisptr, box); + thisptr->m_applyFinalShader = saved; + return result; +} + +static void installShader() { + auto* gl = g_pHyprOpenGL.get(); + if (!gl) + return; + + ensureShaderFile(shaderPath(), defaultShaderSource()); + gl->applyScreenShader(shaderPath().string()); + + auto* shader = gl->m_finalScreenShader.get(); + if (shader && shader->program() >= 1 && + shader->getUniformLocation(SHADER_TINT) != -1) { + g_uniformMode = true; + } else { + g_uniformMode = false; + if (!g_modeDecided) + dlog( + "shader has no 'tint' uniform, falling back to " + "per-step recompiled shaders"); + } + g_modeDecided = true; +} + +static void setUniformGain(const std::array& gain) { + auto* gl = g_pHyprOpenGL.get(); + auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr; + if (!gl || !shader) + return; + + gl->useShader(gl->m_finalScreenShader); + shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]); +} + +static void hkBegin( + CHyprOpenGLImpl* thisptr, + PHLMONITOR mon, + const CRegion& damage, + SP fb, + std::optional finalDamage) { + (*reinterpret_cast( + g_pBeginHook->m_original))(thisptr, mon, damage, fb, finalDamage); + + if (fb) { + auto* renderer = g_pHyprRenderer.get(); + if (renderer) + renderer->m_renderData.blockScreenShader = true; + } + + if (!g_active) return; + + auto* gl = g_pHyprOpenGL.get(); + if (!gl) return; + + if (gl->m_finalScreenShader.get() && + gl->m_finalScreenShader->program() < 1) + installShader(); + if (!gl->m_finalScreenShader.get() || + gl->m_finalScreenShader->program() < 1) + return; + + if (g_transition.active) { + auto* monPtr = mon.get(); + if (!monPtr) + return; + const float elapsed = + std::chrono::duration( + std::chrono::steady_clock::now() - g_transition.start) + .count(); + const float t = + std::clamp(elapsed / g_transition.durationSec, 0.f, 1.f); + const float e = easeInOutCubic(t); + + for (size_t i = 0; i < 3; ++i) + g_currentGain[i] = + g_transition.fromGain[i] + + (g_transition.toGain[i] - g_transition.fromGain[i]) * e; + + if (g_uniformMode) { + setUniformGain(g_currentGain); + } else { + const int step = static_cast(e * BAKED_STEPS); + if (step != g_transition.lastBakedStep) { + g_transition.lastBakedStep = step; + ensureShaderFile( + bakedShaderPath(g_currentGain), + bakedShaderSource(g_currentGain)); + gl->applyScreenShader(bakedShaderPath(g_currentGain).string()); + } + } + + if (t >= 1.f) { + g_transition.active = false; + if (gainIsNeutral(g_currentGain)) { + g_active = false; + gl->applyScreenShader(""); + } + postStateEvent(g_active, false); + } else { + monPtr->m_forceFullFrames = 2; + monPtr->scheduleFrame(); + } + } else if (g_uniformMode) { + setUniformGain(g_currentGain); + } +} + +static constexpr int KELVIN_MIN = 2000; +static constexpr int KELVIN_MAX = 6500; + +static int luaNightlightSet(lua_State* L) { + const int rawKelvin = static_cast(luaL_checknumber(L, 1)); + const float durationSec = + lua_gettop(L) >= 2 ? static_cast(luaL_checknumber(L, 2)) : 0.4f; + + const int targetKelvin = std::clamp(rawKelvin, KELVIN_MIN, KELVIN_MAX); + if (targetKelvin != rawKelvin) + dlog( + "clamped " + std::to_string(rawKelvin) + "K to " + + std::to_string(targetKelvin) + "K"); + + g_transition.fromGain = g_currentGain; + g_transition.toGain = kelvinToGain(targetKelvin); + g_transition.active = true; + g_transition.start = std::chrono::steady_clock::now(); + g_transition.durationSec = durationSec; + g_transition.lastBakedStep = -1; + g_active = true; + + g_lastKelvin = g_currentKelvin; + g_currentKelvin = targetKelvin; + + postStateEvent(true, true); + + lua_pushinteger(L, targetKelvin); + return 1; +} + +static int luaNightlightDisable(lua_State* L) { + if (!g_active) return 0; + + const float durationSec = + lua_gettop(L) >= 1 ? static_cast(luaL_checknumber(L, 1)) : 0.4f; + + g_transition.fromGain = g_currentGain; + g_transition.toGain = {1.f, 1.f, 1.f}; + g_transition.active = true; + g_transition.start = std::chrono::steady_clock::now(); + g_transition.durationSec = durationSec; + g_transition.lastBakedStep = -1; + + g_lastKelvin = g_currentKelvin; + g_currentKelvin = 6500; + + postStateEvent(false, true); + + return 0; +} + +static int luaNightlightState(lua_State* L) { + lua_pushstring( + L, + std::format( + "{{\"enabled\":{},\"animating\":{},\"last\":{},\"current\":{}}}", + g_active ? "true" : "false", + g_transition.active ? "true" : "false", + g_lastKelvin, + g_currentKelvin) + .c_str()); + return 1; +} + +static void postStateEvent(bool enabled, bool animating) { + if (!g_pEventManager) return; + g_pEventManager->postEvent( + SHyprIPCEvent{ + .event = "zshell-nightlight", + .data = std::format( + "{},{},{},{}", + enabled ? 1 : 0, + animating ? 1 : 0, + g_lastKelvin, + g_currentKelvin)}); +} + +static const SFunctionMatch* findExactMethod( + const std::vector& methods, + const std::string& classAndMethod) { + for (const auto& m : methods) { + if (m.signature.find(classAndMethod) != std::string::npos) return &m; + } + return nullptr; +} + +static std::string manglePrefix( + const std::string& className, const std::string& methodName) { + return std::to_string(className.size()) + className + + std::to_string(methodName.size()) + methodName; +} + +APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) { + PHANDLE = handle; + initEnvPaths(); + dlog("=== PLUGIN_INIT, build timestamp " __DATE__ " " __TIME__ " ==="); + + const std::string serverHash = __hyprland_api_get_hash(); + const std::string clientHash = __hyprland_api_get_client_hash(); + + if (serverHash != clientHash) { + HyprlandAPI::addNotification( + PHANDLE, + "[zshell-nightlight] Mismatched headers! Can't proceed.", + CHyprColor{1.0f, 0.2f, 0.2f, 1.0f}, + 5000); + throw std::runtime_error("[zshell-nightlight] version mismatch"); + } + + static const auto SAVEMIRROR_METHODS = + HyprlandAPI::findFunctionsByName(PHANDLE, "saveBufferForMirror"); + const SFunctionMatch* saveMirrorTarget = findExactMethod( + SAVEMIRROR_METHODS, + manglePrefix("CHyprOpenGLImpl", "saveBufferForMirror")); + + if (saveMirrorTarget) { + g_pSaveMirrorHook = HyprlandAPI::createFunctionHook( + PHANDLE, + saveMirrorTarget->address, + reinterpret_cast(&hkSaveBufferForMirror)); + if (g_pSaveMirrorHook && g_pSaveMirrorHook->hook()) + dlog( + "saveBufferForMirror hook installed: " + + saveMirrorTarget->signature); + } + + if (!g_pSaveMirrorHook) + HyprlandAPI::addNotification( + PHANDLE, + "[zshell-nightlight] WARNING: couldn't hook saveBufferForMirror — " + "captures may still show the nightlight", + CHyprColor{1.f, 0.6f, 0.2f, 1.f}, + 8000); + + static const auto METHODS = + HyprlandAPI::findFunctionsByName(PHANDLE, "begin"); + const SFunctionMatch* target = + findExactMethod(METHODS, manglePrefix("CHyprOpenGLImpl", "begin")); + if (!target) + throw std::runtime_error( + "[zshell-nightlight] couldn't uniquely resolve " + "CHyprOpenGLImpl::begin"); + + g_pBeginHook = HyprlandAPI::createFunctionHook( + PHANDLE, target->address, reinterpret_cast(&hkBegin)); + if (!g_pBeginHook || !g_pBeginHook->hook()) + throw std::runtime_error("[zshell-nightlight] failed to hook begin()"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlSet", luaNightlightSet)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlDisable", luaNightlightDisable)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + if (!HyprlandAPI::addLuaFunction( + PHANDLE, "zshell", "nlState", luaNightlightState)) + throw std::runtime_error( + "[zshell-nightlight] failed to register Lua function"); + + return { + "zshell-nightlight", + "Animated nightlight, excluded from screencopy", + "ZShell", + "1.2"}; +} + + +APICALL EXPORT std::string PLUGIN_API_VERSION() { + return HYPRLAND_API_VERSION; +} + + +APICALL EXPORT void PLUGIN_EXIT() {} diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index 7d1d2ff..2dfe707 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -9,7 +9,7 @@ qml_module(ZShell-services cavaprovider.hpp cavaprovider.cpp desktopmodel.hpp desktopmodel.cpp desktopstatemanager.hpp desktopstatemanager.cpp - hyprsunsetmanager.hpp hyprsunsetmanager.cpp + nightlightmanager.hpp nightlightmanager.cpp tickingservice.hpp tickingservice.cpp sensorslib.hpp sensorslib.cpp usagefmt.hpp usagefmt.cpp @@ -25,4 +25,4 @@ qml_module(ZShell-services PkgConfig::Aubio PkgConfig::Cava Sensors::Sensors -) + ) diff --git a/Plugins/ZShell/Services/hyprsunsetmanager.cpp b/Plugins/ZShell/Services/hyprsunsetmanager.cpp deleted file mode 100644 index 0b33f6f..0000000 --- a/Plugins/ZShell/Services/hyprsunsetmanager.cpp +++ /dev/null @@ -1,148 +0,0 @@ -#include "hyprsunsetmanager.hpp" -#include -#include -#include -#include - -namespace ZShell::services { - -HyprsunsetManager::HyprsunsetManager(QObject* parent) : QObject(parent) { - connect(&m_timer, &QTimer::timeout, this, &HyprsunsetManager::apply); - connect(&m_manualTimer, &QTimer::timeout, this, [this] { - m_manualToggle = false; - emit manualToggleChanged(); - apply(); - }); - connect(&m_startCooldown, &QTimer::timeout, this, [this] { - m_startAllowed = true; - apply(); - }); - - m_startCooldown.start(2000); - m_manualTimer.setSingleShot(true); - m_timer.start(60000); - - m_process.setStandardInputFile(QProcess::nullDevice()); - m_process.setStandardOutputFile(QProcess::nullDevice()); -} - -int HyprsunsetManager::startTime() const { - return m_startTime; -} - -int HyprsunsetManager::endTime() const { - return m_endTime; -} - -bool HyprsunsetManager::manualToggle() const { - return m_manualToggle; -} - -bool HyprsunsetManager::enabled() const { - return m_enabled; -} - -bool HyprsunsetManager::activeAuto() const { - return m_activeAuto; -} - -int HyprsunsetManager::temp() const { - return m_temp; -} - -void HyprsunsetManager::setActiveAuto(bool activeAuto) { - if (activeAuto == m_activeAuto) return; - - m_activeAuto = activeAuto; - emit activeAutoChanged(); -} - -void HyprsunsetManager::setManualToggle(bool toggle) { - if (toggle == m_manualToggle) return; - - m_manualToggle = toggle; - emit manualToggleChanged(); - - m_manualTimer.start(60 * 60 * 1000); -} - -void HyprsunsetManager::setEndTime(const int& time) { - if (time == m_endTime) return; - - m_endTime = time; - emit endTimeChanged(); - apply(); -} - -void HyprsunsetManager::setStartTime(const int& time) { - if (time == m_startTime) return; - - m_startTime = time; - emit startTimeChanged(); - apply(); -} - -void HyprsunsetManager::setTemp(const int& temp) { - if (temp == m_temp) return; - - m_temp = temp; - emit tempChanged(); - apply(); -} - -void HyprsunsetManager::toggle() { - if (m_enabled) { - end(); - } else { - start(); - } -} - -void HyprsunsetManager::start() { - if (m_enabled && m_initialized) return; - - m_initialized = true; - m_enabled = true; - - emit enabledChanged(); - - m_process.setProgram("hyprctl"); - m_process.setArguments( - {"hyprsunset", "temperature", QString::number(m_temp)}); - m_process.startDetached(); -} - -void HyprsunsetManager::end() { - if (!m_enabled && m_initialized) return; - - m_initialized = true; - m_enabled = false; - - emit enabledChanged(); - - m_process.setProgram("hyprctl"); - m_process.setArguments({"hyprsunset", "identity"}); - m_process.startDetached(); -} - -void HyprsunsetManager::apply() { - if (m_manualToggle || !m_activeAuto || !m_startAllowed) return; - - const auto current = QTime::currentTime(); - const auto currentMin = current.hour() * 60 + current.minute(); - bool isDarkTime = false; - - if (m_startTime <= m_endTime) { - isDarkTime = (currentMin >= m_startTime && currentMin < m_endTime); - } else { - isDarkTime = (currentMin >= m_startTime || currentMin < m_endTime); - } - - if (isDarkTime) { - start(); - } else { - end(); - } -} - -}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/hyprsunsetmanager.hpp b/Plugins/ZShell/Services/hyprsunsetmanager.hpp deleted file mode 100644 index 7241c0d..0000000 --- a/Plugins/ZShell/Services/hyprsunsetmanager.hpp +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -namespace ZShell::services { - -class HyprsunsetManager : public QObject { - Q_OBJECT - QML_ELEMENT - Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged) - Q_PROPERTY( - int startTime READ startTime WRITE setStartTime NOTIFY startTimeChanged) - Q_PROPERTY(int endTime READ endTime WRITE setEndTime NOTIFY endTimeChanged) - Q_PROPERTY(int temp READ temp WRITE setTemp NOTIFY tempChanged) - Q_PROPERTY( - bool activeAuto READ activeAuto WRITE setActiveAuto NOTIFY - activeAutoChanged) - Q_PROPERTY( - bool manualToggle READ manualToggle WRITE setManualToggle NOTIFY - manualToggleChanged) - - public: - explicit HyprsunsetManager(QObject* parent = nullptr); - - [[nodiscard]] int startTime() const; - [[nodiscard]] int endTime() const; - [[nodiscard]] bool enabled() const; - [[nodiscard]] int temp() const; - [[nodiscard]] bool activeAuto() const; - [[nodiscard]] bool manualToggle() const; - - Q_INVOKABLE void toggle(); - Q_INVOKABLE void apply(); - - void setStartTime(const int& time); - void setEndTime(const int& time); - void setTemp(const int& temp); - void setActiveAuto(bool activeAuto); - void setManualToggle(bool toggle); - - signals: - void enabledChanged(); - void startTimeChanged(); - void activeAutoChanged(); - void endTimeChanged(); - void tempChanged(); - void manualToggleChanged(); - - private: - int m_startTime; - int m_endTime; - bool m_enabled = false; - bool m_manualToggle = false; - bool m_activeAuto; - bool m_startAllowed = false; - bool m_initialized = false; - QTimer m_startCooldown; - int m_temp; - QProcess m_process; - QTimer m_timer; - QTimer m_manualTimer; - void start(); - void end(); -}; - -}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/nightlightmanager.cpp b/Plugins/ZShell/Services/nightlightmanager.cpp new file mode 100644 index 0000000..78658d4 --- /dev/null +++ b/Plugins/ZShell/Services/nightlightmanager.cpp @@ -0,0 +1,444 @@ +#include "nightlightmanager.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::services { + +namespace { +constexpr const char* NL_EVENT = "zshell-nightlight"; +constexpr int NEUTRAL_KELVIN = 6500; +constexpr int RECONNECT_MS = 2000; +} // namespace + +NightlightManager::NightlightManager(QObject* parent) : QObject(parent) { + connect(&m_timer, &QTimer::timeout, this, &NightlightManager::apply); + connect(&m_manualTimer, &QTimer::timeout, this, [this] { + m_manualToggle = false; + emit manualToggleChanged(); + apply(); + }); + connect(&m_startCooldown, &QTimer::timeout, this, [this] { + m_startAllowed = true; + apply(); + }); + connect(&m_reconnectTimer, &QTimer::timeout, this, [this] { + if (!m_useNativeNightlight) return; + + connectEventSocket(); + m_pluginLoadAttempted = false; + m_pluginLoadedByUs = false; + bootstrapState(); + }); + + m_startCooldown.start(2000); + m_manualTimer.setSingleShot(true); + m_reconnectTimer.setSingleShot(true); + m_timer.start(60000); + + m_process.setStandardInputFile(QProcess::nullDevice()); + m_process.setStandardOutputFile(QProcess::nullDevice()); +} + +int NightlightManager::startTime() const { + return m_startTime; +} + +int NightlightManager::endTime() const { + return m_endTime; +} + +bool NightlightManager::manualToggle() const { + return m_manualToggle; +} + +bool NightlightManager::enabled() const { + return m_enabled; +} + +bool NightlightManager::activeAuto() const { + return m_activeAuto; +} + +int NightlightManager::temp() const { + return m_temp; +} + +double NightlightManager::fadeDuration() const { + return m_fadeDuration; +} + +bool NightlightManager::animating() const { + return m_animating; +} + +int NightlightManager::lastTemp() const { + return m_lastTemp; +} + +int NightlightManager::currentTemp() const { + return m_currentTemp; +} + +void NightlightManager::setActiveAuto(bool activeAuto) { + if (activeAuto == m_activeAuto) return; + + m_activeAuto = activeAuto; + emit activeAutoChanged(); +} + +void NightlightManager::setManualToggle(bool toggle) { + if (toggle == m_manualToggle) return; + + m_manualToggle = toggle; + emit manualToggleChanged(); + + m_manualTimer.start(60 * 60 * 1000); +} + +void NightlightManager::setEndTime(const int& time) { + if (time == m_endTime) return; + + m_endTime = time; + emit endTimeChanged(); + apply(); +} + +void NightlightManager::setStartTime(const int& time) { + if (time == m_startTime) return; + + m_startTime = time; + emit startTimeChanged(); + apply(); +} + +void NightlightManager::setTemp(const int& temp) { + if (temp == m_temp) return; + + m_temp = temp; + emit tempChanged(); + apply(); +} + +void NightlightManager::setFadeDuration(double duration) { + if (duration < 0.0) duration = 0.0; + + m_fadeDuration = duration; + emit fadeDurationChanged(); +} + +void NightlightManager::toggle() { + if (m_enabled) { + end(); + } else { + start(); + } +} + +void NightlightManager::start() { + if (m_enabled && m_initialized) return; + + m_initialized = true; + m_enabled = true; + + emit enabledChanged(); + + if (m_useNativeNightlight) { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"dispatch", + QString("hl.plugin.zshell.nlSet(%1, %2)") + .arg(m_temp) + .arg(m_fadeDuration)}); + m_process.startDetached(); + } else { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"hyprsunset", "temperature", QString::number(m_temp)}); + m_process.startDetached(); + } +} + +void NightlightManager::end() { + if (!m_enabled && m_initialized) return; + + m_initialized = true; + m_enabled = false; + + emit enabledChanged(); + + if (m_useNativeNightlight) { + m_process.setProgram("hyprctl"); + m_process.setArguments( + {"dispatch", + QString("hl.plugin.zshell.nlDisable(%1)").arg(m_fadeDuration)}); + m_process.startDetached(); + } else { + m_process.setProgram("hyprctl"); + m_process.setArguments({"hyprsunset", "identity"}); + m_process.startDetached(); + } +} + +void NightlightManager::apply() { + if (m_manualToggle || !m_activeAuto || !m_startAllowed) return; + + const auto current = QTime::currentTime(); + const auto currentMin = current.hour() * 60 + current.minute(); + bool isDarkTime = false; + + if (m_startTime <= m_endTime) { + isDarkTime = (currentMin >= m_startTime && currentMin < m_endTime); + } else { + isDarkTime = (currentMin >= m_startTime || currentMin < m_endTime); + } + + if (isDarkTime) { + start(); + } else { + end(); + } +} + +bool NightlightManager::useNativeNightlight() const { + return m_useNativeNightlight; +} + +QString NightlightManager::pluginPath() const { + return m_pluginPath; +} + +void NightlightManager::setPluginPath(const QString& path) { + if (path == m_pluginPath) return; + + m_pluginPath = path; + emit pluginPathChanged(); +} + +void NightlightManager::setUseNativeNightlight(bool native) { + if (native == m_useNativeNightlight) return; + + m_useNativeNightlight = native; + emit useNativeNightlightChanged(); + + if (native) { + disconnect(&m_eventSocket, nullptr, this, nullptr); + connect( + &m_eventSocket, + &QLocalSocket::readyRead, + this, + &NightlightManager::handleEventLines); + connect( + &m_eventSocket, + &QLocalSocket::errorOccurred, + this, + &NightlightManager::scheduleReconnect); + connectEventSocket(); + bootstrapState(); + } else { + m_reconnectTimer.stop(); + disconnect(&m_eventSocket, nullptr, this, nullptr); + if (m_eventSocket.state() != QLocalSocket::UnconnectedState) + m_eventSocket.disconnectFromServer(); + m_socketBuffer.clear(); + m_animating = false; + emit animatingChanged(); + m_pluginLoadAttempted = false; + m_pluginLoadedByUs = false; + } +} + +QString NightlightManager::hyprlandDir() const { + const auto his = qEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE"); + if (his.isEmpty()) return QString(); + + auto runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR"); + auto dir = runtimeDir + "/hypr/" + his; + if (!QFileInfo(dir).isDir()) dir = "/tmp/hypr/" + his; + + if (!QFileInfo(dir).isDir()) return QString(); + + return dir; +} + +QString NightlightManager::eventSocketPath() const { + const auto dir = hyprlandDir(); + return dir.isEmpty() ? QString() : dir + "/.socket2.sock"; +} + +QString NightlightManager::resolvePluginPath() const { + if (!m_pluginPath.isEmpty()) return m_pluginPath; + + const QString name = QStringLiteral("libzshell-nightlight.so"); + const QStringList dirs = { + "/usr/lib/ZShell/plugins", + "/usr/local/lib/ZShell/plugins", + }; + for (const auto& dir : dirs) { + const QString candidate = dir + "/" + name; + if (QFileInfo::exists(candidate)) return candidate; + } + + const QString home = qEnvironmentVariable("HOME"); + if (!home.isEmpty()) { + const QString candidate = home + "/.local/lib/ZShell/plugins/" + name; + if (QFileInfo::exists(candidate)) return candidate; + } + + return QString(); +} + +void NightlightManager::tryLoadPlugin() { + if (m_pluginLoadAttempted) return; + + const auto path = resolvePluginPath(); + if (path.isEmpty()) { + qWarning() << "NightlightManager: plugin not found; searched " + "pluginPath and /usr/lib/ZShell/plugins"; + m_pluginLoadAttempted = true; + return; + } + + m_pluginLoadAttempted = true; + m_pluginLoadedByUs = true; + qInfo() << "NightlightManager: plugin not loaded, loading" << path; + QProcess::startDetached("hyprctl", {"plugin", "load", path}); + + QTimer::singleShot(1500, this, [this] { bootstrapState(); }); +} + +void NightlightManager::bootstrapState() { + if (!m_useNativeNightlight) return; + + const auto dir = hyprlandDir(); + if (dir.isEmpty()) return; + + auto* sock = new QLocalSocket(this); + auto* watchdog = new QTimer(this); + watchdog->setSingleShot(true); + + const auto finish = [sock, watchdog]() { + watchdog->stop(); + sock->abort(); + sock->deleteLater(); + }; + + connect(sock, &QLocalSocket::connected, sock, [sock] { + sock->write("repl return hl.plugin.zshell.nlState()"); + sock->flush(); + }); + connect(sock, &QLocalSocket::readyRead, sock, [this, sock, finish]() { + const auto raw = sock->readAll(); + const auto doc = QJsonDocument::fromJson(raw); + finish(); + if (!doc.isObject()) { + if (m_pluginLoadAttempted) + qWarning() << "NightlightManager: plugin load failed, state " + << "query still answered:" << raw; + + tryLoadPlugin(); + return; + } + + if (m_pluginLoadedByUs) { + qInfo() << "NightlightManager: plugin was not loaded, manager " + << "loaded it; state:" << raw; + m_pluginLoadedByUs = false; + } else { + qInfo() << "NightlightManager: plugin already loaded, state:" + << raw; + } + + const auto obj = doc.object(); + updateState( + obj.value("enabled").toBool(), + obj.value("animating").toBool(), + obj.value("last").toInt(), + obj.value("current").toInt()); + }); + connect(sock, &QLocalSocket::errorOccurred, sock, finish); + connect(watchdog, &QTimer::timeout, sock, finish); + watchdog->start(2500); + + sock->connectToServer(dir + "/.socket.sock"); +} + +void NightlightManager::connectEventSocket() { + if (!m_useNativeNightlight) return; + + if (m_eventSocket.state() != QLocalSocket::UnconnectedState) return; + + const auto path = eventSocketPath(); + if (path.isEmpty()) { + scheduleReconnect(); + return; + } + + m_socketBuffer.clear(); + m_eventSocket.connectToServer(path, QLocalSocket::ReadOnly); +} + +void NightlightManager::scheduleReconnect() { + if (!m_useNativeNightlight) return; + + m_reconnectTimer.start(RECONNECT_MS); +} + +void NightlightManager::handleEventLines() { + m_socketBuffer.append(m_eventSocket.readAll()); + + qsizetype idx = 0; + while ((idx = m_socketBuffer.indexOf('\n')) >= 0) { + const auto line = m_socketBuffer.left(idx); + m_socketBuffer.remove(0, idx + 1); + handleEventLine(line); + } +} + +void NightlightManager::handleEventLine(const QByteArray& line) { + const qsizetype sep = line.indexOf(">>"); + if (sep <= 0) return; + + if (line.left(sep) != NL_EVENT) return; + + // data = enabled,animating,last,current + const auto fields = line.mid(sep + 2).split(','); + if (fields.size() < 4) return; + + bool ok = false; + const auto enabled = fields.at(0).toInt(&ok); + if (!ok) return; + const auto animating = fields.at(1).toInt(&ok); + if (!ok) return; + const auto last = fields.at(2).toInt(&ok); + if (!ok) return; + const auto current = fields.at(3).toInt(&ok); + if (!ok) return; + + updateState(enabled == 1, animating == 1, last, current); +} + +void NightlightManager::updateState( + bool enabled, bool animating, int last, int current) { + if (enabled != m_enabled) { + m_enabled = enabled; + emit enabledChanged(); + } + if (animating != m_animating) { + m_animating = animating; + emit animatingChanged(); + } + if (last != m_lastTemp) { + m_lastTemp = last; + emit lastTempChanged(); + } + if (current != m_currentTemp) { + m_currentTemp = current; + emit currentTempChanged(); + } +} + +}; // namespace ZShell::services diff --git a/Plugins/ZShell/Services/nightlightmanager.hpp b/Plugins/ZShell/Services/nightlightmanager.hpp new file mode 100644 index 0000000..43b341d --- /dev/null +++ b/Plugins/ZShell/Services/nightlightmanager.hpp @@ -0,0 +1,122 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::services { + +class NightlightManager : public QObject { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(bool enabled READ enabled NOTIFY enabledChanged) + Q_PROPERTY( + int startTime READ startTime WRITE setStartTime NOTIFY startTimeChanged) + Q_PROPERTY(int endTime READ endTime WRITE setEndTime NOTIFY endTimeChanged) + Q_PROPERTY(int temp READ temp WRITE setTemp NOTIFY tempChanged) + Q_PROPERTY( + double fadeDuration READ fadeDuration WRITE setFadeDuration NOTIFY + fadeDurationChanged) + Q_PROPERTY( + bool useNativeNightlight READ useNativeNightlight WRITE + setUseNativeNightlight NOTIFY useNativeNightlightChanged) + Q_PROPERTY( + QString pluginPath READ pluginPath WRITE setPluginPath NOTIFY + pluginPathChanged) + Q_PROPERTY( + bool activeAuto READ activeAuto WRITE setActiveAuto NOTIFY + activeAutoChanged) + Q_PROPERTY( + bool manualToggle READ manualToggle WRITE setManualToggle NOTIFY + manualToggleChanged) + Q_PROPERTY(bool animating READ animating NOTIFY animatingChanged) + Q_PROPERTY(int lastTemp READ lastTemp NOTIFY lastTempChanged) + Q_PROPERTY(int currentTemp READ currentTemp NOTIFY currentTempChanged) + + public: + explicit NightlightManager(QObject* parent = nullptr); + + [[nodiscard]] int startTime() const; + [[nodiscard]] int endTime() const; + [[nodiscard]] bool enabled() const; + [[nodiscard]] int temp() const; + [[nodiscard]] double fadeDuration() const; + [[nodiscard]] bool useNativeNightlight() const; + [[nodiscard]] QString pluginPath() const; + [[nodiscard]] bool activeAuto() const; + [[nodiscard]] bool manualToggle() const; + [[nodiscard]] bool animating() const; + [[nodiscard]] int lastTemp() const; + [[nodiscard]] int currentTemp() const; + + Q_INVOKABLE void toggle(); + Q_INVOKABLE void apply(); + + void setStartTime(const int& time); + void setEndTime(const int& time); + void setTemp(const int& temp); + void setFadeDuration(double duration); + void setUseNativeNightlight(bool native); + void setPluginPath(const QString& path); + void setActiveAuto(bool activeAuto); + void setManualToggle(bool toggle); + + signals: + void enabledChanged(); + void startTimeChanged(); + void activeAutoChanged(); + void endTimeChanged(); + void tempChanged(); + void fadeDurationChanged(); + void useNativeNightlightChanged(); + void pluginPathChanged(); + void manualToggleChanged(); + void animatingChanged(); + void lastTempChanged(); + void currentTempChanged(); + + private: + int m_startTime; + int m_endTime; + bool m_enabled = false; + bool m_manualToggle = false; + bool m_activeAuto; + bool m_startAllowed = false; + bool m_initialized = false; + QTimer m_startCooldown; + int m_temp; + double m_fadeDuration = 0.4; + bool m_useNativeNightlight = false; + QString m_pluginPath; + bool m_pluginLoadAttempted = false; + bool m_pluginLoadedByUs = false; + bool m_animating = false; + int m_lastTemp = 6500; + int m_currentTemp = 6500; + QProcess m_process; + QTimer m_timer; + QTimer m_manualTimer; + QLocalSocket m_eventSocket; + QByteArray m_socketBuffer; + QTimer m_reconnectTimer; + + void start(); + void end(); + [[nodiscard]] QString hyprlandDir() const; + [[nodiscard]] QString eventSocketPath() const; + [[nodiscard]] QString resolvePluginPath() const; + void tryLoadPlugin(); + void bootstrapState(); + void connectEventSocket(); + void scheduleReconnect(); + void handleEventLines(); + void handleEventLine(const QByteArray& line); + void updateState(bool enabled, bool animating, int last, int current); +}; + +}; // namespace ZShell::services From d259c6ffbeab737d4815f03fbbca579e818d6dcc Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 31 Aug 2026 14:09:54 +0200 Subject: [PATCH 12/90] fix: shader fade animation linear curve --- Plugins/ZShell/HyprPlugins/colortemp.hpp | 5 ----- Plugins/ZShell/HyprPlugins/main.cpp | 6 ++---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/Plugins/ZShell/HyprPlugins/colortemp.hpp b/Plugins/ZShell/HyprPlugins/colortemp.hpp index 25f9c05..0da6f88 100644 --- a/Plugins/ZShell/HyprPlugins/colortemp.hpp +++ b/Plugins/ZShell/HyprPlugins/colortemp.hpp @@ -29,9 +29,4 @@ inline std::array kelvinToGain(int kelvin) { return {static_cast(r), static_cast(g), static_cast(b)}; } -inline float easeInOutCubic(float x) { - return x < 0.5f ? 4.f * x * x * x - : 1.f - std::pow(-2.f * x + 2.f, 3.f) / 2.f; -} - } // namespace ZShell::services diff --git a/Plugins/ZShell/HyprPlugins/main.cpp b/Plugins/ZShell/HyprPlugins/main.cpp index ad2080e..39953e9 100644 --- a/Plugins/ZShell/HyprPlugins/main.cpp +++ b/Plugins/ZShell/HyprPlugins/main.cpp @@ -25,7 +25,6 @@ extern "C" { #include "colortemp.hpp" -using ZShell::services::easeInOutCubic; using ZShell::services::kelvinToGain; using Render::IFramebuffer; @@ -241,17 +240,16 @@ static void hkBegin( .count(); const float t = std::clamp(elapsed / g_transition.durationSec, 0.f, 1.f); - const float e = easeInOutCubic(t); for (size_t i = 0; i < 3; ++i) g_currentGain[i] = g_transition.fromGain[i] + - (g_transition.toGain[i] - g_transition.fromGain[i]) * e; + (g_transition.toGain[i] - g_transition.fromGain[i]) * t; if (g_uniformMode) { setUniformGain(g_currentGain); } else { - const int step = static_cast(e * BAKED_STEPS); + const int step = static_cast(t * BAKED_STEPS); if (step != g_transition.lastBakedStep) { g_transition.lastBakedStep = step; ensureShaderFile( From 2c90339c77a311b841b2157fb95785b000497779 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 20 Sep 2026 12:59:53 +0200 Subject: [PATCH 13/90] feat: dashboard show on hover, media and weather tabs added --- Components/ColorArcPicker.qml | 10 +- Components/CustomFlickable.qml | 4 +- Components/CustomListView.qml | 4 +- Components/CustomScrollBar.qml | 10 +- Components/CustomTextField.qml | 44 +- Components/LineGraph.qml | 467 +++++++ Components/ScreenState.qml | 37 + Components/StateLayer.qml | 4 +- Daemons/NotifServer.qml | 3 +- Drawers/Drawing.qml | 15 +- Drawers/Interactions.qml | 130 +- Drawers/Panels.qml | 35 +- Drawers/Windows.qml | 89 +- Greeter/WeatherInfo.qml | 3 +- Helpers/Strings.qml | 9 + Helpers/Visibilities.qml | 16 - Helpers/Weather.qml | 35 +- Modules/Bar/Bar.qml | 11 +- Modules/Bar/BarLoader.qml | 6 +- Modules/Bar/Border.qml | 2 +- Modules/Bar/Components/Clock.qml | 10 +- Modules/Bar/Components/NotifBell.qml | 12 +- Modules/Bar/Components/Resources.qml | 24 +- Modules/Bar/Components/Tray/TrayItem.qml | 8 +- Modules/Clipboard/Content.qml | 4 +- Modules/Clipboard/Wrapper.qml | 6 +- Modules/Dashboard/Content.qml | 175 ++- Modules/Dashboard/Dash.qml | 26 +- Modules/Dashboard/Dash/Calendar.qml | 18 +- Modules/Dashboard/Dash/Media.qml | 329 ----- Modules/Dashboard/Dash/User.qml | 2 +- Modules/Dashboard/Media.qml | 152 +++ .../{Dash => }/Media/BackgroundShapes.qml | 0 .../Dashboard/{Dash => }/Media/CoverArt.qml | 0 .../{Dash => }/Media/CoverVisualizer.qml | 4 +- Modules/Dashboard/Media/Details.qml | 194 +++ Modules/Dashboard/Media/LyricList.qml | 317 +++++ Modules/Dashboard/Media/LyricsAndSelector.qml | 79 ++ Modules/Dashboard/Media/LyricsInfo.qml | 212 +++ Modules/Dashboard/Tabs.qml | 234 ++-- Modules/Dashboard/Weather/Graphs.qml | 145 ++ Modules/Dashboard/WeatherTab.qml | 259 ++++ Modules/Dashboard/Wrapper.qml | 29 +- .../DesktopIcons/BackgroundContextMenu.qml | 5 +- Modules/Dock/Content.qml | 6 +- Modules/Dock/Parts/DockAppButton.qml | 6 +- Modules/Dock/Wrapper.qml | 6 +- Modules/Drawing/Content.qml | 18 +- Modules/Drawing/Wrapper.qml | 6 +- Modules/Launcher/AppList.qml | 10 +- Modules/Launcher/CarouselView.qml | 6 +- Modules/Launcher/Content.qml | 14 +- Modules/Launcher/ContentList.qml | 10 +- Modules/Launcher/Items/AppItem.qml | 4 +- Modules/Launcher/Items/CalcItem.qml | 4 +- Modules/Launcher/Items/FilesItem.qml | 141 ++ Modules/Launcher/Items/WallpaperTile.qml | 2 +- Modules/Launcher/Services/Actions.qml | 4 +- Modules/Launcher/Services/SchemeVariants.qml | 2 +- Modules/Launcher/Wrapper.qml | 8 +- Modules/Lock/Weather/Forecast.qml | 4 +- Modules/Notifications/Content.qml | 8 +- Modules/Notifications/Sidebar/Content.qml | 6 +- Modules/Notifications/Sidebar/Notif.qml | 5 +- Modules/Notifications/Sidebar/NotifDock.qml | 6 +- .../Notifications/Sidebar/NotifDockList.qml | 6 +- Modules/Notifications/Sidebar/NotifGroup.qml | 14 +- .../Notifications/Sidebar/NotifGroupList.qml | 6 +- Modules/Notifications/Sidebar/Props.qml | 7 - .../Sidebar/Utils/Cards/Record.qml | 11 +- .../Sidebar/Utils/Cards/RecordingList.qml | 37 +- .../Sidebar/Utils/Cards/Toggles.qml | 8 +- .../Notifications/Sidebar/Utils/Content.qml | 12 +- .../Notifications/Sidebar/Utils/Wrapper.qml | 16 +- Modules/Notifications/Sidebar/Wrapper.qml | 10 +- Modules/Notifications/Wrapper.qml | 4 +- Modules/Osd/Content.qml | 4 +- Modules/Osd/Wrapper.qml | 10 +- Modules/Resources/Wrapper.qml | 4 +- Modules/Settings/Common/TextFieldRow.qml | 85 ++ .../Settings/Pages/Panels/DashboardPanel.qml | 28 + Modules/Settings/Wrapper.qml | 4 +- Modules/Shortcuts.qml | 25 +- Plugins/ZShell/Config/CMakeLists.txt | 2 + Plugins/ZShell/Config/dashboard.hpp | 2 + Plugins/ZShell/Config/enums.hpp | 29 + Plugins/ZShell/Config/general.hpp | 5 + Plugins/ZShell/Config/services.hpp | 2 + Plugins/ZShell/Internal/sparklineitem.cpp | 117 +- Plugins/ZShell/Internal/sparklineitem.hpp | 23 + Plugins/ZShell/Services/CMakeLists.txt | 7 +- Plugins/ZShell/Services/lyriccandidate.cpp | 55 + Plugins/ZShell/Services/lyriccandidate.hpp | 53 + Plugins/ZShell/Services/lyrics.cpp | 1178 +++++++++++++++++ Plugins/ZShell/Services/lyrics.hpp | 162 +++ Plugins/cmake/qml-module.cmake | 55 + Services/ShellState.qml | 35 + 97 files changed, 4480 insertions(+), 990 deletions(-) create mode 100644 Components/LineGraph.qml create mode 100644 Components/ScreenState.qml delete mode 100644 Helpers/Visibilities.qml delete mode 100644 Modules/Dashboard/Dash/Media.qml create mode 100644 Modules/Dashboard/Media.qml rename Modules/Dashboard/{Dash => }/Media/BackgroundShapes.qml (100%) rename Modules/Dashboard/{Dash => }/Media/CoverArt.qml (100%) rename Modules/Dashboard/{Dash => }/Media/CoverVisualizer.qml (63%) create mode 100644 Modules/Dashboard/Media/Details.qml create mode 100644 Modules/Dashboard/Media/LyricList.qml create mode 100644 Modules/Dashboard/Media/LyricsAndSelector.qml create mode 100644 Modules/Dashboard/Media/LyricsInfo.qml create mode 100644 Modules/Dashboard/Weather/Graphs.qml create mode 100644 Modules/Dashboard/WeatherTab.qml create mode 100644 Modules/Launcher/Items/FilesItem.qml delete mode 100644 Modules/Notifications/Sidebar/Props.qml create mode 100644 Modules/Settings/Common/TextFieldRow.qml create mode 100644 Plugins/ZShell/Config/enums.hpp create mode 100644 Plugins/ZShell/Services/lyriccandidate.cpp create mode 100644 Plugins/ZShell/Services/lyriccandidate.hpp create mode 100644 Plugins/ZShell/Services/lyrics.cpp create mode 100644 Plugins/ZShell/Services/lyrics.hpp create mode 100644 Plugins/cmake/qml-module.cmake create mode 100644 Services/ShellState.qml diff --git a/Components/ColorArcPicker.qml b/Components/ColorArcPicker.qml index 0a14f32..43b9c34 100644 --- a/Components/ColorArcPicker.qml +++ b/Components/ColorArcPicker.qml @@ -46,10 +46,10 @@ Item { } function syncFromPenColor() { - if (!drawing.drawingState) + if (!drawing.screenState) return; - const c = drawing.drawingState.penColor; + const c = drawing.screenState.penColor; if (c.hsvSaturation > 0) { currentHue = c.hsvHue; @@ -86,7 +86,7 @@ Item { currentHue = relative / arcSweep; lastChromaticHue = currentHue; - drawing.drawingState.penColor = Qt.hsva(currentHue, drawing.drawingState.penColor.hsvSaturation, drawing.drawingState.penColor.hsvValue, drawing.drawingState.penColor.a); + drawing.screenState.penColor = Qt.hsva(currentHue, drawing.screenState.penColor.hsvSaturation, drawing.screenState.penColor.hsvValue, drawing.screenState.penColor.a); } implicitHeight: 180 @@ -112,7 +112,7 @@ Item { root.syncFromPenColor(); } - target: root.drawing.drawingState + target: root.drawing.screenState } Canvas { @@ -152,7 +152,7 @@ Item { CustomRect { anchors.centerIn: parent - color: root.drawing?.drawingState.penColor + color: root.drawing?.screenState.penColor implicitHeight: implicitWidth implicitWidth: canvas.height - root.handleSize - Tokens.padding.extraLarge * 2 radius: Tokens.rounding.full diff --git a/Components/CustomFlickable.qml b/Components/CustomFlickable.qml index 249f093..a1267f2 100644 --- a/Components/CustomFlickable.qml +++ b/Components/CustomFlickable.qml @@ -1,12 +1,12 @@ import QtQuick -import qs.Helpers +import qs.Services Flickable { id: root property bool doneFakeFlick - interactive: !Visibilities.getForActive().isDrawing + interactive: !ShellState.forActive().isDrawing maximumFlickVelocity: 3000 rebound: Transition { diff --git a/Components/CustomListView.qml b/Components/CustomListView.qml index 0b8d618..2b02e96 100644 --- a/Components/CustomListView.qml +++ b/Components/CustomListView.qml @@ -1,12 +1,12 @@ import QtQuick -import qs.Helpers +import qs.Services ListView { id: root property bool doneFakeFlick - interactive: !Visibilities.getForActive()?.isDrawing + interactive: !ShellState.forActive()?.isDrawing maximumFlickVelocity: 3000 rebound: Transition { diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index 0159df0..dedd428 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Templates -import qs.Helpers +import qs.Services import ZShell.Config import qs.Services @@ -17,8 +17,12 @@ ScrollBar { property bool shouldBeActive readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0 - enabled: !Visibilities.getForActive().isDrawing - implicitWidth: Tokens.padding.extraSmall * 2 + property bool shouldBeActive + property bool inverted + + enabled: !ShellState.forActive().isDrawing + implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2 + implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0 contentItem: Item { } diff --git a/Components/CustomTextField.qml b/Components/CustomTextField.qml index 8ddc120..66141b8 100644 --- a/Components/CustomTextField.qml +++ b/Components/CustomTextField.qml @@ -15,28 +15,33 @@ TextFieldBase { readonly property int clampedRadius: Math.min(horizontalPadding, Math.min(width, height) / 2, radius) readonly property string effectiveSupportingText: isError && errorText ? errorText : supportingText + readonly property int filledOffset: type === CustomTextField.Filled ? Tokens.spacing.small : 0 + readonly property int leadingOffset: leadingIcon ? leadingIconLoader.width + leadingIconLoader.anchors.leftMargin : 0 + readonly property real smallFontScale: smallFontSize / font.pointSize + readonly property int supportingTextOffset: effectiveSupportingText ? supportingTextLoader.height + Tokens.spacing.extraSmall : 0 + readonly property int trailingOffset: trailingIcon ? trailingIconLoader.width + trailingIconLoader.anchors.rightMargin : 0 + readonly property bool valid: !validate || (!text && emptyIsValid) || (validate instanceof RegExp ? validate.test(text) : !!validate(text)) + + property int horizontalPadding: Tokens.padding.large + property int verticalPadding: Tokens.padding.large + + property int radius: Tokens.rounding.small + + property string supportingText + property string trailingIcon + property int smallFontSize: Tokens.font.size.small + + property int type: CustomTextField.Outlined + property var validate // Regex or function property bool emptyIsValid: true property string errorText - readonly property int filledOffset: type === CustomTextField.Filled ? Tokens.spacing.small : 0 - readonly property int horizontalPadding: Tokens.padding.large property bool isError property string leadingIcon - readonly property int leadingOffset: leadingIcon ? leadingIconLoader.width + leadingIconLoader.anchors.leftMargin : 0 - property int radius: Tokens.rounding.small - readonly property real smallFontScale: smallFontSize / font.pointSize - property int smallFontSize: Tokens.font.size.small - property string supportingText - readonly property int supportingTextOffset: effectiveSupportingText ? supportingTextLoader.height + Tokens.spacing.extraSmall : 0 - property string trailingIcon - readonly property int trailingOffset: trailingIcon ? trailingIconLoader.width + trailingIconLoader.anchors.rightMargin : 0 - property int type: CustomTextField.Outlined - readonly property bool valid: !validate || (!text && emptyIsValid) || (validate instanceof RegExp ? validate.test(text) : !!validate(text)) - property var validate // Regex or function - bottomPadding: Tokens.padding.large + supportingTextOffset - filledOffset + bottomPadding: verticalPadding + supportingTextOffset - filledOffset leftPadding: horizontalPadding + leadingOffset rightPadding: horizontalPadding + trailingOffset - topPadding: Tokens.padding.large + filledOffset + topPadding: verticalPadding + filledOffset background: Loader { anchors.bottomMargin: root.supportingTextOffset @@ -212,12 +217,10 @@ TextFieldBase { } } Behavior on strokeColor { - CAnim { - } + CAnim {} } Behavior on strokeWidth { - Anim { - } + Anim {} } PathLine { @@ -292,8 +295,7 @@ TextFieldBase { implicitHeight: root.activeFocus ? 2 : 1 Behavior on implicitHeight { - Anim { - } + Anim {} } } } diff --git a/Components/LineGraph.qml b/Components/LineGraph.qml new file mode 100644 index 0000000..c16a1a6 --- /dev/null +++ b/Components/LineGraph.qml @@ -0,0 +1,467 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +Item { + id: root + + required property list values + + property int xSteps: 24 + property real yMin: NaN + property real yMax: NaN + property real yPaddingTop: 0 + property real yPaddingBottom: 0 + + property int verticalLineStep: 0 + property int horizontalLineStep: 0 + property bool firstXLine: true + property bool lastXLine: false + property int minHorizontalLines: 0 + + property var xLabel: null + property var yLabel: null + + property real smoothing: 0 + property bool curveSmoothing: true + property real markerX: -1 + property real markerGap: 2 // px gap between line and marker dot edge, each side + + property color lineColor: "white" + property color gridColor: "white" + property color markerColor: "white" + property color labelColor: "white" + property real gridAlpha: 0.12 + property real labelAlpha: 0.6 + property real fillAlpha: 0.15 + property real lineWidth: 2 + property real markerDotRadius: 4 + property real labelFontSize: 10 + + Canvas { + id: canvas + + anchors.fill: parent + renderStrategy: Canvas.Immediate + + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + function strokeCurve(ctx, pts) { + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + + if (root.curveSmoothing && pts.length > 2) { + const k = 1 / 6; + const n = pts.length; + + for (let i = 0; i < n - 1; i++) { + const p0 = pts[Math.max(i - 1, 0)]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[Math.min(i + 2, n - 1)]; + + let c1x = p1.x + (p2.x - p0.x) * k; + let c1y = p1.y + (p2.y - p0.y) * k; + let c2x = p2.x - (p3.x - p1.x) * k; + let c2y = p2.y - (p3.y - p1.y) * k; + + const minY = Math.min(p1.y, p2.y); + const maxY = Math.max(p1.y, p2.y); + c1y = Math.max(minY, Math.min(maxY, c1y)); + c2y = Math.max(minY, Math.min(maxY, c2y)); + + ctx.bezierCurveTo(c1x, c1y, c2x, c2y, p2.x, p2.y); + } + } else { + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i]; + const p1 = pts[i + 1]; + const cx = (p0.x + p1.x) * 0.5; + ctx.bezierCurveTo(cx, p0.y, cx, p1.y, p1.x, p1.y); + } + } + } + + // Control points for segment i (pts[i] -> pts[i+1]), matching strokeCurve + function segmentControls(pts, i) { + const p0 = pts[Math.max(i - 1, 0)]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[Math.min(i + 2, pts.length - 1)]; + + if (root.curveSmoothing && pts.length > 2) { + const k = 1 / 6; + let c1x = p1.x + (p2.x - p0.x) * k; + let c1y = p1.y + (p2.y - p0.y) * k; + let c2x = p2.x - (p3.x - p1.x) * k; + let c2y = p2.y - (p3.y - p1.y) * k; + + const minY = Math.min(p1.y, p2.y); + const maxY = Math.max(p1.y, p2.y); + c1y = Math.max(minY, Math.min(maxY, c1y)); + c2y = Math.max(minY, Math.min(maxY, c2y)); + + return [c1x, c1y, c2x, c2y]; + } + + const cx = (p1.x + p2.x) * 0.5; + return [cx, p1.y, cx, p2.y]; + } + + // Point on the curve at a given x (x is monotonic per segment) + function pointAtX(pts, targetX) { + let j = pts.length - 2; + for (let i = 0; i < pts.length - 1; i++) { + if (targetX <= pts[i + 1].x) { + j = i; + break; + } + } + + const p1 = pts[j]; + const p2 = pts[j + 1]; + const [c1x, c1y, c2x, c2y] = segmentControls(pts, j); + + let tlo = 0; + let thi = 1; + for (let it = 0; it < 24; it++) { + const tm = (tlo + thi) / 2; + const u = 1 - tm; + const x = u * u * u * p1.x + 3 * u * u * tm * c1x + 3 * u * tm * tm * c2x + tm * tm * tm * p2.x; + if (x < targetX) + tlo = tm; + else + thi = tm; + } + + const t = (tlo + thi) / 2; + const u = 1 - t; + const y = u * u * u * p1.y + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * p2.y; + return { x: targetX, y }; + } + + // Part of the curve left/right of a cut x, including the cut point + function cutLeft(pts, targetX) { + if (targetX <= pts[0].x) + return []; + if (targetX >= pts[pts.length - 1].x) + return pts.slice(); + + let j = pts.length - 2; + for (let i = 0; i < pts.length - 1; i++) { + if (targetX <= pts[i + 1].x) { + j = i; + break; + } + } + + const left = pts.slice(0, j + 1); + if (targetX > pts[j].x) + left.push(pointAtX(pts, targetX)); + return left; + } + + function cutRight(pts, targetX) { + if (targetX >= pts[pts.length - 1].x) + return []; + if (targetX <= pts[0].x) + return pts.slice(); + + let j = pts.length - 2; + for (let i = 0; i < pts.length - 1; i++) { + if (targetX <= pts[i + 1].x) { + j = i; + break; + } + } + + const right = pts.slice(j + 1); + if (targetX < pts[j + 1].x) + right.unshift(pointAtX(pts, targetX)); + return right; + } + + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + + const W = width; + const H = height; + if (W < 2 || H < 2) + return; + + const padLeft = root.labelFontSize * 3.4; + const padRight = 2; + const padTop = 2; + const padBottom = root.labelFontSize + 8; + const plot = { + x: padLeft, + y: padTop, + w: W - padLeft - padRight, + h: H - padTop - padBottom + }; + if (plot.w < 2 || plot.h < 2) + return; + + // Auto-fit: NaN yMin/yMax derive from the data, snapped to the + // horizontal step grid, honoring minHorizontalLines + let lo = root.yMin; + let hi = root.yMax; + const loAuto = Number.isNaN(lo); + const hiAuto = Number.isNaN(hi); + + if (loAuto || hiAuto) { + let dmin = Infinity; + let dmax = -Infinity; + for (const v of root.values) { + if (v == null) + continue; + if (v < dmin) + dmin = v; + if (v > dmax) + dmax = v; + } + if (dmin === Infinity) { + dmin = 0; + dmax = 1; + } + + const step = root.horizontalLineStep; + const minSteps = root.minHorizontalLines > 1 ? root.minHorizontalLines - 1 : 0; + + if (step > 0) { + if (hiAuto) { + const baseLo = loAuto ? Math.floor(dmin / step) * step : lo; + const span = Math.max(Math.ceil((dmax - baseLo) / step), minSteps, 1); + lo = baseLo; + hi = baseLo + span * step; + } else { + const span = Math.max(Math.ceil((hi - dmin) / step), minSteps, 1); + lo = hi - span * step; + } + } else { + if (loAuto) + lo = dmin; + if (hiAuto) + hi = dmax; + if (hi <= lo) + hi = lo + 1; + } + } + + const scaleBottom = lo - root.yPaddingBottom; + const scaleTop = hi + root.yPaddingTop; + const range = scaleTop - scaleBottom; + if (range <= 0 || root.xSteps < 1) + return; + + let series = root.values; + if (root.smoothing > 0 && series.length > 0) { + const radius = Math.round(root.smoothing); + const raw = series; + series = raw.map((v, i) => { + if (v == null) + return null; + + let sum = 0; + let n = 0; + for (let j = -radius; j <= radius; j++) { + const u = raw[i + j]; + if (u != null) { + sum += u; + n++; + } + } + return n > 0 ? sum / n : null; + }); + } + + const xAt = i => plot.x + (i / root.xSteps) * plot.w; + const yAt = v => plot.y + (1 - (v - scaleBottom) / range) * plot.h; + const crisp = v => Math.floor(v) + 0.5; + + // 1. Grid lines + ctx.strokeStyle = Qt.alpha(root.gridColor, root.gridAlpha); + ctx.lineWidth = 1; + + if (root.verticalLineStep > 0) { + for (let i = 0; i <= root.xSteps; i += root.verticalLineStep) { + if (i === 0 && !root.firstXLine) + continue; + if (i === root.xSteps && !root.lastXLine) + continue; + + const x = crisp(xAt(i)); + ctx.beginPath(); + ctx.moveTo(x, plot.y); + ctx.lineTo(x, H); + ctx.stroke(); + } + } + + // Lines extend down into yPaddingBottom, snapped to the grid + const vStart = lo - (root.yPaddingBottom > 0 ? Math.floor(root.yPaddingBottom / root.horizontalLineStep) * root.horizontalLineStep : 0); + + if (root.horizontalLineStep > 0) { + for (let v = vStart; v <= hi + 0.001; v += root.horizontalLineStep) { + const y = crisp(yAt(v)); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(plot.x + plot.w, y); + ctx.stroke(); + } + } + + // 2. Labels + ctx.fillStyle = Qt.alpha(root.labelColor, root.labelAlpha); + ctx.font = root.labelFontSize + "px sans-serif"; + + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + if (root.verticalLineStep > 0 && root.xLabel) { + for (let i = 0; i <= root.xSteps; i += root.verticalLineStep) { + if (i === 0 && !root.firstXLine) + continue; + if (i === root.xSteps && !root.lastXLine) + continue; + + const text = root.xLabel(i); + if (text) + ctx.fillText(text, xAt(i) + 4, plot.y + plot.h + 4); + } + } + + ctx.textBaseline = "bottom"; + if (root.horizontalLineStep > 0 && root.yLabel) { + for (let v = vStart; v <= hi + 0.001; v += root.horizontalLineStep) { + const text = root.yLabel(v); + if (text) + ctx.fillText(text, 2, yAt(v) - 2); + } + } + + // 3. Curve, split into segments at null gaps + const segments = []; + let current = null; + for (let i = 0; i < series.length; i++) { + if (series[i] == null) { + if (current) + segments.push(current); + current = null; + continue; + } + const pt = { + x: xAt(i), + y: yAt(series[i]) + }; + if (!current) + current = []; + current.push(pt); + } + if (current) + segments.push(current); + + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + const hasMarker = root.markerX >= 0 && root.markerX <= root.xSteps; + const markerPx = hasMarker ? xAt(root.markerX) : 0; + let gapPx = 0; + if (hasMarker) { + // markerGap is measured from the dot edge, perpendicular to the + // curve; project onto x via the local curve slope + let slope = 0; + const seg = segments.find(s => s[0].x <= markerPx && markerPx <= s[s.length - 1].x); + if (seg && seg.length > 2 && markerPx - 1 >= seg[0].x && markerPx + 1 <= seg[seg.length - 1].x) + slope = (pointAtX(seg, markerPx + 1).y - pointAtX(seg, markerPx - 1).y) / 2; + gapPx = (root.markerDotRadius + root.markerGap) / Math.sqrt(1 + slope * slope); + } + + for (const seg of segments) { + if (seg.length < 2) + continue; + + // Fill stays continuous under the marker + ctx.fillStyle = Qt.alpha(root.lineColor, root.fillAlpha); + strokeCurve(ctx, seg); + ctx.lineTo(seg[seg.length - 1].x, plot.y + plot.h); + ctx.lineTo(seg[0].x, plot.y + plot.h); + ctx.closePath(); + ctx.fill(); + + // Stroke has a gap around the marker + const pieces = hasMarker ? [cutLeft(seg, markerPx - gapPx), cutRight(seg, markerPx + gapPx)] : [seg]; + + for (const pts of pieces) { + if (pts.length < 2) + continue; + + ctx.strokeStyle = root.lineColor; + ctx.lineWidth = root.lineWidth; + strokeCurve(ctx, pts); + ctx.stroke(); + } + } + + // 4. Marker: dashed vertical + dot + if (root.markerX >= 0 && root.markerX <= root.xSteps) { + const x = markerPx; + + ctx.setLineDash([4, 4]); + ctx.strokeStyle = Qt.alpha(root.markerColor, 0.6); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(crisp(x), plot.y); + ctx.lineTo(crisp(x), H); + ctx.stroke(); + ctx.setLineDash([]); + + const i0 = Math.floor(root.markerX); + const v0 = series[i0]; + const v1 = series[i0 + 1]; + let y = null; + + if (v0 != null && v1 != null) + y = yAt(v0 + (v1 - v0) * (root.markerX - i0)); + else if (v0 != null) + y = yAt(v0); + + if (y != null) { + ctx.fillStyle = root.markerColor; + ctx.beginPath(); + ctx.arc(x, y, root.markerDotRadius, 0, Math.PI * 2); + ctx.fill(); + } + } + } + } + + onValuesChanged: canvas.requestPaint() + onXStepsChanged: canvas.requestPaint() + onYMinChanged: canvas.requestPaint() + onYMaxChanged: canvas.requestPaint() + onYPaddingTopChanged: canvas.requestPaint() + onYPaddingBottomChanged: canvas.requestPaint() + onVerticalLineStepChanged: canvas.requestPaint() + onHorizontalLineStepChanged: canvas.requestPaint() + onFirstXLineChanged: canvas.requestPaint() + onLastXLineChanged: canvas.requestPaint() + onMinHorizontalLinesChanged: canvas.requestPaint() + onXLabelChanged: canvas.requestPaint() + onYLabelChanged: canvas.requestPaint() + onSmoothingChanged: canvas.requestPaint() + onCurveSmoothingChanged: canvas.requestPaint() + onMarkerXChanged: canvas.requestPaint() + onMarkerGapChanged: canvas.requestPaint() + onLineColorChanged: canvas.requestPaint() + onGridColorChanged: canvas.requestPaint() + onMarkerColorChanged: canvas.requestPaint() + onLabelColorChanged: canvas.requestPaint() + onGridAlphaChanged: canvas.requestPaint() + onLabelAlphaChanged: canvas.requestPaint() + onFillAlphaChanged: canvas.requestPaint() + onLineWidthChanged: canvas.requestPaint() + onMarkerDotRadiusChanged: canvas.requestPaint() + onLabelFontSizeChanged: canvas.requestPaint() +} diff --git a/Components/ScreenState.qml b/Components/ScreenState.qml new file mode 100644 index 0000000..663dfe1 --- /dev/null +++ b/Components/ScreenState.qml @@ -0,0 +1,37 @@ +import QtQuick +import Quickshell + +PersistentProperties { + required property ShellScreen modelData + + reloadableId: "screenState" + + // Drawer visibilities + property bool bar + property bool clipboard + property bool dashboard + property bool dock + property bool isDrawing + property bool launcher + property bool osd + property bool resources + property bool settings + property bool sidebar + + // Dashboard state + property int dashboardTab + property date dashboardDate: new Date() + + // Sidebar state + property int sidebarTab + property list expandedNotifs: [] + + // Utilities state + property string recordingConfirmDelete + property bool recordingListExpanded + property string recordingMode + + // Drawing state + property color penColor: "white" + property int penWidth: 4 +} diff --git a/Components/StateLayer.qml b/Components/StateLayer.qml index 88d5f0b..523f6e0 100644 --- a/Components/StateLayer.qml +++ b/Components/StateLayer.qml @@ -2,7 +2,7 @@ import QtQuick import QtQuick.Shapes import ZShell import ZShell.Config -import qs.Helpers +import qs.Services import qs.Services MouseArea { @@ -52,7 +52,7 @@ MouseArea { anchors.fill: parent cursorShape: !enabled ? undefined : Qt.PointingHandCursor - enabled: parent.enabled && !Visibilities.getForActive()?.isDrawing + enabled: parent.enabled && !ShellState.forActive()?.isDrawing hoverEnabled: true Behavior on stateOpacity { diff --git a/Daemons/NotifServer.qml b/Daemons/NotifServer.qml index 0f8f305..3360c8b 100644 --- a/Daemons/NotifServer.qml +++ b/Daemons/NotifServer.qml @@ -10,6 +10,7 @@ import ZShell import qs.Components import qs.Modules import qs.Helpers +import qs.Services import qs.Paths import ZShell.Config @@ -25,7 +26,7 @@ Singleton { property alias server: server function shouldThrottle(appName: string): bool { - if (props.dnd || [...Visibilities.screens.values()].some(v => v.sidebar)) + if (props.dnd || ShellState.anySidebarOpen()) return false; const key = (appName || "unknown").trim().toLowerCase(); diff --git a/Drawers/Drawing.qml b/Drawers/Drawing.qml index 9b45c31..ac27301 100644 --- a/Drawers/Drawing.qml +++ b/Drawers/Drawing.qml @@ -3,28 +3,23 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick import ZShell.Internal +import qs.Components Item { id: root readonly property alias content: contentLoader.item - readonly property PersistentProperties drawingState: PersistentProperties { - property color penColor: "white" - property int penWidth: 4 - - reloadableId: "drawingState" - } - required property PersistentProperties visibilities + required property ScreenState screenState Loader { id: contentLoader - active: root.visibilities.isDrawing + active: root.screenState.isDrawing anchors.fill: parent sourceComponent: StrokeCanvas { - penColor: root.drawingState.penColor - penWidth: root.drawingState.penWidth + penColor: root.screenState.penColor + penWidth: root.screenState.penWidth } } } diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index 85dd0da..a8a1bbc 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -19,7 +19,7 @@ Item { required property ShellScreen screen property bool singleGestureTriggered: false property bool utilitiesShortcutActive - required property PersistentProperties visibilities + required property ScreenState screenState function inBottomPanel(panel: Item, x: real, y: real): bool { const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); @@ -63,7 +63,7 @@ Item { cursorShape: (active && centroid.pressPosition.y < root.bar.implicitHeight) ? Qt.ClosedHandCursor : undefined dragThreshold: 0 - enabled: !root.visibilities.isDrawing + enabled: !root.screenState.isDrawing grabPermissions: PointerHandler.CanTakeOverFromHandlersOfSameType | PointerHandler.ApprovesTakeOverByAnything maximumPointCount: 1 minimumPointCount: 1 @@ -76,48 +76,60 @@ Item { onCentroidChanged: { const x = centroid.position.x; const y = centroid.position.y; - const dragX = x - centroid.pressPosition.x; - const dragY = y - centroid.pressPosition.y; + const pressX = centroid.pressPosition.x; + const pressY = centroid.pressPosition.y; + const dragX = x - pressX; + const dragY = y - pressY; - if (centroid.pressPosition.y >= root.screen.height - Config.bar.border && centroid.pressPosition.x > root.screen.width / 5 && dragY < -Config.launcher.dragThreshold) - root.visibilities.launcher = true; + if (pressY >= root.screen.height - Config.bar.border && pressX > root.screen.width / 5 && dragY < -Config.launcher.dragThreshold) + root.screenState.launcher = true; if (root.singleGestureTriggered) return; - if (root.geometry.insetTop(root.borderThickness) > centroid.pressPosition.y) { - if (dragY > 20) { - root.visibilities.settings = true; - root.singleGestureTriggered = true; - } else if (dragY < -20) { - root.visibilities.settings = false; - root.singleGestureTriggered = true; - } - } + // if (root.geometry.insetTop(root.borderThickness) > pressY) { + // if (dragY > 20) { + // root.screenState.settings = true; + // root.singleGestureTriggered = true; + // } else if (dragY < -20) { + // root.screenState.settings = false; + // root.singleGestureTriggered = true; + // } + // } - if (centroid.pressPosition.y > root.screen.height - root.geometry.insetBottom(root.borderThickness) && centroid.pressPosition.x < root.screen.width / 5 && dragY < -50) - root.visibilities.clipboard = true; + if (pressY > root.screen.height - root.geometry.insetBottom(root.borderThickness) && pressX < root.screen.width / 5 && dragY < -50) + root.screenState.clipboard = true; - if (!Config.dock.hoverToReveal && centroid.pressPosition.y > root.screen.height - root.geometry.insetTop(root.borderThickness) && centroid.pressPosition.x > root.screen.width / 5 && !root.visibilities.launcher) + if (!Config.dock.hoverToReveal && pressY > root.screen.height - root.geometry.insetTop(root.borderThickness) && pressX > root.screen.width / 5 && !root.screenState.launcher) if (dragY < -10) { - root.visibilities.dock = true; + root.screenState.dock = true; root.singleGestureTriggered = true; } - if (centroid.pressPosition.x > root.screen.width - Config.bar.border && centroid.pressPosition.y < (root.screen.height / 2) && dragX < -Config.sidebar.dragThreshold) { - root.visibilities.sidebar = true; + if (pressX > root.screen.width - Config.bar.border && pressY < (root.screen.height / 2) && dragX < -Config.sidebar.dragThreshold) { + root.screenState.sidebar = true; root.singleGestureTriggered = true; } - if (centroid.pressPosition.x >= root.screen.width - Config.bar.border && centroid.pressPosition.y > (root.screen.height / 2) && dragX < -20) { + if (pressX >= root.screen.width - Config.bar.border && pressY > (root.screen.height / 2) && dragX < -20) { Hypr.dispatch(`hl.dsp.focus({ workspace = 'r+1', on_current_monitor = true })`); root.singleGestureTriggered = true; } - if (centroid.pressPosition.x <= Config.bar.border && dragX > 20) { + if (pressX <= Config.bar.border && dragX > 20) { Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`); root.singleGestureTriggered = true; } + + if (root.inTopPanel(root.panels.dashboardWrapper, pressX, pressY) && root.withinPanelWidth(root.panels.dashboardWrapper, x, y)) { + if (dragY > Config.dashboard.dragThreshold) { + root.screenState.dashboard = true; + root.singleGestureTriggered = true; + } else if (dragY < -Config.dashboard.dragThreshold) { + root.screenState.dashboard = false; + root.singleGestureTriggered = true; + } + } } } @@ -127,7 +139,7 @@ Item { property bool setInitialPoint: false acceptedButtons: Qt.LeftButton | Qt.RightButton - enabled: root.visibilities.isDrawing && (!root.inLeftPanel(root.panels.drawing, hoverHandler.point.position.x, hoverHandler.point.position.y) || !root.panels.drawing.expanded) + enabled: root.screenState.isDrawing && (!root.inLeftPanel(root.panels.drawing, hoverHandler.point.position.x, hoverHandler.point.position.y) || !root.panels.drawing.expanded) onActiveChanged: { if (!active) { @@ -178,12 +190,12 @@ Item { HoverHandler { id: hoverHandler - cursorShape: root.visibilities.isDrawing && !root.inLeftPanel(root.panels.drawing, point.position.x, point.position.y) ? Qt.BlankCursor : undefined + cursorShape: root.screenState.isDrawing && !root.inLeftPanel(root.panels.drawing, point.position.x, point.position.y) ? Qt.BlankCursor : undefined onHoveredChanged: { if (!hovered) { if (!root.osdShortcutActive) { - root.visibilities.osd = false; + root.screenState.osd = false; root.panels.osd.hovered = false; } @@ -193,13 +205,16 @@ Item { if (Config.bar.autoHide || root.bar.fullscreen) root.bar.isHovered = false; + + if (root.screenState.dashboard && Config.dashboard.showOnHover) + root.screenState.dashboard = false; } } onPointChanged: { const x = point.position.x; const y = point.position.y; - if (root.visibilities.isDrawing) { + if (root.screenState.isDrawing) { if (root.inLeftPanel(root.panels.drawing, x, y) && !(drawingHandler.point.pressedButtons & Qt.LeftButton)) { root.panels.drawing.expand(); root.drawing.content.hideHover(); @@ -210,7 +225,7 @@ Item { return; } - if (!root.visibilities.bar && Config.bar.autoHide && root.geometry.barContains(x, y, true)) + if (!root.screenState.bar && Config.bar.autoHide && root.geometry.barContains(x, y, true)) root.bar.isHovered = true; if (root.bar.fullscreen && !root.bar.isHovered) @@ -228,7 +243,7 @@ Item { const showOsd = outOfSidebar && root.inRightPanel(root.panels.osdWrapper, x, y); if (!root.osdShortcutActive) { - root.visibilities.osd = showOsd; + root.screenState.osd = showOsd; root.panels.osd.hovered = showOsd; } else if (showOsd) { root.osdShortcutActive = false; @@ -236,8 +251,13 @@ Item { } } - if (Config.dock.enable && Config.dock.hoverToReveal && !root.visibilities.dock && !root.visibilities.launcher && root.inBottomPanel(root.panels.dock, x, y)) - root.visibilities.dock = true; + if (Config.dock.enable && Config.dock.hoverToReveal && !root.screenState.dock && !root.screenState.launcher && root.inBottomPanel(root.panels.dock, x, y)) + root.screenState.dock = true; + + const inDashboard = root.inTopPanel(root.panels.dashboardWrapper, x, y); + + if (Config.dashboard.showOnHover) + root.screenState.dashboard = inDashboard; if (root.geometry.barContains(x, y)) root.bar.checkPopout(root.geometry.axisPos(x, y)); @@ -246,16 +266,16 @@ Item { Connections { function onDashboardChanged() { - if (root.visibilities.dashboard) { + if (root.screenState.dashboard) { const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); if (!inDashboardArea) { root.dashboardShortcutActive = true; } if (root.panels.launcher.x + root.panels.launcher.width > root.panels.dashboardWrapper.x) - root.visibilities.launcher = false; + root.screenState.launcher = false; - root.visibilities.settings = false; - root.visibilities.sidebar = false; + root.screenState.settings = false; + root.screenState.sidebar = false; root.popouts.hasCurrent = false; } else { root.dashboardShortcutActive = false; @@ -263,12 +283,12 @@ Item { } function onIsDrawingChanged() { - if (!root.visibilities.isDrawing) + if (!root.screenState.isDrawing) root.drawing.clear(); } function onLauncherChanged() { - if (!root.visibilities.launcher) { + if (!root.screenState.launcher) { root.dashboardShortcutActive = false; root.osdShortcutActive = false; root.utilitiesShortcutActive = false; @@ -276,23 +296,23 @@ Item { const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); if (!inOsdArea) { - root.visibilities.osd = false; + root.screenState.osd = false; root.panels.osd.hovered = false; } } - if (root.visibilities.launcher) { + if (root.screenState.launcher) { if (root.panels.dashboardWrapper.x < root.panels.launcher.x + root.panels.launcher.width) { - root.visibilities.dashboard = false; + root.screenState.dashboard = false; } - root.visibilities.dock = false; - root.visibilities.settings = false; + root.screenState.dock = false; + root.screenState.settings = false; } } function onOsdChanged() { - if (root.visibilities.osd) { + if (root.screenState.osd) { const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); if (!inOsdArea) { root.osdShortcutActive = true; @@ -303,32 +323,32 @@ Item { } function onResourcesChanged() { - if (root.visibilities.resources && (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates"))) { + if (root.screenState.resources && (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates"))) { root.popouts.hasCurrent = false; } - if (root.visibilities.resources) - root.visibilities.settings = false; + if (root.screenState.resources) + root.screenState.settings = false; } function onSettingsChanged() { - if (root.visibilities.settings) { - root.visibilities.resources = false; - root.visibilities.dashboard = false; - root.visibilities.sidebar = false; + if (root.screenState.settings) { + root.screenState.resources = false; + root.screenState.dashboard = false; + root.screenState.sidebar = false; root.panels.popouts.hasCurrent = false; - root.visibilities.launcher = false; + root.screenState.launcher = false; } } function onSidebarChanged() { - if (root.visibilities.sidebar) { - root.visibilities.dashboard = false; - root.visibilities.settings = false; + if (root.screenState.sidebar) { + root.screenState.dashboard = false; + root.screenState.settings = false; root.popouts.hasCurrent = false; } } - target: root.visibilities + target: root.screenState } } diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 96ca9f8..18ecd64 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -42,7 +42,7 @@ Item { readonly property alias sidebar: sidebar readonly property alias toasts: toasts readonly property alias utilities: utilities - required property PersistentProperties visibilities + required property ScreenState screenState anchors.bottomMargin: geometry.insetBottom(borderThickness) anchors.fill: parent @@ -64,7 +64,7 @@ Item { id: resources position: root.geometry.position - visibilities: root.visibilities + screenState: root.screenState } } @@ -85,7 +85,7 @@ Item { anchors.verticalCenter: parent.verticalCenter screen: root.screen sidebarOrSessionVisible: sidebar.visible - visibilities: root.visibilities + screenState: root.screenState } } @@ -96,7 +96,7 @@ Item { anchors.verticalCenter: parent.verticalCenter drawing: root.drawingItem screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } Popouts.ClipWrapper { @@ -122,7 +122,7 @@ Item { anchors.top: parent.top panels: root sidebarPanel: sidebar - visibilities: root.visibilities + screenState: root.screenState } Launcher.Wrapper { @@ -132,7 +132,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter panels: root screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } Utils.Wrapper { @@ -142,7 +142,7 @@ Item { anchors.right: parent.right popouts: popouts sidebar: sidebar - visibilities: root.visibilities + screenState: root.screenState } Item { @@ -150,13 +150,11 @@ Item { property real offsetScale: dashboard.shouldBeActive ? 0 : 1 - anchors.bottom: root.geometry.barOnLeft || root.geometry.barOnBottom ? parent.bottom : undefined - anchors.left: root.geometry.barOnLeft ? parent.left : undefined - anchors.right: root.geometry.barOnLeft ? undefined : parent.right - anchors.top: root.geometry.barOnBottom || root.geometry.barOnLeft ? undefined : parent.top + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter clip: true - implicitHeight: root.geometry.horizontal ? dashboard.implicitHeight * (1 - offsetScale) : dashboard.implicitHeight - implicitWidth: root.geometry.horizontal ? dashboard.implicitWidth : dashboard.implicitWidth * (1 - offsetScale) + implicitHeight: dashboard.implicitHeight * (1 - offsetScale) + implicitWidth: dashboard.implicitWidth Behavior on offsetScale { Anim { @@ -169,8 +167,7 @@ Item { id: dashboard offsetScale: dashboardWrapper.offsetScale - position: root.geometry.position - visibilities: root.visibilities + screenState: root.screenState } } @@ -181,7 +178,7 @@ Item { anchors.right: parent.right anchors.top: notifications.bottom panels: root - visibilities: root.visibilities + screenState: root.screenState } Item { @@ -197,7 +194,7 @@ Item { anchors.verticalCenterOffset: (-implicitHeight - 5 - ((root.height - implicitHeight) / 2)) * offsetScale panels: root screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } @@ -208,7 +205,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter panels: root screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } Clipboard.Wrapper { @@ -217,6 +214,6 @@ Item { anchors.bottom: parent.bottom anchors.left: parent.left screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index bac1fbc..161027a 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -50,16 +50,17 @@ CustomWindow { readonly property alias menuRegion: regions.menuPopoutRegion readonly property HyprlandMonitor monitor: Hypr.monitorFor(screen) property var root: Quickshell.shellDir + readonly property ScreenState screenState: ShellState.forScreen(screen) readonly property real sdfBorderOffset: 2 * fsTransitionProg readonly property real shadowOpacity: 0.7 * (1 - (bar.isHovered ? 0 : fsTransitionProg)) property color surfaceColor: Colors.tPalette.m3surface WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: screenState.launcher || screenState.settings || screenState.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top color: "transparent" contentItem.focus: true - mask: visibilities.isDrawing ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions) + mask: screenState.isDrawing ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions) name: "Bar" Behavior on fsTransitionProg { @@ -73,26 +74,26 @@ CustomWindow { contentItem.Keys.onEscapePressed: { if (Config.bar.autoHide) - visibilities.bar = false; - visibilities.launcher = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - visibilities.dock = false; - visibilities.clipboard = false; + screenState.bar = false; + screenState.launcher = false; + screenState.sidebar = false; + screenState.dashboard = false; + screenState.osd = false; + screenState.settings = false; + screenState.resources = false; + screenState.dock = false; + screenState.clipboard = false; panels.popouts.hasCurrent = false; } onHasFullscreenChanged: { - visibilities.launcher = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - visibilities.clipboard = false; - visibilities.dock = false; + screenState.launcher = false; + screenState.sidebar = false; + screenState.dashboard = false; + screenState.osd = false; + screenState.settings = false; + screenState.resources = false; + screenState.clipboard = false; + screenState.dock = false; panels.popouts.hasCurrent = false; } @@ -144,44 +145,26 @@ CustomWindow { HyprlandFocusGrab { id: focusGrab - active: visibilities.dock || visibilities.resources || visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || visibilities.clipboard || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") && (!Config.bar.tray.showOnHover || (panels.popouts.current as StackView)?.depth > 1)) + active: root.screenState.dock || root.screenState.resources || root.screenState.launcher || root.screenState.sidebar || (!Config.dashboard.showOnHover && root.screenState.dashboard) || root.screenState.settings || root.screenState.clipboard || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") && (!Config.bar.tray.showOnHover || (panels.popouts.current as StackView)?.depth > 1)) windows: [root] onCleared: { - visibilities.launcher = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - visibilities.clipboard = false; - visibilities.dock = false; + root.screenState.launcher = false; + root.screenState.sidebar = false; + root.screenState.dashboard = false; + root.screenState.osd = false; + root.screenState.settings = false; + root.screenState.resources = false; + root.screenState.clipboard = false; + root.screenState.dock = false; panels.popouts.hasCurrent = false; } } - PersistentProperties { - id: visibilities - - property bool bar - property bool clipboard - property bool dashboard - property bool dock - property bool isDrawing - property bool launcher - property bool notif: NotifServer.popups.length > 0 - property bool osd - property bool resources - property bool settings - property bool sidebar - - Component.onCompleted: Visibilities.load(root.screen, this) - } - Binding { property: "bar" - target: visibilities - value: visibilities.sidebar || visibilities.dashboard || visibilities.osd || (!Config.bar.hideWhenNotif && visibilities.notif) || visibilities.resources || visibilities.settings || bar.isHovered + target: root.screenState + value: root.screenState.sidebar || root.screenState.dashboard || root.screenState.osd || (!Config.bar.hideWhenNotif && NotifServer.popups.length > 0) || root.screenState.resources || root.screenState.settings || bar.isHovered when: Config.bar.autoHide } @@ -348,7 +331,7 @@ CustomWindow { anchors.fill: parent layer.enabled: true - visibilities: visibilities + screenState: root.screenState z: 2 layer.effect: MultiEffect { @@ -389,7 +372,7 @@ CustomWindow { panels: panels popouts: panels.popouts screen: root.screen - visibilities: visibilities + screenState: root.screenState Panels { id: panels @@ -399,7 +382,7 @@ CustomWindow { drawingItem: drawing geometry: geometry screen: root.screen - visibilities: visibilities + screenState: root.screenState clipboard.transform: Matrix4x4 { matrix: clipboardBg.deformMatrix @@ -442,14 +425,14 @@ CustomWindow { anchors.bottom: geometry.barOnBottom ? parent.bottom : undefined anchors.left: parent.left anchors.top: geometry.barOnBottom ? undefined : parent.top - enabled: !visibilities.isDrawing + enabled: !screenState.isDrawing fullscreen: root.hasFullscreen height: geometry.horizontal ? implicitHeight : parent.height popouts: panels.popouts popoutsWrapper: panels.popoutsWrapper position: geometry.position screen: root.screen - visibilities: visibilities + screenState: root.screenState width: geometry.horizontal ? parent.width : implicitWidth } } diff --git a/Greeter/WeatherInfo.qml b/Greeter/WeatherInfo.qml index d7f0f1c..60cda3a 100644 --- a/Greeter/WeatherInfo.qml +++ b/Greeter/WeatherInfo.qml @@ -119,7 +119,8 @@ ColumnLayout { length: count }, () => null); - return forecast.slice(0, count); + const start = Math.max(0, Weather.currentHourIndex); + return forecast.slice(start, start + count); } ColumnLayout { diff --git a/Helpers/Strings.qml b/Helpers/Strings.qml index f1980b5..0e9061b 100644 --- a/Helpers/Strings.qml +++ b/Helpers/Strings.qml @@ -5,6 +5,15 @@ import Quickshell Singleton { property var _regexCache: ({}) + function percent(value: int): string { + // TRANSLATORS: %1 = a number + return qsTr("%1%").arg(value); + } + + function percentOne(value: real): string { + return percent(Math.round(value * 100)); + } + function testRegexList(filterList: list, target: string): bool { const regexChecker = /^\^.*\$$/; for (const filter of filterList) { diff --git a/Helpers/Visibilities.qml b/Helpers/Visibilities.qml deleted file mode 100644 index fe22047..0000000 --- a/Helpers/Visibilities.qml +++ /dev/null @@ -1,16 +0,0 @@ -pragma Singleton - -import Quickshell - -Singleton { - property var bars: new Map() - property var screens: new Map() - - function getForActive(): PersistentProperties { - return screens.get(Hypr.focusedMonitor); - } - - function load(screen: ShellScreen, visibilities: var): void { - screens.set(Hypr.monitorFor(screen), visibilities); - } -} diff --git a/Helpers/Weather.qml b/Helpers/Weather.qml index ecf5e80..0b9b535 100644 --- a/Helpers/Weather.qml +++ b/Helpers/Weather.qml @@ -3,25 +3,36 @@ pragma Singleton import QtQuick import Quickshell import ZShell +import ZShell.Internal import ZShell.Config Singleton { id: root readonly property var cachedCities: new Map() - property var cc - property string city readonly property string description: cc?.weatherDesc ?? qsTr("No weather") readonly property string feelsLike: formatTemp(cc?.feelsLikeC) - property list forecast - property list hourlyForecast readonly property int humidity: cc?.humidity ?? 0 readonly property string icon: cc ? Icons.getWeatherIcon(cc.weatherCode) : "cloud_alert" - property string loc readonly property string sunrise: cc ? Qt.formatDateTime(new Date(cc.sunrise), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" readonly property string sunset: cc ? Qt.formatDateTime(new Date(cc.sunset), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" readonly property string temp: formatTemp(cc?.tempC) readonly property real windSpeed: cc?.windSpeed ?? 0 + readonly property CircularBuffer precipitationForecast: precipitationBuffer + + property var cc + property string city + property list forecast + property list hourlyForecast + readonly property int currentHourIndex: { + const now = new Date(); + for (let i = 0; i < hourlyForecast.length; i++) { + if (new Date(hourlyForecast[i].timestamp.replace("T", " ")) >= now) + return i; + } + return -1; + } + property string loc function fetchCityFromCoords(coords: string): void { if (cachedCities.has(coords)) { @@ -112,11 +123,12 @@ Singleton { forecast = forecastList; const hourlyList = []; - const now = new Date(); + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); for (let i = 0; i < json.hourly.time.length; i++) { const time = new Date(json.hourly.time[i].replace("T", " ")); - if (time < now) + if (time < startOfToday) continue; hourlyList.push({ @@ -129,6 +141,10 @@ Singleton { }); } hourlyForecast = hourlyList; + precipitationBuffer.clear(); + + for (const hour of hourlyList) + precipitationBuffer.push(hour.precipChance); }); } @@ -258,6 +274,11 @@ Singleton { onLocChanged: fetchWeatherData() + CircularBuffer { + id: precipitationBuffer + capacity: 24 + } + Connections { function onWeatherLocationChanged(): void { root.reload(); diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index fe33739..f0f5fb0 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -4,6 +4,7 @@ import Quickshell import QtQuick import QtQuick.Layouts import ZShell.Config +import qs.Components import qs.Modules.Bar.Components import qs.Modules.Bar.Popouts @@ -17,7 +18,7 @@ GridLayout { required property ClipWrapper popoutsWrapper required property ShellScreen screen readonly property int vPadding: 6 - required property PersistentProperties visibilities + required property ScreenState screenState function axisCenterOf(item: Item): real { const c = item.mapToItem(root, item.implicitWidth / 2, item.implicitHeight / 2); @@ -34,7 +35,7 @@ GridLayout { return; } - if (visibilities.sidebar || visibilities.dashboard || visibilities.resources || visibilities.settings) + if (screenState.sidebar || screenState.dashboard || screenState.resources || screenState.settings) return; if (id === "statusIcons" && Config.bar.popouts.statusIcons) { @@ -149,7 +150,7 @@ GridLayout { delegate: EntryWrapper { Resources { horizontal: root.horizontal - visibilities: root.visibilities + screenState: root.screenState visible: !root.fullscreen || root.isHovered } } @@ -173,7 +174,7 @@ GridLayout { NotifBell { horizontal: root.horizontal popouts: root.popouts - visibilities: root.visibilities + screenState: root.screenState visible: !root.fullscreen || root.isHovered } } @@ -187,7 +188,7 @@ GridLayout { horizontal: root.horizontal loader: root popouts: root.popouts - visibilities: root.visibilities + screenState: root.screenState visible: !root.fullscreen || root.isHovered } } diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index 5fd3a70..41e52fb 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -21,9 +21,9 @@ Item { required property ClipWrapper popoutsWrapper required property string position required property ShellScreen screen - readonly property bool shouldBeVisible: (!fullscreen && (!Config.bar.autoHide || visibilities.bar)) || isHovered + readonly property bool shouldBeVisible: (!fullscreen && (!Config.bar.autoHide || screenState.bar)) || isHovered readonly property int vPadding: 6 - required property PersistentProperties visibilities + required property ScreenState screenState function checkPopout(pos: real): void { (content.item as Bar)?.checkPopout(pos); @@ -85,7 +85,7 @@ Item { popouts: root.popouts popoutsWrapper: root.popoutsWrapper screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Bar/Border.qml b/Modules/Bar/Border.qml index c67fa45..95503f6 100644 --- a/Modules/Bar/Border.qml +++ b/Modules/Bar/Border.qml @@ -11,7 +11,7 @@ Item { id: root required property Item bar - required property PersistentProperties visibilities + required property ScreenState screenState anchors.fill: parent diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index e7de27d..7940c1d 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -13,7 +13,7 @@ import qs.Services WidgetBase { id: root - readonly property color contentColor: visibilities.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + readonly property color contentColor: screenState.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface readonly property string effectiveFormat: { let fmt = Config.general.dateFormat; if (!root.horizontal) @@ -44,10 +44,10 @@ WidgetBase { } required property GridLayout loader required property Wrapper popouts - readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.small * 2 : verticalColumn.implicitHeight + Tokens.padding.small * 2 - required property PersistentProperties visibilities + readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2 + required property ScreenState screenState - color: visibilities.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer + color: screenState.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer implicitHeight: horizontal ? baseSize : size implicitWidth: horizontal ? size : baseSize radius: Tokens.rounding.full @@ -151,7 +151,7 @@ WidgetBase { color: root.contentColor onClicked: { - root.visibilities.dashboard = !root.visibilities.dashboard; + root.screenState.dashboard = !root.screenState.dashboard; } } } diff --git a/Modules/Bar/Components/NotifBell.qml b/Modules/Bar/Components/NotifBell.qml index c255deb..34b4c68 100644 --- a/Modules/Bar/Components/NotifBell.qml +++ b/Modules/Bar/Components/NotifBell.qml @@ -11,17 +11,17 @@ WidgetBase { id: root required property Wrapper popouts - required property PersistentProperties visibilities + required property ScreenState screenState - color: visibilities.sidebar ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer + color: screenState.sidebar ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer radius: Tokens.rounding.full MaterialIcon { id: notificationCenterIcon anchors.centerIn: parent - color: root.visibilities.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - fill: root.visibilities.sidebar ? 1 : 0 + color: root.screenState.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + fill: root.screenState.sidebar ? 1 : 0 font.pointSize: Tokens.font.size.larger text: NotifServer.list.length ? "\uf4fe" : "\ue7f4" @@ -36,10 +36,10 @@ WidgetBase { } StateLayer { - color: root.visibilities.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: root.screenState.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface onClicked: { - root.visibilities.sidebar = !root.visibilities.sidebar; + root.screenState.sidebar = !root.screenState.sidebar; } } } diff --git a/Modules/Bar/Components/Resources.qml b/Modules/Bar/Components/Resources.qml index 56cb3d9..32bd1f2 100644 --- a/Modules/Bar/Components/Resources.qml +++ b/Modules/Bar/Components/Resources.qml @@ -12,18 +12,18 @@ import qs.Components WidgetBase { id: root - required property PersistentProperties visibilities + required property ScreenState screenState clip: true - color: visibilities.resources ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer + color: screenState.resources ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer implicitHeight: horizontal ? baseSize : gridLayout.implicitHeight + Tokens.padding.medium * 2 implicitWidth: horizontal ? gridLayout.implicitWidth + Tokens.padding.medium * 2 : baseSize radius: Tokens.rounding.full StateLayer { - color: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - onClicked: root.visibilities.resources = !root.visibilities.resources + onClicked: root.screenState.resources = !root.screenState.resources } GridLayout { @@ -53,8 +53,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "memory" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary percentage: Cpu.percentage warningThreshold: 95 } @@ -64,8 +64,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "memory_alt" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3secondary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3secondary percentage: Memory.percentage warningThreshold: 80 } @@ -75,8 +75,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "gamepad" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3tertiary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3tertiary percentage: Gpu.percentage } @@ -85,8 +85,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "developer_board" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary percentage: Gpu.memoryUsed / Gpu.memoryTotal } } diff --git a/Modules/Bar/Components/Tray/TrayItem.qml b/Modules/Bar/Components/Tray/TrayItem.qml index d7b8fc1..9a1e228 100644 --- a/Modules/Bar/Components/Tray/TrayItem.qml +++ b/Modules/Bar/Components/Tray/TrayItem.qml @@ -62,10 +62,10 @@ Item { root.popouts.currentName = `traymenu${root.ind}`; root.popouts.currentCenter = Qt.binding(() => root.mapToItem(root.loader, root.implicitWidth / 2, 0).x); root.popouts.hasCurrent = true; - if (visibilities.sidebar || visibilities.dashboard || visibilities.settings) { - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.settings = false; + if (screenState.sidebar || screenState.dashboard || screenState.settings) { + screenState.sidebar = false; + screenState.dashboard = false; + screenState.settings = false; } } } diff --git a/Modules/Clipboard/Content.qml b/Modules/Clipboard/Content.qml index 4be55ea..dd5d6a3 100644 --- a/Modules/Clipboard/Content.qml +++ b/Modules/Clipboard/Content.qml @@ -14,7 +14,7 @@ Item { readonly property int itemHeight: Config.clipboard.sizes.itemHeight required property ShellScreen screen - required property PersistentProperties visibilities + required property ScreenState screenState implicitHeight: search.implicitHeight + entries.anchors.topMargin + ((view.spacing + itemHeight) * Config.clipboard.maxEntriesShown) - view.spacing implicitWidth: Config.clipboard.sizes.width + preview.width + preview.anchors.leftMargin @@ -49,7 +49,7 @@ Item { Keys.onUpPressed: view.decrementCurrentIndex() onAccepted: { ClipHistory.copy(view.currentItem.modelData); - root.visibilities.clipboard = false; + root.screenState.clipboard = false; } } } diff --git a/Modules/Clipboard/Wrapper.qml b/Modules/Clipboard/Wrapper.qml index 1a0a8f7..cfd60a3 100644 --- a/Modules/Clipboard/Wrapper.qml +++ b/Modules/Clipboard/Wrapper.qml @@ -11,8 +11,8 @@ Item { property int contentHeight property real offsetScale: shouldBeActive ? 0 : 1 required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.clipboard && Config.clipboard.enabled - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.clipboard && Config.clipboard.enabled + required property ScreenState screenState anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight + Tokens.padding.small * 2 @@ -35,7 +35,7 @@ Item { sourceComponent: Content { screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index 13e8e49..20c418a 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -5,78 +5,177 @@ import Quickshell.Widgets import QtQuick import QtQuick.Layouts import ZShell.Config +import ZShell import qs.Components Item { id: root - required property PersistentProperties dashState - readonly property real nonAnimHeight: view.implicitHeight + viewWrapper.anchors.margins * 2 readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 - required property PersistentProperties visibilities + readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins * 2 + + required property ScreenState screenState + + readonly property var dashboardTabs: { + const allTabs = [ + { + component: dashComponent, + iconName: "dashboard", + text: qsTr("Dashboard"), + enabled: true + }, + { + component: weatherComponent, + iconName: "cloud", + text: qsTr("Weather"), + enabled: true + }, + { + component: mediaComponent, + iconName: "queue_music", + text: qsTr("Media"), + enabled: true + }, + // { + // component: testingComponent, + // iconName: "speed", + // text: qsTr("Testing"), + // enabled: true + // }, + ]; + return allTabs.filter(tab => tab.enabled); + } - implicitHeight: nonAnimHeight implicitWidth: nonAnimWidth + implicitHeight: nonAnimHeight Behavior on implicitHeight { - Anim { - } + Anim {} } Behavior on implicitWidth { - Anim { - } + Anim {} + } + + Tabs { + id: tabs + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins) + anchors.margins: Tokens.padding.large + + nonAnimWidth: root.nonAnimWidth - anchors.margins * 2 + screenState: root.screenState + tabs: root.dashboardTabs } ClippingRectangle { id: viewWrapper - anchors.bottom: parent.bottom + anchors.top: tabs.bottom anchors.left: parent.left - anchors.margins: Tokens.padding.small anchors.right: parent.right - anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: Tokens.padding.small color: "transparent" radius: Tokens.rounding.extraLarge - anchors.margins - Item { + Flickable { id: view - readonly property int currentIndex: root.dashState?.currentTab - readonly property Item currentItem: row.children[currentIndex] + readonly property int currentIndex: root.screenState.dashboardTab + readonly property Item currentItem: { + repeater.count; + return repeater.itemAt(currentIndex); + } anchors.fill: parent - implicitHeight: currentItem.implicitHeight - implicitWidth: currentItem.implicitWidth + + implicitWidth: currentItem?.implicitWidth ?? 0 + implicitHeight: currentItem?.implicitHeight ?? 0 + + contentX: currentItem?.x ?? 0 + contentWidth: row.implicitWidth + contentHeight: row.implicitHeight + + interactive: false + + onContentXChanged: { + if (!moving || !currentItem) + return; + + const x = contentX - currentItem.x; + if (x > currentItem.implicitWidth / 2) + root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1); + else if (x < -currentItem.implicitWidth / 2) + root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); + } RowLayout { id: row - Pane { - index: 0 + Repeater { + id: repeater - sourceComponent: Dash { - dashState: root.dashState - visibilities: root.visibilities + model: ScriptModel { + values: root.dashboardTabs } + + delegate: Loader { + id: paneLoader + + required property int index + required property var modelData + + Layout.alignment: Qt.AlignTop + + sourceComponent: modelData.component + + Component.onCompleted: active = Qt.binding(() => { + if (index === view.currentIndex) + return true; + const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); + const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); + return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); + }) + } + } + } + + Component { + id: dashComponent + + Dash { + screenState: root.screenState + } + } + + Component { + id: weatherComponent + + WeatherTab {} + } + + Component { + id: mediaComponent + + Media { + screenState: root.screenState + } + } + + // Component { + // id: testingComponent + // + // WeatherTest {} + // } + + Behavior on contentX { + Anim { + type: Anim.DefaultEffects } } } } - - component Pane: Loader { - id: pane - - required property int index - - Layout.alignment: Qt.AlignTop - - Component.onCompleted: active = Qt.binding(() => { - // Always keep current tab loaded - if (pane.index === view.currentIndex) - return true; - const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); - const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); - return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); - }) - } } diff --git a/Modules/Dashboard/Dash.qml b/Modules/Dashboard/Dash.qml index bf7bc2a..54d6e51 100644 --- a/Modules/Dashboard/Dash.qml +++ b/Modules/Dashboard/Dash.qml @@ -11,10 +11,9 @@ import qs.Services GridLayout { id: root - required property PersistentProperties dashState - readonly property bool dashboardVisible: visibilities.dashboard + readonly property bool dashboardVisible: screenState.dashboard property int radius: Tokens.rounding.medium - required property PersistentProperties visibilities + required property ScreenState screenState columnSpacing: Tokens.spacing.small rowSpacing: Tokens.spacing.small @@ -61,7 +60,7 @@ GridLayout { User { id: user - dashState: root.dashState + screenState: root.screenState } } @@ -72,8 +71,7 @@ GridLayout { Layout.row: 0 radius: root.radius - Weather { - } + Weather {} } Rect { @@ -87,7 +85,7 @@ GridLayout { Calendar { id: calendar - dashState: root.dashState + screenState: root.screenState } } @@ -104,20 +102,6 @@ GridLayout { } } - CustomClippingRect { - Layout.column: 0 - Layout.columnSpan: 5 - Layout.fillWidth: true - Layout.preferredHeight: media.implicitHeight - Layout.row: 2 - color: Colors.tPalette.m3surfaceContainer - radius: root.radius - - Media { - id: media - } - } - component Rect: CustomRect { color: Colors.tPalette.m3surfaceContainer } diff --git a/Modules/Dashboard/Dash/Calendar.qml b/Modules/Dashboard/Dash/Calendar.qml index 8161957..9d198ef 100644 --- a/Modules/Dashboard/Dash/Calendar.qml +++ b/Modules/Dashboard/Dash/Calendar.qml @@ -12,12 +12,12 @@ CustomMouseArea { id: root property int activeGrid: 1 - required property PersistentProperties dashState + required property ScreenState screenState property bool initialized: false property int month1 property int month2 - readonly property int realCurrMonth: dashState.currentDate.getMonth() - readonly property int realCurrYear: dashState.currentDate.getFullYear() + readonly property int realCurrMonth: screenState.dashboardDate.getMonth() + readonly property int realCurrYear: screenState.dashboardDate.getFullYear() property int year1 property int year2 @@ -43,9 +43,9 @@ CustomMouseArea { function onWheel(event: WheelEvent): void { if (event.angleDelta.y > 0) - root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1); + root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1); else if (event.angleDelta.y < 0) - root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1); + root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1); } acceptedButtons: Qt.MiddleButton @@ -61,7 +61,7 @@ CustomMouseArea { initialized = true; } - onClicked: root.dashState.currentDate = new Date() + onClicked: root.screenState.dashboardDate = new Date() onRealCurrMonthChanged: handleDateChange() onRealCurrYearChanged: handleDateChange() @@ -135,7 +135,7 @@ CustomMouseArea { padding: Tokens.padding.extraSmall type: IconButton.Text - onClicked: root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1) + onClicked: root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1) } Item { @@ -158,7 +158,7 @@ CustomMouseArea { } } - onClicked: root.dashState.currentDate = new Date() + onClicked: root.screenState.dashboardDate = new Date() } Item { @@ -206,7 +206,7 @@ CustomMouseArea { padding: Tokens.padding.extraSmall type: IconButton.Text - onClicked: root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1) + onClicked: root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1) } } diff --git a/Modules/Dashboard/Dash/Media.qml b/Modules/Dashboard/Dash/Media.qml deleted file mode 100644 index 53cda5a..0000000 --- a/Modules/Dashboard/Dash/Media.qml +++ /dev/null @@ -1,329 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import Quickshell.Services.Mpris -import QtQuick -import QtQuick.Layouts -import QtQuick.Shapes -import QtQuick.Effects -import ZShell.Components -import ZShell.Services -import ZShell.Config -import qs.Modules.Dashboard.Dash.Media -import qs.Daemons -import qs.Components -import qs.Helpers -import qs.Services - -Item { - id: root - readonly property bool hasUnknownLength: (Players.active?.length ?? 0) > 2147483647 - property real playerProgress: { - const active = Players.active; - return active?.length ? (active.position % active.length) / active.length : 0; - } - property int rowHeight: Tokens.padding.large + Config.dashboard.sizes.mediaProgressThickness + Tokens.spacing.small - - anchors.left: parent.left - anchors.right: parent.right - implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 - function lengthStr(length: int): string { - if (length < 0) - return "-1:-1"; - - const hours = Math.floor(length / 3600); - const mins = Math.floor((length % 3600) / 60); - const secs = Math.floor(length % 60).toString().padStart(2, "0"); - - if (hours > 0) - return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; - return `${mins}:${secs}`; - } - - Behavior on playerProgress { - Anim { - duration: Tokens.anim.durations.large - } - } - - Timer { - running: Players.active?.isPlaying ?? false - interval: Config.dashboard.mediaUpdateInterval - triggeredOnStart: true - repeat: true - onTriggered: Players.active?.positionChanged() - } - - ServiceRef { - service: Audio.cava - } - - BackgroundShapes { - anchors.fill: parent - } - - Shape { - id: visualizer - - readonly property int bars: Config.services.visualizerBars - property color color: Qt.alpha(Colors.palette.m3primary, 0.5) - property color fillColor: Qt.alpha(color, 0.1) - - anchors.fill: parent - anchors.leftMargin: -(shape.strokeWidth / 2) - layer.enabled: true - asynchronous: true - preferredRendererType: Shape.CurveRenderer - - ShapePath { - id: shape - strokeColor: visualizer.color - fillColor: visualizer.fillColor - strokeWidth: 2 - - PathSvg { - path: shape.curvePath - } - - property string curvePath: { - const values = Audio.cava.values; - const n = values.length; - - if (n < 2) - return ""; - - const h = root.height + shape.strokeWidth / 2; - const w = root.width + shape.strokeWidth; - - function x(i) { - return i * w / (n - 1); - } - - function y(i) { - return h - (values[i] * Config.dashboard.sizes.mediaVisualizerSize) / 2; - } - - let d = `M 0 ${h} `; - d += `L ${x(0)} ${y(0)} `; - - for (let i = 0; i < n - 1; ++i) { - const x0 = x(i); - const y0 = y(i); - - const x1 = x(i + 1); - const y1 = y(i + 1); - - const prev = Math.max(0, i - 1); - const next = Math.min(n - 1, i + 2); - - const c1x = x0 + (x1 - x(prev)) / 6; - const c1y = y0 + (y1 - y(prev)) / 6; - - const c2x = x1 - (x(next) - x0) / 6; - const c2y = y1 - (y(next) - y0) / 6; - - d += `C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x1} ${y1} `; - } - - d += `L ${w} ${h} `; - d += `L 0 ${h} Z`; - - return d; - } - } - } - - CustomRect { - anchors.fill: visualizer - - color: Qt.alpha(Colors.palette.m3shadow, 0.2) - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: visualizer - } - } - - RowLayout { - id: layout - - anchors.fill: parent - anchors.margins: Tokens.padding.large - anchors.leftMargin: Tokens.padding.extraLarge - - CoverVisualizer { - Layout.fillHeight: true - implicitWidth: Config.dashboard.sizes.mediaCoverArtSize - } - - ColumnLayout { - Layout.fillWidth: true - Layout.leftMargin: Tokens.spacing.largeIncreased - Layout.rightMargin: Tokens.spacing.largeIncreased - - spacing: Tokens.spacing.small - - MarqueeText { - id: title - - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.normal - horizontalAlignment: Text.AlignHCenter - pauseMs: 4000 - text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") - implicitWidth: parent.width - Tokens.padding.large * 4 - } - - MarqueeText { - id: album - - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small - horizontalAlignment: Text.AlignHCenter - pauseMs: 4000 - text: (Players.active?.trackAlbum ?? qsTr("No media")) || qsTr("Unknown album") - implicitWidth: parent.width - Tokens.padding.large * 4 - } - - MarqueeText { - id: artist - - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - color: Colors.palette.m3secondary - horizontalAlignment: Text.AlignHCenter - pauseMs: 4000 - text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist") - implicitWidth: parent.width - Tokens.padding.large * 4 - } - - RowLayout { - id: positionSliderLayout - Layout.topMargin: Tokens.spacing.large - Layout.fillWidth: true - spacing: Tokens.spacing.small - - TextMetrics { - id: timeMetrics - - text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00" - } - - CustomText { - id: positionLabel - - Layout.preferredWidth: timeMetrics.width - text: root.lengthStr(Players.active?.position ?? -1) - color: Colors.palette.m3onSurfaceVariant - font: timeMetrics.font - horizontalAlignment: Text.AlignHCenter - } - - CustomSlider { - id: positionSlider - - Layout.fillWidth: true - value: Players.active ? Players.active.position / (Players.active.length || 1) : 0 - enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength - wavy: true - animateWave: Players.active?.isPlaying ?? false - waveFrequency: 5 - waveDuration: 2000 - interactionOnMove: false - onInteraction: value => { - const active = Players.active; - if (active?.canSeek && active?.positionSupported) - active.position = value * active.length; - } - Binding { - target: positionLabel - property: "text" - value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0)) - when: positionSlider.dragging - } - } - - CustomText { - Layout.preferredWidth: timeMetrics.width - text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1) - color: Colors.palette.m3onSurfaceVariant - font: timeMetrics.font - horizontalAlignment: Text.AlignHCenter - } - } - ButtonRow { - Layout.topMargin: Tokens.spacing.largeIncreased - Layout.fillWidth: true - spacing: Tokens.spacing.extraSmall - Layout.leftMargin: positionSliderLayout.implicitWidth / 4 - Layout.rightMargin: positionSliderLayout.implicitWidth / 4 - - IconButton { - type: IconButton.Tonal - icon: "shuffle" - isRound: true - shapeMorph: true - checked: Players.active?.shuffle ?? false - enabled: Players.active?.shuffleSupported - onClicked: Players.active.shuffle = !Players.active?.shuffle - } - - IconButton { - id: previousBtn - - type: IconButton.Tonal - icon: "skip_previous" - isRound: true - shapeMorph: true - enabled: Players.active?.canGoPrevious - onClicked: Players.active?.previous() - font.pointSize: Tokens.font.size.large - } - - IconButton { - id: playPauseBtn - - icon: Players.active?.isPlaying ? "pause" : "play_arrow" - isRound: true - shapeMorph: true - fillWidth: true - checked: Players.active?.isPlaying ?? false - enabled: Players.active?.canTogglePlaying - onClicked: Players.active?.togglePlaying() - font.pointSize: Tokens.font.size.large - } - - IconButton { - id: nextBtn - - type: IconButton.Tonal - icon: "skip_next" - isRound: true - shapeMorph: true - enabled: Players.active?.canGoNext - onClicked: Players.active?.next() - font.pointSize: Tokens.font.size.large - } - - IconButton { - type: IconButton.Tonal - icon: Players.active?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat" - isRound: true - shapeMorph: true - checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist - enabled: Players.active?.loopSupported - onClicked: { - const state = Players.active.loopState; - if (state === MprisLoopState.None) - Players.active.loopState = MprisLoopState.Track; - else if (state === MprisLoopState.Track) - Players.active.loopState = MprisLoopState.Playlist; - else - Players.active.loopState = MprisLoopState.None; - } - } - } - } - } -} diff --git a/Modules/Dashboard/Dash/User.qml b/Modules/Dashboard/Dash/User.qml index 0c6e0c8..1d37230 100644 --- a/Modules/Dashboard/Dash/User.qml +++ b/Modules/Dashboard/Dash/User.qml @@ -9,7 +9,7 @@ import qs.Helpers Row { id: root - required property PersistentProperties dashState + required property ScreenState screenState padding: 20 spacing: 12 diff --git a/Modules/Dashboard/Media.qml b/Modules/Dashboard/Media.qml new file mode 100644 index 0000000..f1f98ee --- /dev/null +++ b/Modules/Dashboard/Media.qml @@ -0,0 +1,152 @@ +import QtQuick +import QtQuick.Layouts +import M3Shapes +import ZShell.Config +import qs.Modules.Dashboard.Media +import qs.Components +import qs.Services +import qs.Helpers + +Item { + id: root + + property ScreenState screenState + + implicitWidth: 1000 + implicitHeight: 320 + + BackgroundShapes { + anchors.fill: parent + } + + RowLayout { + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.extraLarge + + CoverVisualizer { + Layout.fillHeight: true + implicitWidth: 250 + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + state: Players.active ? "" : "noMedia" + + states: State { + name: "noMedia" + + PropertyChanges { + noMedia.opacity: 1 + content.opacity: 0 + } + } + + transitions: [ + Transition { + from: "" + + SequentialAnimation { + Anim { + target: content + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + target: noMedia + property: "opacity" + type: Anim.SlowEffects + } + } + }, + Transition { + to: "" + + SequentialAnimation { + Anim { + target: noMedia + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + target: content + property: "opacity" + type: Anim.SlowEffects + } + } + } + ] + + Loader { + id: noMedia + + anchors.centerIn: parent + anchors.horizontalCenterOffset: -Tokens.padding.extraLarge * 2 + asynchronous: true + active: opacity > 0 + opacity: 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.small + + MaterialShape { + Layout.topMargin: (pathBounds().height - implicitSize) / 2 + Layout.bottomMargin: (pathBounds().height - implicitSize) / 2 + Tokens.spacing.small + Layout.alignment: Qt.AlignHCenter + color: Colors.palette.m3primaryContainer + implicitSize: icon.implicitHeight + Tokens.padding.extraLarge * 2 + shape: MaterialShape.ClamShell + + Behavior on color { + CAnim {} + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: "queue_music" + font.pointSize: Tokens.font.size.large + color: Colors.palette.m3onPrimaryContainer + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Nothing playing") + font.pointSize: Tokens.font.size.normal + } + + CustomText { + text: qsTr("Play something for it to show up here!") + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.large + } + } + } + + Loader { + id: content + + anchors.fill: parent + asynchronous: true + active: opacity > 0 + + sourceComponent: RowLayout { + spacing: Tokens.spacing.extraLarge + + Details { + Layout.fillWidth: true + } + + LyricsAndSelector { + Layout.fillHeight: true + implicitWidth: 300 + } + } + } + } + } +} diff --git a/Modules/Dashboard/Dash/Media/BackgroundShapes.qml b/Modules/Dashboard/Media/BackgroundShapes.qml similarity index 100% rename from Modules/Dashboard/Dash/Media/BackgroundShapes.qml rename to Modules/Dashboard/Media/BackgroundShapes.qml diff --git a/Modules/Dashboard/Dash/Media/CoverArt.qml b/Modules/Dashboard/Media/CoverArt.qml similarity index 100% rename from Modules/Dashboard/Dash/Media/CoverArt.qml rename to Modules/Dashboard/Media/CoverArt.qml diff --git a/Modules/Dashboard/Dash/Media/CoverVisualizer.qml b/Modules/Dashboard/Media/CoverVisualizer.qml similarity index 63% rename from Modules/Dashboard/Dash/Media/CoverVisualizer.qml rename to Modules/Dashboard/Media/CoverVisualizer.qml index b7476a0..171db11 100644 --- a/Modules/Dashboard/Dash/Media/CoverVisualizer.qml +++ b/Modules/Dashboard/Media/CoverVisualizer.qml @@ -11,8 +11,8 @@ Item { id: cover anchors.centerIn: parent - implicitHeight: Config.dashboard.sizes.mediaCoverArtSize - implicitWidth: Config.dashboard.sizes.mediaCoverArtSize + implicitHeight: 200 + implicitWidth: 200 shape.shape: MaterialShape.Cookie9Sided } } diff --git a/Modules/Dashboard/Media/Details.qml b/Modules/Dashboard/Media/Details.qml new file mode 100644 index 0000000..39ba295 --- /dev/null +++ b/Modules/Dashboard/Media/Details.qml @@ -0,0 +1,194 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Mpris +import ZShell.Components +import ZShell.Config +import qs.Components +import qs.Services +import qs.Helpers + +ColumnLayout { + id: root + + readonly property bool hasUnknownLength: (Players.active?.length ?? 0) > 2147483647 + + function lengthStr(length: int): string { + if (length < 0) + return "-1:-1"; + + const hours = Math.floor(length / 3600); + const mins = Math.floor((length % 3600) / 60); + const secs = Math.floor(length % 60).toString().padStart(2, "0"); + + if (hours > 0) + return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; + return `${mins}:${secs}`; + } + + spacing: Tokens.spacing.extraSmall + + Timer { + running: Players.active?.isPlaying ?? false + interval: Config.dashboard.mediaUpdateInterval + triggeredOnStart: true + repeat: true + onTriggered: Players.active?.positionChanged() + } + + CustomText { + Layout.fillWidth: true + text: Players.active?.trackTitle ?? "" + font.pointSize: Tokens.font.size.large + elide: Text.ElideRight + animate: true + } + + CustomText { + Layout.fillWidth: true + text: Players.active?.trackArtist || qsTr("Unknown artist") + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + elide: Text.ElideRight + animate: true + } + + CustomText { + Layout.fillWidth: true + text: Players.active?.trackAlbum || qsTr("Unknown album") + color: Colors.palette.m3secondary + font.pointSize: Tokens.font.size.normal + elide: Text.ElideRight + animate: true + } + + RowLayout { + Layout.topMargin: Tokens.spacing.extraLargeIncreased + Layout.fillWidth: true + spacing: Tokens.spacing.small + + TextMetrics { + id: timeMetrics + + text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00" + font.pointSize: Tokens.font.size.normal + } + + CustomText { + id: positionLabel + + Layout.preferredWidth: timeMetrics.width + text: root.lengthStr(Players.active?.position ?? -1) + color: Colors.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + } + + CustomSlider { + id: positionSlider + + Layout.fillWidth: true + value: Players.active ? Players.active.position / (Players.active.length || 1) : 0 + enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength + wavy: true + animateWave: Players.active?.isPlaying ?? false + waveFrequency: 5 + waveDuration: 2000 + interactionOnMove: false + onInteraction: value => { + const active = Players.active; + if (active?.canSeek && active?.positionSupported) + active.position = value * active.length; + } + + Binding { + target: positionLabel + property: "text" + value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0)) + when: positionSlider.dragging + } + } + + CustomText { + Layout.preferredWidth: timeMetrics.width + text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1) + color: Colors.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + } + } + + ButtonRow { + Layout.topMargin: Tokens.spacing.largeIncreased + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + IconButton { + type: IconButton.Tonal + icon: "shuffle" + isRound: true + shapeMorph: true + checked: Players.active?.shuffle ?? false + font.pointSize: Tokens.font.size.large + enabled: Players.active?.shuffleSupported + onClicked: Players.active.shuffle = !Players.active?.shuffle + implicitWidth: Math.round(implicitHeight * 0.9) + } + + IconButton { + id: previousBtn + + type: IconButton.Tonal + icon: "skip_previous" + isRound: true + shapeMorph: true + font.pointSize: Tokens.font.size.extraLarge + enabled: Players.active?.canGoPrevious + onClicked: Players.active?.previous() + } + + IconButton { + id: playPauseBtn + + icon: Players.active?.isPlaying ? "pause" : "play_arrow" + isRound: true + shapeMorph: true + fillWidth: true + checked: Players.active?.isPlaying ?? false + font.pointSize: Tokens.font.size.extraLarge + enabled: Players.active?.canTogglePlaying + onClicked: Players.active?.togglePlaying() + } + + IconButton { + id: nextBtn + + type: IconButton.Tonal + icon: "skip_next" + isRound: true + shapeMorph: true + font.pointSize: Tokens.font.size.extraLarge + enabled: Players.active?.canGoNext + onClicked: Players.active?.next() + } + + IconButton { + type: IconButton.Tonal + icon: Players.active?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat" + isRound: true + shapeMorph: true + checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist + font.pointSize: Tokens.font.size.large + enabled: Players.active?.loopSupported + onClicked: { + const state = Players.active.loopState; + if (state === MprisLoopState.None) + Players.active.loopState = MprisLoopState.Track; + else if (state === MprisLoopState.Track) + Players.active.loopState = MprisLoopState.Playlist; + else + Players.active.loopState = MprisLoopState.None; + } + implicitWidth: Math.round(implicitHeight * 0.9) + } + } +} diff --git a/Modules/Dashboard/Media/LyricList.qml b/Modules/Dashboard/Media/LyricList.qml new file mode 100644 index 0000000..a90aaa2 --- /dev/null +++ b/Modules/Dashboard/Media/LyricList.qml @@ -0,0 +1,317 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Effects +import qs.Services +import qs.Helpers + +Item { + id: root + + // Funny binding hack to make lyrics update + readonly property var _: { + const p = Players.active; + if (p) + Lyrics.setTrack(p.trackArtist, p.trackTitle, p.trackAlbum, p.length); + else + Lyrics.clearTrack(); + } + + readonly property real fadeAmount: 0.1 + property list lyricList: Lyrics.lyrics + + layer.enabled: true + layer.effect: Mask { + maskSource: mask + + Rectangle { + id: mask + + layer.enabled: true + visible: false + implicitWidth: root.width + implicitHeight: root.height + + gradient: Gradient { + orientation: Gradient.Vertical + + GradientStop { + color: Qt.alpha("black", 0) + position: 0 + } + GradientStop { + color: Qt.alpha("black", 1) + position: root.fadeAmount + } + GradientStop { + color: Qt.alpha("black", 1) + position: 1 - root.fadeAmount + } + GradientStop { + color: Qt.alpha("black", 0) + position: 1 + } + } + } + } + + state: { + if (Lyrics.hasLyrics) + return "hasLyrics"; + if (Lyrics.loading) + return "loading"; + return "noLyrics"; + } + + states: [ + State { + name: "loading" + + PropertyChanges { + loadingIndicator.opacity: 1 + lyrics.opacity: 0 + noLyrics.opacity: 0 + } + }, + State { + name: "hasLyrics" + + PropertyChanges { + loadingIndicator.opacity: 0 + lyrics.opacity: 1 + noLyrics.opacity: 0 + } + }, + State { + name: "noLyrics" + + PropertyChanges { + loadingIndicator.opacity: 0 + lyrics.opacity: 0 + noLyrics.opacity: 1 + } + } + ] + + transitions: [ + Transition { + from: "loading" + + SequentialAnimation { + Anim { + target: loadingIndicator + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [lyrics, noLyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + }, + Transition { + from: "hasLyrics" + + SequentialAnimation { + Anim { + target: lyrics + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [loadingIndicator, noLyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + }, + Transition { + from: "noLyrics" + + SequentialAnimation { + Anim { + target: noLyrics + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [loadingIndicator, lyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + } + ] + + Loader { + id: loadingIndicator + + anchors.centerIn: parent + asynchronous: true + active: opacity > 0 + opacity: 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.large + + CustomRect { + Layout.alignment: Qt.AlignHCenter + implicitWidth: shape.implicitSize + Tokens.padding.medium * 2 + implicitHeight: shape.implicitSize + Tokens.padding.medium * 2 + color: Colors.palette.m3primaryContainer + radius: Tokens.rounding.full + + LoadingIndicator { + id: shape + + anchors.centerIn: parent + implicitSize: Math.round(300 / 5) + containsIcon: true // This removes the pentagon, which is not centered + } + } + + CustomText { + text: qsTr("Loading lyrics...") + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Loader { + id: noLyrics + + anchors.centerIn: parent + asynchronous: true + active: opacity > 0 + opacity: 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "sentiment_sad" + font.pointSize: Tokens.font.size.extraLarge + color: Colors.palette.m3outline + } + + CustomText { + text: qsTr("No lyrics found") + color: Colors.palette.m3outline + font.pointSize: Tokens.font.size.normal + } + } + } + + CustomListView { + id: lyrics + + anchors.fill: parent + anchors.topMargin: parent.height * root.fadeAmount / 2 + anchors.bottomMargin: parent.height * root.fadeAmount / 2 + + displayMarginBeginning: anchors.topMargin + displayMarginEnd: anchors.bottomMargin + + model: root.lyricList + Component.onCompleted: { + currentIndex = Qt.binding(() => { + model; // Force update when lyrics change + return Lyrics.indexForTime(Players.active?.position ?? 0); + }); + positionViewAtIndex(currentIndex, ListView.Center); + } + onModelChanged: Qt.callLater(() => positionViewAtIndex(currentIndex, ListView.Center)) + + highlightRangeMode: ListView.ApplyRange + highlightMoveDuration: Tokens.anim.durations.large + highlightMoveVelocity: -1 + preferredHighlightBegin: (height - (currentItem?.implicitHeight ?? 0)) / 2 + preferredHighlightEnd: (height + (currentItem?.implicitHeight ?? 0)) / 2 + + spacing: Tokens.spacing.small + opacity: 0 + enabled: opacity > 0 + + delegate: CustomText { + id: lyric + + required property string modelData + required property int index + property real effectScale: ListView.isCurrentItem ? 1 : 0 + + anchors.left: lyrics.contentItem.left + anchors.right: lyrics.contentItem.right + + text: modelData || ". . ." + color: ListView.isCurrentItem ? Colors.palette.m3primary : mouse.containsMouse ? Colors.palette.m3onSurface : Colors.palette.m3outline + font.pointSize: Tokens.font.size.medium + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + layer.enabled: effectScale > 0 + layer.effect: MultiEffect { + shadowEnabled: true + shadowColor: Colors.palette.m3primary + shadowOpacity: 0.5 * lyric.effectScale + shadowBlur: 0.6 * lyric.effectScale + blur: 0.4 * lyric.effectScale + } + + Behavior on effectScale { + Anim { + type: Anim.SlowEffects + } + } + + MouseArea { + id: mouse + + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: { + const p = Players.active; + if (p) + p.position = Lyrics.timeForIndex(lyric.index); + } + } + } + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + + Behavior on lyricList { + SequentialAnimation { + Anim { + target: lyrics + property: "opacity" + to: 0 + type: Anim.DefaultEffects + } + PropertyAction {} + Anim { + target: lyrics + property: "opacity" + to: 1 + type: Anim.SlowEffects + } + } + } +} diff --git a/Modules/Dashboard/Media/LyricsAndSelector.qml b/Modules/Dashboard/Media/LyricsAndSelector.qml new file mode 100644 index 0000000..453ed2c --- /dev/null +++ b/Modules/Dashboard/Media/LyricsAndSelector.qml @@ -0,0 +1,79 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Mpris +import ZShell.Config +import qs.Components +import qs.Helpers +import qs.Services + +Item { + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.leftMargin: Tokens.padding.medium + spacing: Tokens.spacing.medium + + RowLayout { + Layout.bottomMargin: -Tokens.spacing.medium + spacing: Tokens.spacing.medium + z: 1 + + MaterialIcon { + Layout.topMargin: Math.round(fontInfo.pointSize * 0.12) + text: "lyrics" + font.pointSize: Tokens.font.size.large + } + + CustomText { + Layout.fillWidth: true + text: qsTr("Lyrics") + font.pointSize: Tokens.font.size.large + } + + LyricsInfo {} + } + + LyricList { + Layout.fillWidth: true + Layout.fillHeight: true + } + + SplitButton { + Layout.alignment: Qt.AlignHCenter + + type: SplitButton.Tonal + enabled: Players.list.length + active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null + menu.onItemSelected: item => Players.manualActive = (item as PlayerItem).modelData + + menuItems: playerList.instances + fallbackIcon: "music_off" + fallbackText: qsTr("No players", "no media players active") + + minLeftWidth: layout.width - expandBtn.implicitWidth - spacing + label.Layout.maximumWidth: minLeftWidth - iconLabel.implicitWidth - textRow.spacing - textRow.anchors.horizontalCenterOffset / 2 - horizontalPadding * 2 + label.elide: Text.ElideRight + + stateLayer.enabled: true + menuOnTop: true + + Variants { + id: playerList + + model: Players.list + + PlayerItem {} + } + } + } + + component PlayerItem: MenuItem { + required property MprisPlayer modelData + + icon: modelData === Players.active ? "check" : "" + text: Players.getIdentity(modelData) + activeIcon: "animated_images" + } +} diff --git a/Modules/Dashboard/Media/LyricsInfo.qml b/Modules/Dashboard/Media/LyricsInfo.qml new file mode 100644 index 0000000..2e4437c --- /dev/null +++ b/Modules/Dashboard/Media/LyricsInfo.qml @@ -0,0 +1,212 @@ +import QtQuick +import QtQuick.Layouts +import ZShell +import ZShell.Blobs +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Services + +Item { + id: root + + property bool open + readonly property real padding: Tokens.padding.large + + implicitWidth: btn.implicitWidth * 0.9 + implicitHeight: btn.implicitHeight * 0.9 + + BlobGroup { + id: blobGroup + + color: Colors.palette.m3surfaceContainerHighest + smoothing: Tokens.rounding.medium + cornerFill: false + + Behavior on color { + CAnim {} + } + } + + BlobRect { + id: btnRect + + anchors.fill: parent + anchors.margins: !btn.pressed && btn.containsMouse ? -Tokens.padding.extraSmall : 0 + group: blobGroup + radius: Tokens.rounding.medium + + Behavior on anchors.margins { + Anim {} + } + } + + BlobRect { + id: rect + + anchors.right: parent.right + anchors.top: parent.top + + implicitWidth: parent.width + implicitHeight: parent.height + + group: blobGroup + radius: Tokens.rounding.medium + deformScale: 0.00001 + + states: State { + name: "open" + when: root.open + + PropertyChanges { + rect.anchors.rightMargin: root.width - Tokens.spacing.small + rect.anchors.topMargin: -Tokens.padding.medium + rect.implicitWidth: Math.max(layout.implicitWidth, placeholder.implicitWidth) + root.padding * 2 + rect.implicitHeight: Math.max(layout.implicitHeight, placeholder.implicitHeight) + root.padding * 2 + content.opacity: 1 + } + } + + transitions: Transition { + Anim { + properties: "rightMargin,implicitWidth" + } + Anim { + properties: "topMargin,implicitHeight" + easing: Tokens.anim.expressiveFastSpatial + } + Anim { + property: "opacity" + type: Anim.DefaultEffects + } + } + + Behavior on implicitWidth { + Anim {} + } + + Behavior on implicitHeight { + Anim {} + } + + Item { + id: content + + anchors.fill: parent + clip: true + opacity: 0 + state: Lyrics.loading || !Lyrics.hasLyrics ? "" : "hasLyrics" + + states: State { + name: "hasLyrics" + + PropertyChanges { + layout.opacity: 1 + placeholder.opacity: 0 + } + } + + transitions: [ + Transition { + from: "hasLyrics" + + SequentialAnimation { + Anim { + target: layout + property: "opacity" + type: Anim.FastEffects + } + Anim { + target: placeholder + property: "opacity" + type: Anim.DefaultEffects + } + } + }, + Transition { + to: "hasLyrics" + + SequentialAnimation { + Anim { + target: placeholder + property: "opacity" + type: Anim.FastEffects + } + Anim { + target: layout + property: "opacity" + type: Anim.DefaultEffects + } + } + } + ] + + ColumnLayout { + id: layout + + anchors.centerIn: parent + spacing: Tokens.spacing.extraSmall + opacity: 0 + + CustomText { + // TRANSLATORS: %1 = lyrics backend name, e.g. LRCLIB + text: qsTr("Backend: %1").arg(ZUtils.enumToString(Lyrics, "backend")) + color: Colors.palette.m3onSurfaceVariant + animate: true + } + + CustomText { + Layout.maximumWidth: 1000 / 2 + // TRANSLATORS: %1/%2/%3 = matched track title, artist and album + text: qsTr("Selected candidate: %1 | %2 | %3").arg(Lyrics.selectedCandidate.title).arg(Lyrics.selectedCandidate.artist).arg(Lyrics.selectedCandidate.album) + color: Colors.palette.m3onSurfaceVariant + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + animate: true + } + + CustomText { + // TRANSLATORS: %1 = lyric timing offset; ms is the millisecond unit, leave untranslated + text: qsTr("Offset: %1 ms").arg(Lyrics.offset) + color: Colors.palette.m3onSurfaceVariant + animate: true + } + } + + Item { + id: placeholder + + anchors.centerIn: parent + implicitWidth: placeholderText.implicitWidth + implicitHeight: placeholderText.implicitHeight + + CustomText { + id: placeholderText + + text: Lyrics.loading ? qsTr("Loading...") : qsTr("No lyrics found") + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + animate: true + } + } + } + } + + MouseArea { + id: btn + + anchors.centerIn: parent + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + Tokens.padding.extraSmall * 2 + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: root.open = !root.open + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: "more_vert" + font.pointSize: Tokens.font.size.large + } + } +} diff --git a/Modules/Dashboard/Tabs.qml b/Modules/Dashboard/Tabs.qml index 8a0bccf..ce04b93 100644 --- a/Modules/Dashboard/Tabs.qml +++ b/Modules/Dashboard/Tabs.qml @@ -1,23 +1,24 @@ pragma ComponentBehavior: Bound -import qs.Components -import ZShell.Config -import qs.Helpers -import qs.Modules -import Quickshell -import Quickshell.Widgets import QtQuick -import QtQuick.Controls +import QtQuick.Layouts +import QtQuick.Templates +import Quickshell +import ZShell.Config +import qs.Components import qs.Services Item { id: root - readonly property alias count: bar.count required property real nonAnimWidth - required property PersistentProperties state + required property ScreenState screenState + required property var tabs - implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight + readonly property alias count: bar.count + readonly property int tabIndicatorSpacing: Tokens.spacing.small + + implicitHeight: bar.implicitHeight + bar.anchors.topMargin + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight TabBar { id: bar @@ -25,85 +26,99 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top + anchors.topMargin: root.tabIndicatorSpacing + + currentIndex: root.screenState.dashboardTab + onCurrentIndexChanged: root.screenState.dashboardTab = currentIndex + + implicitHeight: contentHeight background: null - currentIndex: root.state.currentTab + contentItem: RowLayout { + spacing: 0 - onCurrentIndexChanged: root.state.currentTab = currentIndex - - Tab { - iconName: "dashboard" - text: qsTr("Dashboard") + Repeater { + model: bar.contentModel + } } - Tab { - iconName: "queue_music" - text: qsTr("Media") - } + Repeater { + model: ScriptModel { + values: root.tabs + } - Tab { - iconName: "speed" - text: qsTr("Performance") - } + delegate: Tab { + required property var modelData - Tab { - iconName: "cloud" - text: qsTr("Weather") + iconName: modelData.iconName + text: modelData.text + } } - - // Tab { - // iconName: "workspaces" - // text: qsTr("Workspaces") - // } } Item { id: indicator anchors.top: bar.bottom - clip: true - implicitHeight: 40 - implicitWidth: bar.currentItem.implicitWidth + anchors.topMargin: root.tabIndicatorSpacing + + implicitWidth: { + const tab = bar.currentItem; + if (tab) + return tab.implicitWidth; + const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count; + return width; + } + implicitHeight: 3 + x: { const tab = bar.currentItem; const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count; - return width * tab.TabBar.index + (width - tab.implicitWidth) / 2; + const tabWidth = tab?.implicitWidth ?? width; + return width * bar.currentIndex + (width - tabWidth) / 2; + } + + clip: true + + CustomRect { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: parent.implicitHeight * 2 + + color: Colors.palette.m3primary + radius: Tokens.rounding.full + } + + Behavior on x { + Anim {} } Behavior on implicitWidth { - Anim { - } - } - Behavior on x { - Anim { - } - } - - CustomRect { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - color: Colors.palette.m3primary - implicitHeight: parent.implicitHeight * 2 - radius: Tokens.rounding.full + Anim {} } } CustomRect { id: separator + anchors.top: indicator.bottom anchors.left: parent.left anchors.right: parent.right - anchors.top: indicator.bottom - color: Colors.palette.m3outlineVariant + implicitHeight: 1 + color: Colors.palette.m3outlineVariant } component Tab: TabButton { id: tab - readonly property bool current: TabBar.tabBar.currentItem === this required property string iconName + readonly property bool current: TabBar.tabBar.currentItem === this + Layout.fillWidth: true + Layout.preferredWidth: 1 // Uniform width across all tabs + implicitWidth: implicitContentWidth + implicitHeight: implicitContentHeight background: null contentItem: CustomMouseArea { @@ -111,117 +126,47 @@ Item { function onWheel(event: WheelEvent): void { if (event.angleDelta.y < 0) - root.state.currentTab = Math.min(root.state.currentTab + 1, bar.count - 1); + root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, bar.count - 1); else if (event.angleDelta.y > 0) - root.state.currentTab = Math.max(root.state.currentTab - 1, 0); + root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); } - cursorShape: Qt.PointingHandCursor - implicitHeight: icon.height + label.height implicitWidth: Math.max(icon.width, label.width) + implicitHeight: icon.height + label.height - onPressed: event => { - root.state.currentTab = tab.TabBar.index; + hoverEnabled: true + cursorShape: Qt.PointingHandCursor - const stateY = stateWrapper.y; - rippleAnim.x = event.x; - rippleAnim.y = event.y - stateY; + onPressed: root.screenState.dashboardTab = tab.TabBar.index - const dist = (ox, oy) => ox * ox + oy * oy; - rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y + stateY), dist(event.x, stateWrapper.height - event.y), dist(width - event.x, event.y + stateY), dist(width - event.x, stateWrapper.height - event.y))); - - rippleAnim.restart(); - } - - SequentialAnimation { - id: rippleAnim - - property real radius - property real x - property real y - - PropertyAction { - property: "x" - target: ripple - value: rippleAnim.x - } - - PropertyAction { - property: "y" - target: ripple - value: rippleAnim.y - } - - PropertyAction { - property: "opacity" - target: ripple - value: 0.08 - } - - Anim { - from: 0 - properties: "implicitWidth,implicitHeight" - target: ripple - to: rippleAnim.radius * 2 - } - - Anim { - easing.type: Easing.BezierSpline - property: "opacity" - target: ripple - to: 0 - } - } - - ClippingRectangle { - id: stateWrapper + StateLayer { + id: stateLayer + anchors.fill: undefined anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - color: "transparent" - implicitHeight: parent.height + 8 * 2 - radius: Tokens.rounding.small + implicitHeight: parent.height + root.tabIndicatorSpacing * 2 - CustomRect { - id: stateLayer - - anchors.fill: parent - color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface - opacity: mouse.pressed ? 0.1 : tab.hovered ? 0.08 : 0 - - Behavior on opacity { - Anim { - } - } - } - - CustomRect { - id: ripple - - color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface - opacity: 0 - radius: Tokens.rounding.full - - transform: Translate { - x: -ripple.width / 2 - y: -ripple.height / 2 - } - } + radius: Tokens.rounding.medium + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface + onClicked: root.screenState.dashboardTab = tab.TabBar.index } MaterialIcon { id: icon - anchors.bottom: label.top anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: label.top + + text: tab.iconName color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant fill: tab.current ? 1 : 0 - font.pointSize: 18 - text: tab.iconName + font.pointSize: Tokens.font.size.large Behavior on fill { Anim { + type: Anim.DefaultEffects } } } @@ -229,10 +174,11 @@ Item { CustomText { id: label - anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter - color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + anchors.bottom: parent.bottom + text: tab.text + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant } } } diff --git a/Modules/Dashboard/Weather/Graphs.qml b/Modules/Dashboard/Weather/Graphs.qml new file mode 100644 index 0000000..ff0e0df --- /dev/null +++ b/Modules/Dashboard/Weather/Graphs.qml @@ -0,0 +1,145 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services +import qs.Helpers + +RowLayout { + id: root + + spacing: Tokens.spacing.largeIncreased + + readonly property list forecastToday: Weather.hourlyForecast.slice(0, 25) + + property var now: new Date() + property bool useTwelveHourClock: Config.services.useTwelveHourClock + property bool curveSmoothing: true + property real smoothing: 2 + property real markerGap: Math.min(Math.max(Tokens.spacing.medium / 2, 0), 6) + + readonly property real markerX: { + const d = root.now; + return d.getHours() + d.getMinutes() / 60; + } + + function formatHour(h) { + if (root.useTwelveHourClock) { + const hr = h % 12 === 0 ? 12 : h % 12; + return hr + (h < 12 ? " AM" : " PM"); + } + return h; + } + + Timer { + interval: 60000 + repeat: true + running: true + + onTriggered: root.now = new Date() + } + + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: root.width / 2 + + RowLayout { + Layout.fillWidth: true + + MaterialIcon { + text: "device_thermostat" + font.pointSize: Tokens.font.size.large + color: Colors.palette.m3secondary + } + + CustomText { + text: qsTr("Temperature") + } + } + + Item { + id: temperature + + implicitHeight: 150 + Layout.fillWidth: true + + LineGraph { + id: tempGraph + + anchors.fill: parent + values: root.forecastToday.map(e => e.tempC) + verticalLineStep: 3 + xSteps: 24 + horizontalLineStep: 10 + minHorizontalLines: 4 + yPaddingTop: horizontalLineStep + yPaddingBottom: horizontalLineStep + xLabel: i => root.formatHour(i) + yLabel: v => Math.round(v) + "°" + + markerX: root.markerX + smoothing: root.smoothing + curveSmoothing: root.curveSmoothing + markerGap: root.markerGap + + lineColor: Colors.palette.m3secondary + gridColor: Colors.palette.m3onSurface + markerColor: Colors.palette.m3secondary + labelColor: Colors.palette.m3onSurfaceVariant + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: root.width / 2 + + RowLayout { + Layout.fillWidth: true + + MaterialIcon { + text: "water_drop" + font.pointSize: Tokens.font.size.large + color: Colors.palette.m3tertiary + } + + CustomText { + text: qsTr("Chance of precipitation") + } + } + + Item { + id: precipitation + + Layout.fillWidth: true + implicitHeight: 150 + + LineGraph { + id: graph + + anchors.fill: parent + + values: root.forecastToday.map(e => e?.precipChance ?? null) + xSteps: 24 + yMin: 0 + yMax: 100 + yPaddingTop: 20 + verticalLineStep: 3 + lastXLine: false + horizontalLineStep: 20 + xLabel: i => root.formatHour(i) + yLabel: v => v + "%" + + markerX: root.markerX + smoothing: root.smoothing + curveSmoothing: root.curveSmoothing + markerGap: root.markerGap + + lineColor: Colors.palette.m3tertiary + gridColor: Colors.palette.m3onSurface + markerColor: Colors.palette.m3tertiary + labelColor: Colors.palette.m3onSurfaceVariant + } + } + } +} diff --git a/Modules/Dashboard/WeatherTab.qml b/Modules/Dashboard/WeatherTab.qml new file mode 100644 index 0000000..fbd44bc --- /dev/null +++ b/Modules/Dashboard/WeatherTab.qml @@ -0,0 +1,259 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Modules.Dashboard.Weather +import qs.Services +import qs.Helpers + +Item { + id: root + + readonly property var today: Weather.forecast && Weather.forecast.length > 0 ? Weather.forecast[0] : null + + implicitWidth: layout.implicitWidth > 800 ? layout.implicitWidth : 840 + implicitHeight: layout.implicitHeight + Component.onCompleted: Weather.reload() + + ColumnLayout { + id: layout + + anchors.fill: parent + spacing: Tokens.spacing.medium + + RowLayout { + Layout.leftMargin: Tokens.padding.large + Layout.rightMargin: Tokens.padding.large + Layout.fillWidth: true + + Column { + spacing: Tokens.spacing.extraSmall + + CustomText { + text: Weather.city || qsTr("Loading...") + font.pointSize: Tokens.font.size.large + color: Colors.palette.m3onSurface + } + + CustomText { + text: new Date().toLocaleDateString(Qt.locale("en_US"), "dddd, MMMM d") + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3onSurfaceVariant + } + } + + Item { + Layout.fillWidth: true + } + + Row { + spacing: Tokens.spacing.largeIncreased + + WeatherStat { + icon: "wb_twilight" + label: qsTr("Sunrise") + value: Weather.sunrise + colour: Colors.palette.m3tertiary + } + + WeatherStat { + icon: "bedtime" + label: qsTr("Sunset") + value: Weather.sunset + colour: Colors.palette.m3tertiary + } + } + } + + CustomRect { + Layout.fillWidth: true + implicitHeight: bigInfoRow.implicitHeight + bigInfoRow.anchors.margins * 2 + + radius: Tokens.rounding.large + color: Colors.tPalette.m3surfaceContainer + + Graphs { + id: bigInfoRow + anchors.fill: parent + anchors.margins: Tokens.padding.medium + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.medium + + DetailCard { + icon: Weather.icon + label: qsTr("Temperature") + value: Weather.temp + colour: Colors.palette.m3primary + } + DetailCard { + icon: "thermostat" + label: qsTr("Feels like", "apparent temperature") + value: Weather.feelsLike + colour: Colors.palette.m3primary + } + DetailCard { + icon: "water_drop" + label: qsTr("Humidity") + value: Strings.percent(Weather.humidity) + colour: Colors.palette.m3secondary + } + DetailCard { + icon: "air" + label: qsTr("Wind") + value: Weather.windSpeed ? qsTr("%1 km/h").arg(Weather.windSpeed) : "--" + colour: Colors.palette.m3tertiary + } + } + + CustomText { + Layout.topMargin: Tokens.spacing.medium + Layout.leftMargin: Tokens.padding.medium + visible: forecastRepeater.count > 0 + text: qsTr("7-day forecast") + font.pointSize: Tokens.font.size.normal + color: Colors.palette.m3onSurface + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.medium + + Repeater { + id: forecastRepeater + + model: Weather.forecast + + CustomRect { + id: forecastItem + + required property int index + required property var modelData + + Layout.fillWidth: true + implicitHeight: forecastItemColumn.implicitHeight + Tokens.padding.medium * 2 + + radius: Tokens.rounding.large + color: Colors.tPalette.m3surfaceContainer + + ColumnLayout { + id: forecastItemColumn + + anchors.centerIn: parent + spacing: Tokens.spacing.small + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: forecastItem.index === 0 ? qsTr("Today", "forecast column") : new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale("en_US"), "ddd") + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3primary + } + + CustomText { + Layout.topMargin: -Tokens.spacing.extraSmall + Layout.alignment: Qt.AlignHCenter + text: new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale("en_US"), "MMM d") + font.pointSize: Tokens.font.size.small + opacity: 0.7 + color: Colors.palette.m3onSurfaceVariant + } + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: forecastItem.modelData.icon + font.pointSize: Tokens.font.size.extraLarge + color: Colors.palette.m3secondary + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: { + const min = Weather.formatTemp(forecastItem.modelData.minTempC, true); + const max = Weather.formatTemp(forecastItem.modelData.maxTempC, true); + return qsTr("%1 / %2", "min/max temperature").arg(min).arg(max); + } + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3tertiary + } + } + } + } + } + } + + component DetailCard: CustomRect { + id: detailRoot + + property string icon + property string label + property string value + property color colour + + Layout.fillWidth: true + Layout.preferredHeight: 60 + radius: Tokens.rounding.medium + color: Colors.tPalette.m3surfaceContainer + + Row { + anchors.centerIn: parent + spacing: Tokens.spacing.medium + + MaterialIcon { + text: detailRoot.icon + color: detailRoot.colour + font.pointSize: Tokens.font.size.large + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + CustomText { + text: detailRoot.label + font.pointSize: Tokens.font.size.small + opacity: 0.7 + horizontalAlignment: Text.AlignLeft + } + CustomText { + text: detailRoot.value + font.pointSize: Tokens.font.size.small + horizontalAlignment: Text.AlignLeft + } + } + } + } + + component WeatherStat: Row { + id: weatherStat + + property string icon + property string label + property string value + property color colour + + spacing: Tokens.spacing.small + + MaterialIcon { + text: weatherStat.icon + font.pointSize: Tokens.font.size.extraLarge + color: weatherStat.colour + } + + Column { + CustomText { + text: weatherStat.label + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3onSurfaceVariant + } + CustomText { + text: weatherStat.value + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3onSurface + } + } + } +} diff --git a/Modules/Dashboard/Wrapper.qml b/Modules/Dashboard/Wrapper.qml index c78a97f..b2e0f4b 100644 --- a/Modules/Dashboard/Wrapper.qml +++ b/Modules/Dashboard/Wrapper.qml @@ -8,24 +8,13 @@ import ZShell.Config Item { id: root - readonly property PersistentProperties dashState: PersistentProperties { - property date currentDate: new Date() - property int currentTab - - reloadableId: "dashboardState" - } readonly property real nonAnimHeight: state === "visible" ? (content.item?.nonAnimHeight ?? 0) : 0 required property real offsetScale - required property string position - readonly property bool shouldBeActive: root.visibilities.dashboard && Config.dashboard.enabled - required property PersistentProperties visibilities + readonly property bool shouldBeActive: root.screenState.dashboard && Config.dashboard.enabled + required property ScreenState screenState - anchors.bottom: position === "bottom" ? parent.bottom : undefined - anchors.bottomMargin: position === "bottom" ? (-implicitHeight - 5) * offsetScale : 0 - anchors.left: position === "left" ? parent.left : undefined - anchors.leftMargin: position === "left" ? (-implicitWidth - 5) * offsetScale : 0 - anchors.top: position === "top" ? parent.top : undefined - anchors.topMargin: position === "top" ? (-implicitHeight - 5) * offsetScale : 0 + anchors.top: parent.top + anchors.topMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth || 854 opacity: 1 - offsetScale @@ -35,15 +24,11 @@ Item { id: content active: root.shouldBeActive || root.visible - anchors.bottom: root.position === "top" ? parent.bottom : undefined - anchors.horizontalCenter: root.position !== "left" ? parent.horizontalCenter : undefined - anchors.right: root.position !== "left" ? undefined : parent.right - anchors.top: root.position === "bottom" ? parent.top : undefined - anchors.verticalCenter: root.position === "left" ? undefined : parent.verticalCenter + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter sourceComponent: Content { - dashState: root.dashState - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/DesktopIcons/BackgroundContextMenu.qml b/Modules/DesktopIcons/BackgroundContextMenu.qml index 88e9bc0..24f0217 100644 --- a/Modules/DesktopIcons/BackgroundContextMenu.qml +++ b/Modules/DesktopIcons/BackgroundContextMenu.qml @@ -8,6 +8,7 @@ import ZShell.Config import qs.Paths import qs.Helpers import qs.Services +import qs.Services Item { id: root @@ -97,8 +98,8 @@ Item { anchors.fill: parent onClicked: { - const visibilities = Visibilities.getForActive(); - visibilities.settings = true; + const state = ShellState.forActive(); + state.settings = true; root.close() } } diff --git a/Modules/Dock/Content.qml b/Modules/Dock/Content.qml index 5fda10a..1cb257c 100644 --- a/Modules/Dock/Content.qml +++ b/Modules/Dock/Content.qml @@ -27,7 +27,7 @@ Item { property var pendingCommitIds: [] readonly property int rounding: Tokens.rounding.largeIncreased required property ShellScreen screen - required property PersistentProperties visibilities + required property ScreenState screenState property var visualIds: [] function beginVisualDrag(appId, modelData, item) { @@ -198,7 +198,7 @@ Item { anchors.centerIn: parent appListRoot: root appToplevel: modelData - visibilities: root.visibilities + screenState: root.screenState visible: root.draggedAppId !== slot.appId } @@ -314,7 +314,7 @@ Item { appListRoot: root appToplevel: root.draggedModelData enabled: false - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Dock/Parts/DockAppButton.qml b/Modules/Dock/Parts/DockAppButton.qml index ab9164b..d6ca2f0 100644 --- a/Modules/Dock/Parts/DockAppButton.qml +++ b/Modules/Dock/Parts/DockAppButton.qml @@ -19,7 +19,7 @@ CustomRect { property real iconSize: implicitHeight - 20 readonly property bool isSeparator: appToplevel?.appId === "__dock_separator__" property int lastFocused: -1 - required property PersistentProperties visibilities + required property ScreenState screenState implicitHeight: Config.dock.height implicitWidth: isSeparator ? 1 : implicitHeight @@ -62,12 +62,12 @@ CustomRect { onClicked: { if (appToplevel?.toplevels.length === 0) { root.desktopEntry?.execute(); - root.visibilities.dock = false; + root.screenState.dock = false; return; } lastFocused = (lastFocused + 1) % appToplevel?.toplevels.length; appToplevel?.toplevels[lastFocused].activate(); - root.visibilities.dock = false; + root.screenState.dock = false; } } diff --git a/Modules/Dock/Wrapper.qml b/Modules/Dock/Wrapper.qml index a9897a5..df41f2e 100644 --- a/Modules/Dock/Wrapper.qml +++ b/Modules/Dock/Wrapper.qml @@ -12,8 +12,8 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 required property var panels required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.dock && Config.dock.enable - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.dock && Config.dock.enable + required property ScreenState screenState anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight @@ -39,7 +39,7 @@ Item { sourceComponent: Content { panels: root.panels screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Drawing/Content.qml b/Modules/Drawing/Content.qml index 5fe6eea..c689836 100644 --- a/Modules/Drawing/Content.qml +++ b/Modules/Drawing/Content.qml @@ -12,7 +12,7 @@ Item { readonly property var colors1: ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4"] readonly property var colors2: ["#3b82f6", "#a855f7", "#ec4899", "#ffffff", "#000000"] required property var drawing - required property var visibilities + required property ScreenState screenState required property Wrapper wrapper function syncFromPenColor() { @@ -20,17 +20,17 @@ Item { return; if (!saturationSlider.pressed) - saturationSlider.value = drawing.drawingState.penColor.hsvSaturation; + saturationSlider.value = drawing.screenState.penColor.hsvSaturation; if (!brightnessSlider.pressed) - brightnessSlider.value = drawing.drawingState.penColor.hsvValue; + brightnessSlider.value = drawing.screenState.penColor.hsvValue; } function updatePenColorFromHsv() { if (!drawing) return; - drawing.drawingState.penColor = Qt.hsva(huePicker.currentHue, saturationSlider.value, brightnessSlider.value, drawing.drawingState.penColor.a); + drawing.screenState.penColor = Qt.hsva(huePicker.currentHue, saturationSlider.value, brightnessSlider.value, drawing.screenState.penColor.a); } implicitHeight: column.height + Tokens.padding.small * 2 @@ -43,7 +43,7 @@ Item { root.syncFromPenColor(); } - target: root.drawing.drawingState + target: root.drawing.screenState } Column { @@ -133,9 +133,9 @@ Item { multiplier: 1 orientation: Qt.Horizontal to: 45 - value: root.drawing.drawingState.penWidth + value: root.drawing.screenState.penWidth - onMoved: root.drawing.drawingState.penWidth = value + onMoved: root.drawing.screenState.penWidth = value } ButtonRow { @@ -153,7 +153,7 @@ Item { shapeMorph: true text: "Exit" - onClicked: root.visibilities.isDrawing = false + onClicked: root.screenState.isDrawing = false } IconTextButton { @@ -203,6 +203,6 @@ Item { shapeMorph: true shapeMorphExpansion: pressed ? 12 : 0 - onClicked: root.drawing.drawingState.penColor = modelData + onClicked: root.drawing.screenState.penColor = modelData } } diff --git a/Modules/Drawing/Wrapper.qml b/Modules/Drawing/Wrapper.qml index 4389b06..e7835d1 100644 --- a/Modules/Drawing/Wrapper.qml +++ b/Modules/Drawing/Wrapper.qml @@ -15,8 +15,8 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 property bool pinned: false required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.isDrawing - required property var visibilities + readonly property bool shouldBeActive: screenState.isDrawing + required property ScreenState screenState function collapse(): void { if (pinned) @@ -98,7 +98,7 @@ Item { } sourceComponent: Content { drawing: root.drawing - visibilities: root.visibilities + screenState: root.screenState wrapper: root } } diff --git a/Modules/Launcher/AppList.qml b/Modules/Launcher/AppList.qml index 4a0e862..717edf6 100644 --- a/Modules/Launcher/AppList.qml +++ b/Modules/Launcher/AppList.qml @@ -15,7 +15,7 @@ CustomListView { property string displayText readonly property string requestedState: stateForText(search.text) required property SearchBar search - required property PersistentProperties visibilities + required property ScreenState screenState function resultsForText(text: string): var { switch (stateForText(text)) { @@ -44,7 +44,7 @@ CustomListView { } function syncDisplayText(): void { - if (visibilities.launcher && requestedState === displayState) + if (screenState.launcher && requestedState === displayState) displayText = search.text; } @@ -55,7 +55,7 @@ CustomListView { preferredHighlightBegin: 0 preferredHighlightEnd: height spacing: Tokens.spacing.small - state: visibilities.launcher ? requestedState : displayState + state: screenState.launcher ? requestedState : displayState verticalLayoutDirection: ListView.BottomToTop CustomScrollBar.vertical: CustomScrollBar { @@ -236,7 +236,7 @@ CustomListView { id: appItem AppItem { - visibilities: root.visibilities + screenState: root.screenState } } @@ -277,6 +277,6 @@ CustomListView { root.syncDisplayText(); } - target: root.visibilities + target: root.screenState } } diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml index e1aeb80..3db4299 100755 --- a/Modules/Launcher/CarouselView.qml +++ b/Modules/Launcher/CarouselView.qml @@ -13,14 +13,14 @@ CarouselView { readonly property var screen: QsWindow.window?.screen required property SearchBar search readonly property var values: Wallpapers.query(root.search.text.split(" ").slice(1).join(" ")) - required property var visibilities + required property ScreenState screenState currentIndex: { const idx = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent); return idx >= 0 ? idx : 0; } delegateProperties: ({ - visibilities: root.visibilities + screenState: root.screenState }) focalWidth: Config.launcher.sizes.wallpaperWidth glideDuration: 250 @@ -32,7 +32,7 @@ CarouselView { return 0; const barMargins = Config.bar.border * 2; let margins = Math.round(screen.width / 6); - if (visibilities.utilities || visibilities.sidebar) + if (screenState.utilities || screenState.sidebar) margins = Config.sidebar.sizes.width; return screen.width - (barMargins + margins) * 2 - Config.bar.rounding * 4; } diff --git a/Modules/Launcher/Content.qml b/Modules/Launcher/Content.qml index 9bb4338..4bfaf49 100644 --- a/Modules/Launcher/Content.qml +++ b/Modules/Launcher/Content.qml @@ -15,7 +15,7 @@ Item { readonly property int padding: Tokens.padding.largeIncreased / 4 required property var panels readonly property int rounding: Tokens.rounding.largeIncreased - required property PersistentProperties visibilities + required property ScreenState screenState implicitHeight: search.height + listWrapper.height + padding * 2 implicitWidth: listWrapper.width + padding * 2 @@ -38,7 +38,7 @@ Item { panels: root.panels rounding: root.rounding search: search - visibilities: root.visibilities + screenState: root.screenState } } @@ -55,7 +55,7 @@ Item { Component.onCompleted: forceActiveFocus() Keys.onDownPressed: list.currentList?.decrementCurrentIndex() - Keys.onEscapePressed: root.visibilities.launcher = false + Keys.onEscapePressed: root.screenState.launcher = false Keys.onPressed: event => { if (event.modifiers & Qt.ControlModifier) { if (event.key === Qt.Key_J) { @@ -81,7 +81,7 @@ Item { if (Colors.scheme === "dynamic" && currentItem.modelData.path !== Wallpapers.actualCurrent) Wallpapers.previewColorLock = true; Wallpapers.setWallpaper(currentItem.modelData.path); - root.visibilities.launcher = false; + root.screenState.launcher = false; } else if (text.startsWith(Config.launcher.actionPrefix)) { if (text.startsWith(`${Config.launcher.actionPrefix}calc `)) currentItem.onClicked(); @@ -89,18 +89,18 @@ Item { currentItem.modelData.onClicked(list.currentList); } else { Apps.launch(currentItem.modelData); - root.visibilities.launcher = false; + root.screenState.launcher = false; } } } Connections { function onLauncherChanged(): void { - if (!root.visibilities.launcher) + if (!root.screenState.launcher) search.text = ""; } - target: root.visibilities + target: root.screenState } } } diff --git a/Modules/Launcher/ContentList.qml b/Modules/Launcher/ContentList.qml index 0081914..76bfeed 100644 --- a/Modules/Launcher/ContentList.qml +++ b/Modules/Launcher/ContentList.qml @@ -20,7 +20,7 @@ Item { required property int rounding required property SearchBar search readonly property bool showWallpapers: search.text.startsWith(`${Config.launcher.actionPrefix}wallpaper `) - required property PersistentProperties visibilities + required property ScreenState screenState anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter @@ -28,7 +28,7 @@ Item { state: showWallpapers ? "wallpapers" : "apps" Behavior on implicitHeight { - enabled: root.visibilities.launcher + enabled: root.screenState.launcher Anim { duration: Tokens.anim.durations.small @@ -36,7 +36,7 @@ Item { } } Behavior on implicitWidth { - enabled: root.visibilities.launcher + enabled: root.screenState.launcher Anim { duration: Tokens.anim.durations.small @@ -99,7 +99,7 @@ Item { sourceComponent: AppList { search: root.search - visibilities: root.visibilities + screenState: root.screenState } } @@ -115,7 +115,7 @@ Item { sourceComponent: CarouselView { panels: root.panels search: root.search - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Modules/Launcher/Items/AppItem.qml b/Modules/Launcher/Items/AppItem.qml index 24d46ba..1a2604a 100644 --- a/Modules/Launcher/Items/AppItem.qml +++ b/Modules/Launcher/Items/AppItem.qml @@ -12,7 +12,7 @@ Item { id: root required property DesktopEntry modelData - required property PersistentProperties visibilities + required property ScreenState screenState anchors.left: parent?.left anchors.right: parent?.right @@ -23,7 +23,7 @@ Item { onClicked: { Apps.launch(root.modelData); - root.visibilities.launcher = false; + root.screenState.launcher = false; } } diff --git a/Modules/Launcher/Items/CalcItem.qml b/Modules/Launcher/Items/CalcItem.qml index 59da218..5a81aab 100644 --- a/Modules/Launcher/Items/CalcItem.qml +++ b/Modules/Launcher/Items/CalcItem.qml @@ -16,7 +16,7 @@ Item { function onClicked(): void { Quickshell.execDetached(["wl-copy", Qalculator.eval(math, false)]); - root.list.visibilities.launcher = false; + root.list.screenState.launcher = false; } anchors.left: parent?.left @@ -81,7 +81,7 @@ Item { onClicked: { Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.terminal, "fish", "-C", `exec qalc -i '${root.math}'`]); - root.list.visibilities.launcher = false; + root.list.screenState.launcher = false; } } diff --git a/Modules/Launcher/Items/FilesItem.qml b/Modules/Launcher/Items/FilesItem.qml new file mode 100644 index 0000000..ab3b3ba --- /dev/null +++ b/Modules/Launcher/Items/FilesItem.qml @@ -0,0 +1,141 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import ZShell.Config +import qs.Components +import qs.Helpers +import qs.Modules.Launcher.Services +import qs.Services + +Item { + id: root + + required property var list + required property var modelData + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: Config.launcher.sizes.itemHeight + + function onClicked(): void { + root.list.screenState.launcher = false; + if (root.modelData?.path) + Files.launch(["xdg-open", root.modelData.path]); + } + + StateLayer { + id: sLayer + + radius: Tokens.rounding.medium + manualHoverOverride: openLocation.hovered + + onClicked: { + root.onClicked(); + } + } + + IconButton { + id: openLocation + + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.medium + anchors.verticalCenter: parent.verticalCenter + icon: "open_in_new" + isRound: true + + scale: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0.4 + opacity: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + Behavior on scale { + Anim { + type: Anim.DefaultEffects + } + } + + onClicked: { + root.list.screenState.launcher = false; + if (root.modelData?.path) + Files.showInFolder(root.modelData.path); + } + } + + Item { + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small + anchors.margins: Tokens.padding.small + anchors.rightMargin: Tokens.padding.small + + IconImage { + id: icon + + anchors.verticalCenter: parent.verticalCenter + implicitSize: Tokens.font.size.extraLarge + source: Quickshell.iconPath(root.modelData?.icon) ?? "" + visible: !root.modelData?.isImage + } + + Loader { + active: opacity > 0 + anchors.verticalCenter: parent.verticalCenter + asynchronous: true + opacity: root.modelData?.isImage && image.status === Image.Loading ? 1 : 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + sourceComponent: LoadingIndicator { + color: Colors.palette.m3primaryContainer + implicitSize: Tokens.font.size.extraLarge + } + } + + FadeImage { + id: image + + property int implicitSize: Tokens.font.size.extraLarge + + width: implicitSize + fillMode: Image.PreserveAspectFit + anchors.verticalCenter: parent.verticalCenter + source: root.modelData?.isImage ? root.modelData?.path ?? "" : "" + visible: root.modelData?.isImage + } + + Item { + anchors.left: icon.right + anchors.leftMargin: Tokens.spacing.medium + anchors.verticalCenter: icon.verticalCenter + implicitHeight: name.implicitHeight + desc.implicitHeight + implicitWidth: parent.width - icon.width + + CustomText { + id: name + + font.pointSize: Tokens.font.size.normal + text: root.modelData?.name ?? "" + elide: Text.ElideRight + width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2 + } + + CustomText { + id: desc + + anchors.top: name.bottom + color: Colors.palette.m3outline + elide: Text.ElideRight + font.pointSize: Tokens.font.size.small + text: root.modelData?.path ?? "" + width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2 + } + } + } +} diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index 806f89e..b4a1091 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -12,7 +12,7 @@ Item { required property var modelData // FileSystemEntry-like {path, relativePath} required property real tileHeight required property real tileWidth - required property PersistentProperties visibilities + required property ScreenState screenState signal requestActivate diff --git a/Modules/Launcher/Services/Actions.qml b/Modules/Launcher/Services/Actions.qml index 80df3fc..135dbc3 100644 --- a/Modules/Launcher/Services/Actions.qml +++ b/Modules/Launcher/Services/Actions.qml @@ -42,10 +42,10 @@ Searcher { if (command[0] === "autocomplete" && command.length > 1) { list.search.text = `${Config.launcher.actionPrefix}${command[1]} `; } else if (command[0] === "setMode" && command.length > 1) { - list.visibilities.launcher = false; + list.screenState.launcher = false; Colors.setMode(command[1]); } else { - list.visibilities.launcher = false; + list.screenState.launcher = false; Quickshell.execDetached(command); } } diff --git a/Modules/Launcher/Services/SchemeVariants.qml b/Modules/Launcher/Services/SchemeVariants.qml index 08a754f..5488706 100644 --- a/Modules/Launcher/Services/SchemeVariants.qml +++ b/Modules/Launcher/Services/SchemeVariants.qml @@ -79,7 +79,7 @@ Searcher { required property string variant function onClicked(list: AppList): void { - list.visibilities.launcher = false; + list.screenState.launcher = false; Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--scheme", variant]); Config.colors.schemeType = variant; } diff --git a/Modules/Launcher/Wrapper.qml b/Modules/Launcher/Wrapper.qml index e5ae86c..1fa0c24 100644 --- a/Modules/Launcher/Wrapper.qml +++ b/Modules/Launcher/Wrapper.qml @@ -12,7 +12,7 @@ Item { property int contentHeight readonly property real maxHeight: { let max = screen.height - Tokens.spacing.largeIncreased * 2; - if (visibilities.resources && panels.resourcesWrapper.x + panels.resourcesWrapper.width > root.x) + if (screenState.resources && panels.resourcesWrapper.x + panels.resourcesWrapper.width > root.x) max -= panels.resources.nonAnimHeight; if (panels.popouts.hasCurrent) if (panels.popouts.current?.x + panels.popouts.current?.width > root.x && panels.popouts.current?.x < root.x + root.width) @@ -22,8 +22,8 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 required property var panels required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.launcher - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.launcher + required property ScreenState screenState anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight @@ -57,7 +57,7 @@ Item { sourceComponent: Content { maxHeight: root.maxHeight panels: root.panels - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Lock/Weather/Forecast.qml b/Modules/Lock/Weather/Forecast.qml index 07632b3..467879b 100644 --- a/Modules/Lock/Weather/Forecast.qml +++ b/Modules/Lock/Weather/Forecast.qml @@ -60,7 +60,7 @@ CustomRect { MaterialShape { Layout.alignment: Qt.AlignHCenter - color: Qt.alpha(Colors.palette.m3primary, hour.index === 0 ? 1 : 0) + color: Qt.alpha(Colors.palette.m3primary, hour.index === Weather.currentHourIndex ? 1 : 0) implicitSize: temp.implicitHeight + Tokens.padding.small * 2 shape: MaterialShape.Cookie4Sided @@ -73,7 +73,7 @@ CustomRect { id: temp anchors.centerIn: parent - color: hour.index === 0 ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: hour.index === Weather.currentHourIndex ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface font.pointSize: Tokens.font.size.normal text: Weather.formatTemp(hour.cond.tempC).slice(0, -1) // Remove C/F } diff --git a/Modules/Notifications/Content.qml b/Modules/Notifications/Content.qml index 46477a9..bad138a 100644 --- a/Modules/Notifications/Content.qml +++ b/Modules/Notifications/Content.qml @@ -13,7 +13,7 @@ Item { readonly property int padding: Tokens.padding.smaller required property Item panels - required property PersistentProperties visibilities + required property ScreenState screenState anchors.bottom: parent.bottom anchors.right: parent.right @@ -27,16 +27,16 @@ Item { for (let i = 0; i < count; i++) height += (list.itemAtIndex(i) as NotifWrapper)?.nonAnimHeight ?? 0; - if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x || visibilities.dashboard) + if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x || screenState.dashboard) return 0; - if (visibilities.osd) { + if (screenState.osd) { const h = panels.osd.y - Tokens.spacing.small * 2 - padding * 2; if (height > h) height = h; } - if (visibilities.session) { + if (screenState.session) { const h = panels.session.y - Tokens.spacing.small * 2 - padding * 2; if (height > h) height = h; diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 06f0c5d..143c6a5 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -7,8 +7,7 @@ import qs.Services Item { id: root - required property Props props - required property var visibilities + required property ScreenState screenState ColumnLayout { id: layout @@ -23,8 +22,7 @@ Item { radius: Tokens.rounding.small NotifDock { - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Modules/Notifications/Sidebar/Notif.qml b/Modules/Notifications/Sidebar/Notif.qml index 4f3c13c..ae7a016 100644 --- a/Modules/Notifications/Sidebar/Notif.qml +++ b/Modules/Notifications/Sidebar/Notif.qml @@ -15,8 +15,7 @@ CustomRect { required property bool expanded required property NotifServer.Notif modelData readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + Tokens.padding.medium * 2 : summaryHeightMetrics.height - required property Props props - required property PersistentProperties visibilities + required property ScreenState screenState color: { const c = root.modelData?.urgency === "critical" ? Colors.palette.m3secondaryContainer : Colors.layer(Colors.palette.m3surfaceContainerHigh, 2); @@ -137,7 +136,7 @@ CustomRect { onLinkActivated: link => { Qt.openUrlExternally(link); - root.visibilities.sidebar = false; + root.screenState.sidebar = false; } } diff --git a/Modules/Notifications/Sidebar/NotifDock.qml b/Modules/Notifications/Sidebar/NotifDock.qml index 5cd56cc..6792807 100644 --- a/Modules/Notifications/Sidebar/NotifDock.qml +++ b/Modules/Notifications/Sidebar/NotifDock.qml @@ -14,8 +14,7 @@ Item { id: root readonly property int notifCount: NotifServer.list.reduce((acc, n) => n.closed ? acc : acc + 1, 0) - required property Props props - required property var visibilities + required property ScreenState screenState anchors.fill: parent anchors.margins: Tokens.padding.small @@ -120,8 +119,7 @@ Item { id: notifList container: view - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/NotifDockList.qml b/Modules/Notifications/Sidebar/NotifDockList.qml index 597a7ba..759e6d9 100644 --- a/Modules/Notifications/Sidebar/NotifDockList.qml +++ b/Modules/Notifications/Sidebar/NotifDockList.qml @@ -12,8 +12,7 @@ LazyListView { id: root required property Flickable container - required property Props props - required property PersistentProperties visibilities + required property ScreenState screenState anchors.left: parent?.left anchors.right: parent?.right @@ -118,8 +117,7 @@ LazyListView { container: root.container modelData: notif.modelData - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/NotifGroup.qml b/Modules/Notifications/Sidebar/NotifGroup.qml index bf6a376..faa67eb 100644 --- a/Modules/Notifications/Sidebar/NotifGroup.qml +++ b/Modules/Notifications/Sidebar/NotifGroup.qml @@ -16,7 +16,7 @@ CustomRect { readonly property list activeNotifs: notifs.filter(n => !n.closed) readonly property string appIcon: activeNotifs.find(n => n.appIcon.length > 0)?.appIcon ?? "" required property Flickable container - readonly property bool expanded: props.expandedNotifs.includes(modelData) + readonly property bool expanded: screenState.expandedNotifs.includes(modelData) readonly property string image: activeNotifs.find(n => n.image.length > 0)?.image ?? "" required property string modelData readonly property int nonAnimHeight: { @@ -26,7 +26,7 @@ CustomRect { } readonly property int notifCount: activeNotifs.length readonly property list notifs: NotifServer.list.filter(n => n.appName === modelData) - required property Props props + required property ScreenState screenState readonly property int urgency: { if (activeNotifs.find(n => n.urgency === NotificationUrgency.Critical)) return NotificationUrgency.Critical; @@ -34,14 +34,13 @@ CustomRect { return NotificationUrgency.Normal; return NotificationUrgency.Low; } - required property PersistentProperties visibilities function toggleExpand(expand: bool): void { if (expand) { if (!expanded) - props.expandedNotifs.push(modelData); + screenState.expandedNotifs.push(modelData); } else if (expanded) { - props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1); + screenState.expandedNotifs.splice(screenState.expandedNotifs.indexOf(modelData), 1); } } @@ -59,7 +58,7 @@ CustomRect { Component.onDestruction: { if (notifCount === 0 && expanded) - props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1); + screenState.expandedNotifs.splice(screenState.expandedNotifs.indexOf(modelData), 1); } RowLayout { @@ -233,8 +232,7 @@ CustomRect { container: root.container expanded: root.expanded notifs: root.notifs - props: root.props - visibilities: root.visibilities + screenState: root.screenState onRequestToggleExpand: expand => root.toggleExpand(expand) } diff --git a/Modules/Notifications/Sidebar/NotifGroupList.qml b/Modules/Notifications/Sidebar/NotifGroupList.qml index 904a787..b047878 100644 --- a/Modules/Notifications/Sidebar/NotifGroupList.qml +++ b/Modules/Notifications/Sidebar/NotifGroupList.qml @@ -14,8 +14,7 @@ LazyListView { required property Flickable container required property bool expanded required property list notifs - required property Props props - required property PersistentProperties visibilities + required property ScreenState screenState signal requestToggleExpand(expand: bool) @@ -122,8 +121,7 @@ LazyListView { anchors.fill: parent expanded: root.expanded modelData: notif.modelData - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/Props.qml b/Modules/Notifications/Sidebar/Props.qml deleted file mode 100644 index 21a2541..0000000 --- a/Modules/Notifications/Sidebar/Props.qml +++ /dev/null @@ -1,7 +0,0 @@ -import Quickshell - -PersistentProperties { - property list expandedNotifs: [] - - reloadableId: "sidebar" -} diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Record.qml b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml index 6731cbe..0f90322 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Record.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml @@ -11,8 +11,8 @@ import qs.Services CustomRect { id: root - required property var props - required property PersistentProperties visibilities + required property ScreenState screenState + Layout.fillWidth: true color: Colors.tPalette.m3surfaceContainer @@ -72,7 +72,7 @@ CustomRect { } SplitButton { - active: menuItems.find(m => root.props.recordingMode === m.icon + m.text) ?? menuItems[0] + active: menuItems.find(m => root.screenState.recordingMode === m.icon + m.text) ?? menuItems[0] enabled: !Recorder.running menuOnTop: true @@ -107,7 +107,7 @@ CustomRect { } ] - menu.onItemSelected: item => root.props.recordingMode = item.icon + item.text + menu.onItemSelected: item => root.screenState.recordingMode = item.icon + item.text } } @@ -190,8 +190,7 @@ CustomRect { id: recordingList RecordingList { - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml index c113f97..876c9d4 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml @@ -14,8 +14,7 @@ import qs.Services ColumnLayout { id: root - required property var props - required property PersistentProperties visibilities + required property ScreenState screenState spacing: 0 @@ -23,7 +22,7 @@ ColumnLayout { Layout.fillWidth: true cursorShape: Qt.PointingHandCursor - onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded + onClicked: root.screenState.recordingListExpanded = !root.screenState.recordingListExpanded RowLayout { spacing: Tokens.spacing.small @@ -42,11 +41,11 @@ ColumnLayout { } IconButton { - icon: root.props.recordingListExpanded ? "unfold_less" : "unfold_more" + icon: root.screenState.recordingListExpanded ? "unfold_less" : "unfold_more" label.animate: true type: IconButton.Text - onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded + onClicked: root.screenState.recordingListExpanded = !root.screenState.recordingListExpanded } } } @@ -57,7 +56,7 @@ ColumnLayout { Layout.fillWidth: true Layout.rightMargin: -Tokens.spacing.small clip: true - implicitHeight: (Tokens.font.size.larger + Tokens.padding.extraSmall) * (root.props.recordingListExpanded ? 10 : 3) + implicitHeight: (Tokens.font.size.larger + Tokens.padding.extraSmall) * (root.screenState.recordingListExpanded ? 10 : 3) CustomScrollBar.vertical: CustomScrollBar { flickable: list @@ -109,8 +108,11 @@ ColumnLayout { type: IconButton.Text onClicked: { - root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); + root.screenState.sidebar = false; + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); + else + Quickshell.execDetached([...Config.general.apps.playback, recording.modelData.path]); } } @@ -119,8 +121,11 @@ ColumnLayout { type: IconButton.Text onClicked: { - root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); + root.screenState.sidebar = false; + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); + else + Quickshell.execDetached([...Config.general.apps.explorer, recording.modelData.path]); } } } @@ -170,11 +175,11 @@ ColumnLayout { MaterialIcon { Layout.alignment: Qt.AlignHCenter - Layout.preferredHeight: root.props.recordingListExpanded ? implicitHeight : 0 + Layout.preferredHeight: root.screenState.recordingListExpanded ? implicitHeight : 0 color: Colors.palette.m3outline font.pointSize: Tokens.font.size.extraLarge - opacity: root.props.recordingListExpanded ? 1 : 0 - scale: root.props.recordingListExpanded ? 1 : 0 + opacity: root.screenState.recordingListExpanded ? 1 : 0 + scale: root.screenState.recordingListExpanded ? 1 : 0 text: "scan_delete" Behavior on Layout.preferredHeight { @@ -196,10 +201,10 @@ ColumnLayout { MaterialIcon { Layout.alignment: Qt.AlignHCenter - Layout.preferredWidth: !root.props.recordingListExpanded ? implicitWidth : 0 + Layout.preferredWidth: !root.screenState.recordingListExpanded ? implicitWidth : 0 color: Colors.palette.m3outline - opacity: !root.props.recordingListExpanded ? 1 : 0 - scale: !root.props.recordingListExpanded ? 1 : 0 + opacity: !root.screenState.recordingListExpanded ? 1 : 0 + scale: !root.screenState.recordingListExpanded ? 1 : 0 text: "scan_delete" Behavior on Layout.preferredWidth { diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml index f32e6b1..a84f644 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml @@ -12,13 +12,11 @@ import qs.Services import qs.Helpers import qs.Daemons import qs.Modules.Settings -import qs.Modules.Bar.Popouts as BarPopouts CustomRect { id: root readonly property bool needExtraRow: quickToggles.length > 6 - required property BarPopouts.Wrapper popouts readonly property var quickToggles: { const seenIds = new Set(); @@ -40,8 +38,8 @@ CustomRect { return true; }); } + required property ScreenState screenState readonly property int splitIndex: Math.ceil(quickToggles.length / 2) - required property PersistentProperties visibilities color: Colors.tPalette.m3surfaceContainer implicitHeight: layout.implicitHeight + Tokens.padding.extraLargeIncreased @@ -168,8 +166,8 @@ CustomRect { isToggle: false onClicked: { - root.visibilities.sidebar = false; - root.visibilities.settings = true; + root.screenState.sidebar = false; + root.screenState.settings = true; } } } diff --git a/Modules/Notifications/Sidebar/Utils/Content.qml b/Modules/Notifications/Sidebar/Utils/Content.qml index 922eff9..26e5343 100644 --- a/Modules/Notifications/Sidebar/Utils/Content.qml +++ b/Modules/Notifications/Sidebar/Utils/Content.qml @@ -1,16 +1,14 @@ import Quickshell import QtQuick import QtQuick.Layouts -import qs.Modules.Bar.Popouts as BarPopouts +import qs.Components import qs.Modules.Notifications.Sidebar.Utils.Cards import ZShell.Config Item { id: root - required property BarPopouts.Wrapper popouts - required property PersistentProperties props - required property var visibilities + required property ScreenState screenState implicitHeight: layout.implicitHeight implicitWidth: layout.implicitWidth @@ -25,15 +23,13 @@ Item { } Record { - props: root.props - visibilities: root.visibilities + screenState: root.screenState z: 1 } Toggles { Layout.fillWidth: true - popouts: root.popouts - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/Utils/Wrapper.qml b/Modules/Notifications/Sidebar/Utils/Wrapper.qml index 63a01b6..189ad55 100644 --- a/Modules/Notifications/Sidebar/Utils/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Utils/Wrapper.qml @@ -10,17 +10,9 @@ Item { id: root property real offsetScale: shouldBeActive ? 0 : 1 - required property BarPopouts.Wrapper popouts - readonly property PersistentProperties props: PersistentProperties { - property string recordingConfirmDelete - property bool recordingListExpanded: false - property string recordingMode - - reloadableId: "utilities" - } - readonly property bool shouldBeActive: visibilities.sidebar + required property ScreenState screenState + readonly property bool shouldBeActive: screenState.sidebar && !sidebar.chatActive required property Item sidebar - required property var visibilities anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight + 8 * 2 @@ -45,9 +37,7 @@ Item { sourceComponent: Content { implicitWidth: root.implicitWidth - 8 * 2 - popouts: root.popouts - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/Wrapper.qml b/Modules/Notifications/Sidebar/Wrapper.qml index 245247f..6f493da 100644 --- a/Modules/Notifications/Sidebar/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Wrapper.qml @@ -7,12 +7,11 @@ import QtQuick Item { id: root + readonly property bool chatActive: screenState.sidebarTab === 1 property real offsetScale: shouldBeActive ? 0 : 1 required property var panels - readonly property Props props: Props { - } - readonly property bool shouldBeActive: root.visibilities.sidebar && Config.sidebar.enabled - required property var visibilities + required property ScreenState screenState + readonly property bool shouldBeActive: root.screenState.sidebar && Config.sidebar.enabled anchors.rightMargin: (-implicitWidth - 5) * offsetScale implicitWidth: Config.sidebar.sizes.width @@ -38,8 +37,7 @@ Item { sourceComponent: Content { implicitWidth: Config.sidebar.sizes.width - Tokens.padding.small * 2 - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Wrapper.qml b/Modules/Notifications/Wrapper.qml index 03131f8..e010b09 100644 --- a/Modules/Notifications/Wrapper.qml +++ b/Modules/Notifications/Wrapper.qml @@ -7,7 +7,7 @@ Item { required property Item panels required property Item sidebarPanel - required property var visibilities + required property ScreenState screenState implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth @@ -17,6 +17,6 @@ Item { id: content panels: root.panels - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Modules/Osd/Content.qml b/Modules/Osd/Content.qml index d1b8b35..e1ac3d9 100644 --- a/Modules/Osd/Content.qml +++ b/Modules/Osd/Content.qml @@ -16,7 +16,7 @@ Item { required property bool muted required property bool sourceMuted required property real sourceVolume - required property var visibilities + required property ScreenState screenState required property real volume implicitHeight: layout.implicitHeight + Tokens.padding.smaller * 2 @@ -53,7 +53,7 @@ Item { // Microphone volume WrappedLoader { - shouldBeActive: Config.osd.enableMicrophone && (!Config.osd.enableBrightness || !root.visibilities.session) + shouldBeActive: Config.osd.enableMicrophone && (!Config.osd.enableBrightness || !root.screenState.session) sourceComponent: CustomMouseArea { function onWheel(event: WheelEvent) { diff --git a/Modules/Osd/Wrapper.qml b/Modules/Osd/Wrapper.qml index e879a4c..e388beb 100644 --- a/Modules/Osd/Wrapper.qml +++ b/Modules/Osd/Wrapper.qml @@ -16,16 +16,16 @@ Item { property bool muted property real offsetScale: shouldBeActive ? 0 : 1 required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.osd && Config.osd.enabled && !(visibilities.utilities && Config.utilities.enabled) + readonly property bool shouldBeActive: screenState.osd && Config.osd.enabled && !(screenState.utilities && Config.utilities.enabled) property real sidebarOffset: sidebarOrSessionVisible ? 12 : 0 required property bool sidebarOrSessionVisible property bool sourceMuted property real sourceVolume - required property var visibilities + required property ScreenState screenState property real volume function show(): void { - visibilities.osd = true; + screenState.osd = true; timer.restart(); } @@ -90,7 +90,7 @@ Item { onTriggered: { if (!root.hovered) - root.visibilities.osd = false; + root.screenState.osd = false; } } @@ -107,7 +107,7 @@ Item { muted: root.muted sourceMuted: root.sourceMuted sourceVolume: root.sourceVolume - visibilities: root.visibilities + screenState: root.screenState volume: root.volume } } diff --git a/Modules/Resources/Wrapper.qml b/Modules/Resources/Wrapper.qml index 9004694..99adbf0 100644 --- a/Modules/Resources/Wrapper.qml +++ b/Modules/Resources/Wrapper.qml @@ -11,8 +11,8 @@ Item { readonly property real nonAnimHeight: content.item?.nonAnimHeight ?? 0 property real offsetScale: shouldBeActive ? 0 : 1 required property string position - readonly property bool shouldBeActive: root.visibilities.resources - required property PersistentProperties visibilities + readonly property bool shouldBeActive: root.screenState.resources + required property ScreenState screenState anchors.bottom: position === "bottom" ? parent.bottom : undefined anchors.bottomMargin: position === "bottom" ? (-implicitHeight - 5) * offsetScale : 0 diff --git a/Modules/Settings/Common/TextFieldRow.qml b/Modules/Settings/Common/TextFieldRow.qml new file mode 100644 index 0000000..38dd9a8 --- /dev/null +++ b/Modules/Settings/Common/TextFieldRow.qml @@ -0,0 +1,85 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services + +ConnectedRect { + id: root + + property alias label: label.text + property string subtext + property string value + property alias placeholderText: input.placeholderText + property string errorText + property alias maximumLength: input.maximumLength + property alias validate: input.validate + property alias validator: input.validator + property bool smallField + readonly property alias field: input + + signal valueEdited(value: string) + signal editingFinished(value: string) + + function clear(): void { + input.clear(); + } + + Component.onDestruction: { + if (value !== input.text) + editingFinished(input.text); + } + + Layout.fillWidth: true + implicitHeight: rowLayout.implicitHeight + Tokens.padding.medium + Math.max(0, Tokens.padding.large - input.verticalPadding) * 2 + + RowLayout { + id: rowLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + CustomText { + id: label + + Layout.fillWidth: true + font: Tokens.font.body.small + elide: Text.ElideRight + } + + CustomText { + Layout.fillWidth: true + visible: root.subtext + text: input.isError && root.errorText ? root.errorText : root.subtext + color: input.isError && root.errorText ? Colors.palette.m3error : Colors.palette.m3outline + font.pointSize: Tokens.font.size.small + elide: Text.ElideRight + animate: root.errorText + } + } + + CustomTextField { + id: input + + Layout.preferredWidth: root.smallField ? Tokens.sizes.nexus.smallTextFieldWidth : Tokens.sizes.nexus.textFieldWidth + Layout.maximumWidth: root.width / 2 + Layout.alignment: Qt.AlignVCenter + verticalPadding: Tokens.padding.small + + text: root.value + + onTextEdited: root.valueEdited(text) + onEditingFinished: root.editingFinished(text) + } + } +} diff --git a/Modules/Settings/Pages/Panels/DashboardPanel.qml b/Modules/Settings/Pages/Panels/DashboardPanel.qml index 7657761..af8beb8 100644 --- a/Modules/Settings/Pages/Panels/DashboardPanel.qml +++ b/Modules/Settings/Pages/Panels/DashboardPanel.qml @@ -33,5 +33,33 @@ PageBase { onToggled: Config.dashboard.enabled = checked } + + // Behavior + SectionHeader { + text: qsTr("Behavior") + } + + ToggleRow { + checked: Config.dashboard.showOnHover + first: true + settingAnchor: "dashboard-showonhover" + text: qsTr("Show on hover") + subtext: qsTr("Dashboard %1 be shown when hovering the middle-top part of the shell").arg(checked ? "will" : "will not") + + onToggled: Config.dashboard.showOnHover = checked + } + + SpinRow { + from: 30 + settingAnchor: "dashboard-drag-threshold" + stepSize: 20 + enabled: !Config.dashboard.showOnHover + subtext: qsTr("How far you need to drag before the panel opens") + text: qsTr("Drag threshold") + to: 100 + value: Config.dashboard.dragThreshold + + onMoved: v => Config.dashboard.dragThreshold = v + } } } diff --git a/Modules/Settings/Wrapper.qml b/Modules/Settings/Wrapper.qml index 6de8131..a096857 100644 --- a/Modules/Settings/Wrapper.qml +++ b/Modules/Settings/Wrapper.qml @@ -12,8 +12,8 @@ Item { required property var panels property string queuedMode: "wallpaper" required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.settings - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.settings + required property ScreenState screenState implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth diff --git a/Modules/Shortcuts.qml b/Modules/Shortcuts.qml index 68c6662..ca227ca 100644 --- a/Modules/Shortcuts.qml +++ b/Modules/Shortcuts.qml @@ -3,6 +3,7 @@ import Quickshell import Quickshell.Io import qs.Components import qs.Helpers +import qs.Services Scope { id: root @@ -17,8 +18,8 @@ Scope { onPressed: { root.launcherInterrupted = false; if (!root.launcherInterrupted && !root.hasFullscreen) { - const visibilities = Visibilities.getForActive(); - visibilities.launcher = !visibilities.launcher; + const state = ShellState.forActive(); + state.launcher = !state.launcher; } root.launcherInterrupted = false; } @@ -28,8 +29,8 @@ Scope { name: "toggle-drawing" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.isDrawing = !visibilities.isDrawing; + const state = ShellState.forActive(); + state.isDrawing = !state.isDrawing; } } @@ -37,8 +38,8 @@ Scope { name: "toggle-nc" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.sidebar = !visibilities.sidebar; + const state = ShellState.forActive(); + state.sidebar = !state.sidebar; } } @@ -46,8 +47,8 @@ Scope { name: "show-osd" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.osd = !visibilities.osd; + const state = ShellState.forActive(); + state.osd = !state.osd; } } @@ -55,8 +56,8 @@ Scope { name: "toggle-settings" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.settings = !visibilities.settings; + const state = ShellState.forActive(); + state.settings = !state.settings; } } @@ -64,8 +65,8 @@ Scope { name: "toggle-clipboard" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.clipboard = !visibilities.clipboard; + const state = ShellState.forActive(); + state.clipboard = !state.clipboard; } } } diff --git a/Plugins/ZShell/Config/CMakeLists.txt b/Plugins/ZShell/Config/CMakeLists.txt index bee1215..339a024 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -1,5 +1,6 @@ qml_module(ZShell-config URI ZShell.Config + INCLUDE_PREFIX config SOURCES confignode.hpp confignode.cpp configobject.hpp configobject.cpp @@ -14,6 +15,7 @@ qml_module(ZShell-config colors.hpp dashboard.hpp dock.hpp + enums.hpp general.hpp launcher.hpp lock.hpp diff --git a/Plugins/ZShell/Config/dashboard.hpp b/Plugins/ZShell/Config/dashboard.hpp index 97471b8..271c6f5 100644 --- a/Plugins/ZShell/Config/dashboard.hpp +++ b/Plugins/ZShell/Config/dashboard.hpp @@ -48,6 +48,8 @@ class Dashboard : public ConfigObject { QML_ANONYMOUS CFG_PROPERTY(bool, enabled, true) + CFG_PROPERTY(bool, showOnHover, true) + CFG_PROPERTY(int, dragThreshold, 50) CFG_PROPERTY(int, mediaUpdateInterval, 2000) CONFIG_SUBOBJECT(Performance, performance) CFG_PROPERTY(int, resourceUpdateInterval, 1000) diff --git a/Plugins/ZShell/Config/enums.hpp b/Plugins/ZShell/Config/enums.hpp new file mode 100644 index 0000000..9ba61a0 --- /dev/null +++ b/Plugins/ZShell/Config/enums.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace ZShell::config { + +#define ENUM(Name, ...) \ + namespace Name { \ + \ + Q_NAMESPACE \ + QML_ELEMENT \ + \ + enum Enum : quint8 { __VA_ARGS__ }; \ + Q_ENUM_NS(Enum) \ + }; + +ENUM(BarWorkspaceDisplay, Shapes, Text, Icons) +ENUM(BarWorkspaceCapitalisation, Preserve, Upper, Lower) +ENUM(LyricsBackend, Auto, Local, LRCLIB, NetEase) +ENUM(GpuType, Auto, Nvidia, Generic, None) +ENUM(NotifsFullscreen, On, Off) +ENUM(TemperatureUnit, Auto, Celsius, Fahrenheit, Kelvin) +ENUM(DataUnit, Binary, Decimal) +ENUM(ClockFormat, Auto, TwelveHour, TwentyFourHour) + +#undef ENUM + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/general.hpp b/Plugins/ZShell/Config/general.hpp index 9f6a70f..1195e6f 100644 --- a/Plugins/ZShell/Config/general.hpp +++ b/Plugins/ZShell/Config/general.hpp @@ -1,6 +1,7 @@ #pragma once #include "configlist.hpp" #include "configobject.hpp" +#include #include namespace ZShell::config { @@ -141,6 +142,10 @@ class General : public ConfigObject { CFG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss")) CFG_PROPERTY(bool, desktopIcons, true) CONFIG_SUBOBJECT(Idle, idle) + CFG_PROPERTY( + QString, + lyricsDir, + QStandardPaths::writableLocation(QStandardPaths::MusicLocation) + QStringLiteral("/Lyrics/")) CFG_PROPERTY(QString, logo, QString()) CFG_PROPERTY(bool, showOverFullscreen, true) CFG_PROPERTY( diff --git a/Plugins/ZShell/Config/services.hpp b/Plugins/ZShell/Config/services.hpp index 45d2c2e..00e0ea5 100644 --- a/Plugins/ZShell/Config/services.hpp +++ b/Plugins/ZShell/Config/services.hpp @@ -1,6 +1,7 @@ #pragma once #include "configlist.hpp" #include "configobject.hpp" +#include "enums.hpp" namespace ZShell::config { @@ -31,6 +32,7 @@ class Services : public ConfigObject { CFG_PROPERTY(bool, ddcutilService, true) CFG_PROPERTY(QString, defaultPlayer, QStringLiteral("Spotify")) CFG_PROPERTY(QString, gpuType, QStringLiteral("NVIDIA")) + CFG_PROPERTY(LyricsBackend::Enum, lyricsBackend, LyricsBackend::Auto) CFG_PROPERTY(qreal, maxVolume, 1) CFG_PROPERTY(qreal, minBrightness, 0.01) CONFIG_LIST( diff --git a/Plugins/ZShell/Internal/sparklineitem.cpp b/Plugins/ZShell/Internal/sparklineitem.cpp index c4f2314..7c0b2f2 100644 --- a/Plugins/ZShell/Internal/sparklineitem.cpp +++ b/Plugins/ZShell/Internal/sparklineitem.cpp @@ -1,9 +1,11 @@ #include "sparklineitem.hpp" #include +#include #include #include #include +#include namespace ZShell::internal { @@ -35,9 +37,19 @@ void SparklineItem::drawLine( const int len = buffer->count(); if (len < 2 || m_maxValue <= 0.0) return; - const qreal stepX = w / static_cast(m_historyLength - 1); - const qreal startX = - w - (len - 1) * stepX - stepX * m_slideProgress + stepX; + qreal stepX = 0.0; + + if (m_sliding) + stepX = w / static_cast(m_historyLength - 1); + else + stepX = w / static_cast(len - 1); + + qreal startX = 0.0; + + if (m_sliding) + startX = w - (len - 1) * stepX - stepX * m_slideProgress + stepX; + else + startX = 0; const qreal strokePad = qCeil(m_lineWidth / 2); const qreal curvePad = 3.0; @@ -52,24 +64,75 @@ void SparklineItem::drawLine( points.reserve(len); for (int i = 0; i < len; ++i) { + qreal value = 0.0; + + if (m_smoothing > 0.0) { + const int radius = qRound(m_smoothing); + qreal sum = 0.0; + int count = 0; + + for (int j = -radius; j <= radius; ++j) { + const int index = i + j; + + if (index < 0 || index >= len) continue; + + sum += buffer->at(index); + count++; + } + + value = sum / static_cast(count); + } else { + value = buffer->at(i); + } + + value = qBound(0.0, value, m_maxValue); + const qreal x = startX + i * stepX; - const qreal value = qBound(0.0, buffer->at(i), m_maxValue); const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH; + points.append(QPointF(x, y)); } QPainterPath linePath; linePath.moveTo(points[0]); - for (int i = 0; i < points.size() - 1; ++i) { - const QPointF& p0 = points[i]; - const QPointF& p1 = points[i + 1]; + if (m_curveSmoothing && points.size() > 2) { + // 1.0 = standard uniform Catmull-Rom. Lower (e.g. 0.5) = tighter, less overshoot. + const qreal tension = 1.0; + const qreal k = + tension / 6.0; // Catmull-Rom -> Bezier factor (1/6 for uniform) - const qreal ctrlX = (p0.x() + p1.x()) * 0.5; - const QPointF c1(ctrlX, p0.y()); - const QPointF c2(ctrlX, p1.y()); + const int n = points.size(); - linePath.cubicTo(c1, c2, p1); + for (int i = 0; i < n - 1; ++i) { + // Duplicate endpoints so the first/last segments are well-defined + const QPointF& p0 = points[qMax(i - 1, 0)]; + const QPointF& p1 = points[i]; + const QPointF& p2 = points[i + 1]; + const QPointF& p3 = points[qMin(i + 2, n - 1)]; + + QPointF c1 = p1 + (p2 - p0) * k; + QPointF c2 = p2 - (p3 - p1) * k; + + // Keep control points inside the vertical range of the segment's + // endpoints so we never overshoot (no dips below zero, no + // bulges above a peak). Remember: smaller y = higher value. + const qreal segMinY = qMin(p1.y(), p2.y()); + const qreal segMaxY = qMax(p1.y(), p2.y()); + c1.setY(qBound(segMinY, c1.y(), segMaxY)); + c2.setY(qBound(segMinY, c2.y(), segMaxY)); + + linePath.cubicTo(c1, c2, p2); + } + } else { + // Existing horizontal-tangent (smoothstep-style) curve + for (int i = 0; i < points.size() - 1; ++i) { + const QPointF& p0 = points[i]; + const QPointF& p1 = points[i + 1]; + + const qreal ctrlX = (p0.x() + p1.x()) * 0.5; + linePath.cubicTo(QPointF(ctrlX, p0.y()), QPointF(ctrlX, p1.y()), p1); + } } QPen pen(color, m_lineWidth); @@ -224,4 +287,36 @@ void SparklineItem::setLineWidth(qreal width) { update(); } +void SparklineItem::setSliding(bool sliding) { + if (m_sliding == sliding) return; + m_sliding = sliding; + emit slidingChanged(); +} + +bool SparklineItem::sliding() const { + return m_sliding; +}; + +void SparklineItem::setSmoothing(qreal smoothing) { + smoothing = qMax(0.0, smoothing); + + if (qFuzzyCompare(m_smoothing, smoothing)) return; + m_smoothing = smoothing; + emit smoothingChanged(); +} + +qreal SparklineItem::smoothing() const { + return m_smoothing; +}; + +void SparklineItem::setCurveSmoothing(bool sliding) { + if (m_curveSmoothing == sliding) return; + m_curveSmoothing = sliding; + emit curveSmoothingChanged(); +} + +bool SparklineItem::curveSmoothing() const { + return m_curveSmoothing; +}; + } // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/sparklineitem.hpp b/Plugins/ZShell/Internal/sparklineitem.hpp index 3cea1ed..a79be15 100644 --- a/Plugins/ZShell/Internal/sparklineitem.hpp +++ b/Plugins/ZShell/Internal/sparklineitem.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include "circularbuffer.hpp" @@ -40,6 +41,13 @@ class SparklineItem : public QQuickPaintedItem { Q_PROPERTY( qreal lineWidth READ lineWidth WRITE setLineWidth NOTIFY lineWidthChanged) + Q_PROPERTY(bool sliding READ sliding WRITE setSliding NOTIFY slidingChanged) + Q_PROPERTY( + qreal smoothing READ smoothing WRITE setSmoothing NOTIFY + smoothingChanged) + Q_PROPERTY( + bool curveSmoothing READ curveSmoothing WRITE setCurveSmoothing NOTIFY + curveSmoothingChanged) public: explicit SparklineItem(QQuickItem* parent = nullptr); @@ -76,6 +84,15 @@ class SparklineItem : public QQuickPaintedItem { [[nodiscard]] qreal lineWidth() const; void setLineWidth(qreal width); + [[nodiscard]] bool sliding() const; + void setSliding(bool sliding); + + [[nodiscard]] qreal smoothing() const; + void setSmoothing(qreal smoothing); + + [[nodiscard]] bool curveSmoothing() const; + void setCurveSmoothing(bool curveSmoothing); + signals: void line1Changed(); void line2Changed(); @@ -87,6 +104,9 @@ class SparklineItem : public QQuickPaintedItem { void slideProgressChanged(); void historyLengthChanged(); void lineWidthChanged(); + void slidingChanged(); + void smoothingChanged(); + void curveSmoothingChanged(); private: void drawLine( @@ -106,6 +126,9 @@ class SparklineItem : public QQuickPaintedItem { qreal m_slideProgress = 0.0; int m_historyLength = 30; qreal m_lineWidth = 2.0; + bool m_sliding = true; + qreal m_smoothing = 0.0; + bool m_curveSmoothing = false; }; } // namespace ZShell::internal diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index 2dfe707..4dcae3c 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -18,6 +18,8 @@ qml_module(ZShell-services memory.hpp memory.cpp diskinfo.hpp diskinfo.cpp storage.hpp storage.cpp + lyriccandidate.hpp lyriccandidate.cpp + lyrics.hpp lyrics.cpp LIBRARIES Qt6::Core Qt6::Qml @@ -25,4 +27,7 @@ qml_module(ZShell-services PkgConfig::Aubio PkgConfig::Cava Sensors::Sensors - ) + ZShell-config + DEPENDENCIES + ZShell.Config +) diff --git a/Plugins/ZShell/Services/lyriccandidate.cpp b/Plugins/ZShell/Services/lyriccandidate.cpp new file mode 100644 index 0000000..8678769 --- /dev/null +++ b/Plugins/ZShell/Services/lyriccandidate.cpp @@ -0,0 +1,55 @@ +#include "lyriccandidate.hpp" + +namespace ZShell::services { + +LyricCandidate::LyricCandidate( + LyricsBackend backend, + QString id, + QString title, + QString artist, + QString album, + qreal duration) + : m_backend(backend) + , m_id(std::move(id)) + , m_title(std::move(title)) + , m_artist(std::move(artist)) + , m_album(std::move(album)) + , m_duration(duration) {} + +LyricsBackend LyricCandidate::backend() const { + return m_backend; +} + +QString LyricCandidate::id() const { + return m_id; +} + +QString LyricCandidate::title() const { + return m_title; +} + +QString LyricCandidate::artist() const { + return m_artist; +} + +QString LyricCandidate::album() const { + return m_album; +} + +qreal LyricCandidate::duration() const { + return m_duration; +} + +bool LyricCandidate::isValid() const { + return !m_id.isEmpty(); +} + +bool LyricCandidate::operator==(const LyricCandidate& o) const noexcept { + return m_backend == o.m_backend && m_id == o.m_id; +} + +bool LyricCandidate::operator!=(const LyricCandidate& o) const noexcept { + return !(*this == o); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/lyriccandidate.hpp b/Plugins/ZShell/Services/lyriccandidate.hpp new file mode 100644 index 0000000..f3f0700 --- /dev/null +++ b/Plugins/ZShell/Services/lyriccandidate.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +#include "config/enums.hpp" + +namespace ZShell::services { + +using LyricsBackend = config::LyricsBackend::Enum; + +class LyricCandidate { + Q_GADGET + QML_VALUE_TYPE(lyricCandidate) + + Q_PROPERTY(ZShell::config::LyricsBackend::Enum backend READ backend) + Q_PROPERTY(QString id READ id) + Q_PROPERTY(QString title READ title) + Q_PROPERTY(QString artist READ artist) + Q_PROPERTY(QString album READ album) + Q_PROPERTY(qreal duration READ duration) + + public: + LyricCandidate() = default; + LyricCandidate( + LyricsBackend backend, + QString id, + QString title, + QString artist, + QString album = {}, + qreal duration = 0.0); + + [[nodiscard]] LyricsBackend backend() const; + [[nodiscard]] QString id() const; + [[nodiscard]] QString title() const; + [[nodiscard]] QString artist() const; + [[nodiscard]] QString album() const; + [[nodiscard]] qreal duration() const; + + [[nodiscard]] bool isValid() const; + bool operator==(const LyricCandidate& o) const noexcept; + bool operator!=(const LyricCandidate& o) const noexcept; + + private: + LyricsBackend m_backend = LyricsBackend::Auto; + QString m_id; + QString m_title; + QString m_artist; + QString m_album; + qreal m_duration = 0.0; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/lyrics.cpp b/Plugins/ZShell/Services/lyrics.cpp new file mode 100644 index 0000000..6a85e75 --- /dev/null +++ b/Plugins/ZShell/Services/lyrics.cpp @@ -0,0 +1,1178 @@ +#include "lyrics.hpp" + +#include +#include +#include +#include +#include +#include + +#include "config/config.hpp" +#include "config/general.hpp" +#include "config/services.hpp" + +namespace { + +Q_LOGGING_CATEGORY(lcLyrics, "ZShell.lyrics", QtInfoMsg) + +} // namespace + +namespace ZShell::services { + +using Qt::StringLiterals::operator""_s; +using Qt::StringLiterals::operator""_ba; + +namespace { + +constexpr int k_loadDebounceMs = 50; +constexpr qreal k_indexFudge = 0.1; + +[[nodiscard]] const QHash& netEaseHeaders() { + static const QHash k_h = { + {"User-Agent"_ba, + "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"_ba}, + {"Referer"_ba, "https://music.163.com/"_ba}, + }; + return k_h; +} + +[[nodiscard]] const QHash& lrclibHeaders() { + static const QHash k_h = { + {"User-Agent"_ba, + "ZShell-shell (https://github.com/ZShell-dots/shell)"_ba}, + }; + return k_h; +} + +[[nodiscard]] QString joinArtists(const QString& s) { + return s.trimmed(); +} + +[[nodiscard]] QString sanitizeFilenamePart(const QString& s) { + QString out; + out.reserve(s.size()); + for (const QChar c : s) { + if (c == u'/' || c == u'\0') { + out.append(u'_'); + } else { + out.append(c); + } + } + return out; +} + +[[nodiscard]] bool containsCi(const QString& haystack, const QString& needle) { + return haystack.contains(needle, Qt::CaseInsensitive); +} + +} // namespace + +Lyrics::Lyrics(QObject* parent) + : QObject(parent) + , m_nam(new QNetworkAccessManager(this)) + , m_loadDebounce(new QTimer(this)) { + m_loadDebounce->setSingleShot(true); + m_loadDebounce->setInterval(k_loadDebounceMs); + QObject::connect(m_loadDebounce, &QTimer::timeout, this, &Lyrics::doLoad); + + const auto* cfg = config::Config::instance(); + const auto* svcCfg = cfg->services(); + const auto* genCfg = cfg->general(); + + m_preferredBackend = svcCfg->lyricsBackend(); + + QObject::connect( + svcCfg, + &config::Services::lyricsBackendChanged, + this, + &Lyrics::onPreferredBackendConfigChanged); + QObject::connect( + genCfg, + &config::General::lyricsDirChanged, + this, + &Lyrics::onLyricsDirChanged); + + loadLyricsMap(); +} + +QStringList Lyrics::lyrics() const { + return m_lyrics; +} + +LyricsBackend Lyrics::backend() const { + return m_backend; +} + +LyricsBackend Lyrics::preferredBackend() const { + return m_preferredBackend; +} + +void Lyrics::setPreferredBackend(LyricsBackend value) { + if (m_preferredBackend == value) { + return; + } + m_preferredBackend = value; + emit preferredBackendChanged(); + + config::Config::instance()->services()->set_lyricsBackend(value); + + scheduleLoad(); +} + +QList Lyrics::lyricCandidates() const { + return m_candidates; +} + +LyricCandidate Lyrics::selectedCandidate() const { + return m_selected; +} + +void Lyrics::setSelectedCandidate(const LyricCandidate& value) { + if (m_selected == value) { + return; + } + m_selected = value; + emit selectedCandidateChanged(); + + if (!value.isValid()) { + return; + } + + const auto b = value.backend(); + setBackend(b); + setLoading(true); + + cancelInFlight(); + const int reqId = newRequestId(); + + if (b == LyricsBackend::LRCLIB || b == LyricsBackend::NetEase) { + const QString cached = readCachedLrc(b, value.id()); + if (!cached.isEmpty()) { + const auto lines = parseLrc(cached); + if (!lines.isEmpty()) { + setLines(lines, b); + setLoading(false); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + return; + } + } + } + + if (b == LyricsBackend::LRCLIB) { + fetchLrclibById(value.id(), reqId); + } else if (b == LyricsBackend::NetEase) { + fetchNetEaseLyricsById(value.id(), reqId); + } else if (b == LyricsBackend::Local) { + // For local, the id is the file path. Read directly. + QFile f(value.id()); + if (f.open(QIODevice::ReadOnly)) { + const QString text = QString::fromUtf8(f.readAll()); + setLines(parseLrc(text), LyricsBackend::Local); + setLoading(false); + } else { + qCWarning(lcLyrics) + << "selectedCandidate: cannot open local file" << value.id(); + setLoading(false); + } + } + + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } +} + +bool Lyrics::loading() const { + return m_loading; +} + +bool Lyrics::hasLyrics() const { + return m_hasLyrics; +} + +qreal Lyrics::offset() const { + return m_offset; +} + +void Lyrics::setOffset(qreal value) { + if (qFuzzyCompare(m_offset, value)) { + return; + } + m_offset = value; + emit offsetChanged(); + + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } +} + +QString Lyrics::trackArtist() const { + return m_artist; +} + +QString Lyrics::trackTitle() const { + return m_title; +} + +int Lyrics::indexForTime(qreal time) const { + if (m_lines.isEmpty()) { + return -1; + } + const qreal target = time - m_offset + k_indexFudge; + qsizetype lo = 0; + qsizetype hi = m_lines.size(); + while (lo < hi) { + const qsizetype mid = lo + (hi - lo) / 2; + if (m_lines.at(mid).time <= target) { + lo = mid + 1; + } else { + hi = mid; + } + } + return static_cast(lo - 1); +} + +qreal Lyrics::timeForIndex(int index) const { + if (index < 0 || index >= m_lines.size()) { + return -1.0; + } + return m_lines.at(index).time + m_offset; +} + +void Lyrics::setTrack( + const QString& artist, + const QString& title, + const QString& album, + qreal duration) { + const QString a = artist.trimmed(); + const QString t = title.trimmed(); + + if (a == m_artist && t == m_title && album == m_album && + qFuzzyCompare(duration + 1.0, m_duration + 1.0)) { + return; + } + + m_artist = a; + m_title = t; + m_album = album; + m_duration = duration; + emit trackChanged(); + + scheduleLoad(); +} + +void Lyrics::clearTrack() { + cancelInFlight(); + m_artist.clear(); + m_title.clear(); + m_album.clear(); + m_duration = 0.0; + emit trackChanged(); + + clearCandidates(); + clearLines(); + setLoading(false); +} + +void Lyrics::refresh() { + scheduleLoad(); +} + +void Lyrics::setBackend(LyricsBackend value) { + if (m_backend == value) { + return; + } + m_backend = value; + emit backendChanged(); +} + +void Lyrics::setLoading(bool value) { + if (m_loading == value) { + return; + } + m_loading = value; + emit loadingChanged(); +} + +void Lyrics::setLines(QVector lines, LyricsBackend source) { + std::ranges::sort(lines, [](const LyricLine& a, const LyricLine& b) { + return a.time < b.time; + }); + + m_lines = std::move(lines); + QStringList list; + list.reserve(m_lines.size()); + for (const auto& l : std::as_const(m_lines)) { + list.append(l.text); + } + m_lyrics = std::move(list); + + setBackend(source); + emit lyricsChanged(); + + const auto hasLyrics = !m_lines.isEmpty(); + if (hasLyrics != m_hasLyrics) { + m_hasLyrics = hasLyrics; + emit hasLyricsChanged(); + } +} + +void Lyrics::clearLines() { + if (!m_hasLyrics) { + return; + } + + // Doesn't actually clear lines, set a flag instead so anims can run + m_hasLyrics = false; + emit hasLyricsChanged(); +} + +void Lyrics::appendCandidates(const QList& add) { + if (add.isEmpty()) { + return; + } + bool changed = false; + for (const auto& c : add) { + if (!m_candidates.contains(c)) { + m_candidates.append(c); + changed = true; + } + } + if (changed) { + emit lyricCandidatesChanged(); + } +} + +void Lyrics::clearCandidates() { + if (m_candidates.isEmpty()) { + return; + } + m_candidates.clear(); + emit lyricCandidatesChanged(); +} + +void Lyrics::scheduleLoad() { + m_loadDebounce->start(); +} + +int Lyrics::newRequestId() { + return ++m_currentRequestId; +} + +void Lyrics::cancelInFlight() { + for (auto it = m_pendingReplies.begin(); it != m_pendingReplies.end(); + ++it) { + for (auto& ptr : it.value()) { + if (auto* reply = ptr.data()) { + reply->abort(); + reply->deleteLater(); + } + } + } + m_pendingReplies.clear(); +} + +void Lyrics::trackReply(int reqId, QNetworkReply* reply) { + if (!reply) { + return; + } + m_pendingReplies[reqId].append(QPointer(reply)); +} + +void Lyrics::doLoad() { + if (m_artist.isEmpty() && m_title.isEmpty()) { + clearLines(); + clearCandidates(); + setLoading(false); + return; + } + + cancelInFlight(); + const int reqId = newRequestId(); + + setLoading(true); + clearLines(); + clearCandidates(); + + // Restore per-track prefs (offset, last-selected backend/id) + m_settingFromPrefs = true; + const QJsonObject saved = m_lyricsMap.value(trackKey()).toObject(); + setOffset(saved.value(u"offset"_s).toDouble(0.0)); + LyricCandidate restored; + const QString savedBackendKey = saved.value(u"backend"_s).toString(); + const QString savedId = saved.value(u"id"_s).toString(); + if (!savedBackendKey.isEmpty() && !savedId.isEmpty()) { + restored = LyricCandidate( + backendFromKey(savedBackendKey), + savedId, + m_title, + m_artist, + m_album, + m_duration); + } + m_settingFromPrefs = false; + + // Always populate online candidates for the picker, regardless of preferred backend + searchLrclibCandidates(reqId); + searchNetEaseCandidates(reqId); + + if (restored.isValid()) { + // Honor saved selection for this track + m_settingFromPrefs = true; + setSelectedCandidate(restored); + m_settingFromPrefs = false; + return; + } + + // Primary attempt by preferred backend + switch (m_preferredBackend) { + case LyricsBackend::Local: + tryLocal(reqId); + break; + case LyricsBackend::LRCLIB: + tryLrclib(reqId); + break; + case LyricsBackend::NetEase: + tryNetEase(reqId); + break; + case LyricsBackend::Auto: + default: + tryLocal(reqId); + break; + } +} + +void Lyrics::chainNext(LyricsBackend justFailed, int reqId) { + if (m_preferredBackend != LyricsBackend::Auto) { + // Non-auto modes don't chain + setLoading(false); + return; + } + switch (justFailed) { + case LyricsBackend::Local: + tryLrclib(reqId); + return; + case LyricsBackend::LRCLIB: + tryNetEase(reqId); + return; + case LyricsBackend::NetEase: + default: + setLoading(false); + return; + } +} + +void Lyrics::tryLocal(int reqId) { + if (reqId != m_currentRequestId) { + return; + } + + setBackend(LyricsBackend::Local); + + const QString dir = lyricsDir(); + if (dir.isEmpty()) { + chainNext(LyricsBackend::Local, reqId); + return; + } + + const QString direct = tryReadLocalLrc(dir, m_artist, m_title); + if (!direct.isEmpty()) { + QFile f(direct); + if (f.open(QIODevice::ReadOnly)) { + const QString text = QString::fromUtf8(f.readAll()); + const auto lines = parseLrc(text); + if (!lines.isEmpty()) { + setLines(lines, LyricsBackend::Local); + appendCandidates({LyricCandidate( + LyricsBackend::Local, + direct, + m_title, + m_artist, + m_album, + m_duration)}); + m_selected = LyricCandidate( + LyricsBackend::Local, + direct, + m_title, + m_artist, + m_album, + m_duration); + emit selectedCandidateChanged(); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + setLoading(false); + return; + } + } + } + + const QString recursive = findLocalLrcRecursive(dir, m_artist, m_title); + if (!recursive.isEmpty()) { + QFile f(recursive); + if (f.open(QIODevice::ReadOnly)) { + const QString text = QString::fromUtf8(f.readAll()); + const auto lines = parseLrc(text); + if (!lines.isEmpty()) { + setLines(lines, LyricsBackend::Local); + appendCandidates({LyricCandidate( + LyricsBackend::Local, + recursive, + m_title, + m_artist, + m_album, + m_duration)}); + m_selected = LyricCandidate( + LyricsBackend::Local, + recursive, + m_title, + m_artist, + m_album, + m_duration); + emit selectedCandidateChanged(); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + setLoading(false); + return; + } + } + } + + qCDebug(lcLyrics) << "no local lrc for" << m_artist << "-" << m_title; + chainNext(LyricsBackend::Local, reqId); +} + +void Lyrics::tryLrclib(int reqId) { + if (reqId != m_currentRequestId) { + return; + } + + setBackend(LyricsBackend::LRCLIB); + + QUrl url(u"https://lrclib.net/api/get"_s); + QUrlQuery q; + q.addQueryItem(u"track_name"_s, m_title); + q.addQueryItem(u"artist_name"_s, m_artist); + if (!m_album.isEmpty()) { + q.addQueryItem(u"album_name"_s, m_album); + } + + constexpr qreal k_maxDurationSecs = std::numeric_limits::max(); + if (m_duration > 0 && qIsFinite(m_duration) && + m_duration < k_maxDurationSecs) { + q.addQueryItem(u"duration"_s, QString::number(qRound(m_duration))); + } + url.setQuery(q); + + auto* reply = getJson(url, lrclibHeaders()); + trackReply(reqId, reply); + + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) << "lrclib /get error:" << reply->errorString(); + chainNext(LyricsBackend::LRCLIB, reqId); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonObject obj = doc.object(); + const QString synced = obj.value(u"syncedLyrics"_s).toString(); + const qint64 id = static_cast(obj.value(u"id"_s).toDouble()); + + if (synced.isEmpty()) { + qCDebug(lcLyrics) + << "lrclib: no syncedLyrics for" << m_artist << "-" << m_title; + chainNext(LyricsBackend::LRCLIB, reqId); + return; + } + + const auto lines = parseLrc(synced); + if (lines.isEmpty()) { + chainNext(LyricsBackend::LRCLIB, reqId); + return; + } + + writeCachedLrc(LyricsBackend::LRCLIB, QString::number(id), synced); + setLines(lines, LyricsBackend::LRCLIB); + const LyricCandidate cand( + LyricsBackend::LRCLIB, + QString::number(id), + obj.value(u"trackName"_s).toString(), + obj.value(u"artistName"_s).toString(), + obj.value(u"albumName"_s).toString(), + obj.value(u"duration"_s).toDouble()); + appendCandidates({cand}); + m_selected = cand; + emit selectedCandidateChanged(); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + setLoading(false); + }); +} + +void Lyrics::tryNetEase(int reqId) { + if (reqId != m_currentRequestId) { + return; + } + + setBackend(LyricsBackend::NetEase); + + // Reset cookies (LyricsBackend::NetEase rejects requests with stale cookies sometimes) + m_nam->setCookieJar(new QNetworkCookieJar(m_nam)); + + QUrl url(u"https://music.163.com/api/search/get"_s); + QUrlQuery q; + q.addQueryItem(u"s"_s, u"%1 %2"_s.arg(m_title, m_artist)); + q.addQueryItem(u"type"_s, u"1"_s); + q.addQueryItem(u"limit"_s, u"5"_s); + url.setQuery(q); + + auto* reply = getJson(url, netEaseHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) + << "netease /search error:" << reply->errorString(); + chainNext(LyricsBackend::NetEase, reqId); + return; + } + + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonArray songs = doc.object() + .value(u"result"_s) + .toObject() + .value(u"songs"_s) + .toArray(); + + // Find best match by artist substring + qint64 bestId = -1; + for (const auto& v : songs) { + const QJsonObject s = v.toObject(); + const QJsonArray artists = s.value(u"artists"_s).toArray(); + if (artists.isEmpty()) { + continue; + } + const QString sArtist = + artists.first().toObject().value(u"name"_s).toString(); + if (containsCi(m_artist, sArtist) || + containsCi(sArtist, m_artist)) { + bestId = static_cast(s.value(u"id"_s).toDouble()); + break; + } + } + + if (bestId < 0) { + qCDebug(lcLyrics) << "netease: no artist match for" << m_artist + << "-" << m_title; + chainNext(LyricsBackend::NetEase, reqId); + return; + } + + fetchNetEaseLyricsById(QString::number(bestId), reqId); + }); +} + +void Lyrics::searchLrclibCandidates(int reqId) { + QUrl url(u"https://lrclib.net/api/search"_s); + QUrlQuery q; + q.addQueryItem(u"track_name"_s, m_title); + q.addQueryItem(u"artist_name"_s, m_artist); + url.setQuery(q); + + auto* reply = getJson(url, lrclibHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) + << "lrclib /search error:" << reply->errorString(); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonArray arr = doc.array(); + + QList add; + add.reserve(arr.size()); + for (const auto& v : arr) { + const QJsonObject o = v.toObject(); + if (o.value(u"syncedLyrics"_s).isNull() && + o.value(u"plainLyrics"_s).isNull()) { + continue; + } + add.append(LyricCandidate( + LyricsBackend::LRCLIB, + QString::number( + static_cast(o.value(u"id"_s).toDouble())), + o.value(u"trackName"_s).toString(), + o.value(u"artistName"_s).toString(), + o.value(u"albumName"_s).toString(), + o.value(u"duration"_s).toDouble())); + } + appendCandidates(add); + }); +} + +void Lyrics::searchNetEaseCandidates(int reqId) { + m_nam->setCookieJar(new QNetworkCookieJar(m_nam)); + + QUrl url(u"https://music.163.com/api/search/get"_s); + QUrlQuery q; + q.addQueryItem(u"s"_s, u"%1 %2"_s.arg(m_title, m_artist)); + q.addQueryItem(u"type"_s, u"1"_s); + q.addQueryItem(u"limit"_s, u"5"_s); + url.setQuery(q); + + auto* reply = getJson(url, netEaseHeaders()); + trackReply(reqId, reply); + + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) + << "netease candidates error:" << reply->errorString(); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonArray songs = doc.object() + .value(u"result"_s) + .toObject() + .value(u"songs"_s) + .toArray(); + + QList add; + add.reserve(songs.size()); + for (const auto& v : songs) { + const QJsonObject s = v.toObject(); + QStringList artistNames; + const QJsonArray artists = s.value(u"artists"_s).toArray(); + artistNames.reserve(artists.size()); + for (const auto& a : artists) { + artistNames.append(a.toObject().value(u"name"_s).toString()); + } + add.append(LyricCandidate( + LyricsBackend::NetEase, + QString::number( + static_cast(s.value(u"id"_s).toDouble())), + s.value(u"name"_s).toString(), + artistNames.join(u", "_s))); + } + appendCandidates(add); + }); +} + +void Lyrics::fetchLrclibById(const QString& id, int reqId) { + const QUrl url(u"https://lrclib.net/api/get/"_s + id); + auto* reply = getJson(url, lrclibHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId, id] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCWarning(lcLyrics) + << "lrclib /get/{id} error:" << reply->errorString(); + setLoading(false); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QString synced = + doc.object().value(u"syncedLyrics"_s).toString(); + if (synced.isEmpty()) { + qCDebug(lcLyrics) << "lrclib /get/{id}: no syncedLyrics"; + setLoading(false); + return; + } + writeCachedLrc(LyricsBackend::LRCLIB, id, synced); + setLines(parseLrc(synced), LyricsBackend::LRCLIB); + setLoading(false); + }); +} + +void Lyrics::fetchNetEaseLyricsById(const QString& id, int reqId) { + QUrl url(u"https://music.163.com/api/song/lyric"_s); + QUrlQuery q; + q.addQueryItem(u"id"_s, id); + q.addQueryItem(u"lv"_s, u"1"_s); + q.addQueryItem(u"kv"_s, u"1"_s); + q.addQueryItem(u"tv"_s, u"-1"_s); + url.setQuery(q); + + auto* reply = getJson(url, netEaseHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId, id] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCWarning(lcLyrics) + << "netease /lyric error:" << reply->errorString(); + setLoading(false); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QString lrc = doc.object() + .value(u"lrc"_s) + .toObject() + .value(u"lyric"_s) + .toString(); + if (lrc.isEmpty()) { + qCDebug(lcLyrics) << "netease /lyric: empty for id" << id; + setLoading(false); + return; + } + writeCachedLrc(LyricsBackend::NetEase, id, lrc); + setLines(parseLrc(lrc), LyricsBackend::NetEase); + setLoading(false); + }); +} + +QNetworkReply* Lyrics::getJson( + const QUrl& url, const QHash& headers) { + QNetworkRequest req(url); + req.setAttribute( + QNetworkRequest::CacheLoadControlAttribute, + QNetworkRequest::AlwaysNetwork); + req.setRawHeader("Cache-Control"_ba, "no-cache, no-store"_ba); + req.setRawHeader("Pragma"_ba, "no-cache"_ba); + req.setRawHeader("Connection"_ba, "close"_ba); + req.setRawHeader("Accept"_ba, "application/json"_ba); + for (auto it = headers.constBegin(); it != headers.constEnd(); ++it) { + req.setRawHeader(it.key(), it.value()); + } + return m_nam->get(req); +} + +void Lyrics::onPreferredBackendConfigChanged() { + const LyricsBackend desired = + config::Config::instance()->services()->lyricsBackend(); + if (desired == m_preferredBackend) { + return; + } + m_preferredBackend = desired; + emit preferredBackendChanged(); + scheduleLoad(); +} + +void Lyrics::onLyricsDirChanged() { + scheduleLoad(); +} + +void Lyrics::loadLyricsMap() { + m_lyricsMap = {}; + m_lyricsMapLoaded = false; + + QFile f(lyricsMapPath()); + if (!f.open(QIODevice::ReadOnly)) { + m_lyricsMapLoaded = true; + return; + } + const QByteArray bytes = f.readAll(); + f.close(); + + QJsonParseError err{}; + const QJsonDocument doc = QJsonDocument::fromJson(bytes, &err); + if (err.error != QJsonParseError::NoError) { + qCWarning(lcLyrics) + << "lyrics_map.json parse error:" << err.errorString(); + m_lyricsMapLoaded = true; + return; + } + m_lyricsMap = doc.object(); + m_lyricsMapLoaded = true; +} + +void Lyrics::persistTrackPrefs() { + if (!m_lyricsMapLoaded || trackKey().isEmpty()) { + return; + } + const QString key = trackKey(); + QJsonObject entry = m_lyricsMap.value(key).toObject(); + entry.insert(u"offset"_s, m_offset); + if (m_selected.isValid()) { + entry.insert(u"backend"_s, backendKey(m_selected.backend())); + entry.insert(u"id"_s, m_selected.id()); + } + m_lyricsMap.insert(key, entry); + + QDir().mkpath(stateDir()); + + QSaveFile out(lyricsMapPath()); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + qCWarning(lcLyrics) << "cannot open" << lyricsMapPath() + << "for write:" << out.errorString(); + return; + } + const QByteArray bytes = + QJsonDocument(m_lyricsMap).toJson(QJsonDocument::Compact); + if (out.write(bytes) != bytes.size()) { + qCWarning(lcLyrics) << "short write to" << lyricsMapPath(); + out.cancelWriting(); + return; + } + if (!out.commit()) { + qCWarning(lcLyrics) << "commit failed for" << lyricsMapPath() << ":" + << out.errorString(); + } +} + +QString Lyrics::lyricsDir() { + QString dir = config::Config::instance()->general()->lyricsDir(); + if (dir.isEmpty()) { + return {}; + } + if (dir == u"~"_s) { + dir = QDir::homePath(); + } else if (dir.startsWith(u"~/"_s)) { + dir.replace(0, 1, QDir::homePath()); + } + while (dir.endsWith(u'/') && dir.size() > 1) { + dir.chop(1); + } + return dir; +} + +QString Lyrics::lyricsMapPath() { + return stateDir() + u"/lyrics_map.json"_s; +} + +QString Lyrics::trackKey() const { + if (m_artist.isEmpty() && m_title.isEmpty()) { + return {}; + } + return u"%1 - %2"_s.arg(joinArtists(m_artist), m_title); +} + +QString Lyrics::backendKey(LyricsBackend value) { + switch (value) { + case LyricsBackend::Local: + return u"Local"_s; + case LyricsBackend::LRCLIB: + return u"LRCLIB"_s; + case LyricsBackend::NetEase: + return u"NetEase"_s; + case LyricsBackend::Auto: + default: + return u"Auto"_s; + } +} + +LyricsBackend Lyrics::backendFromKey(const QString& key) { + if (key.compare(u"Local"_s, Qt::CaseInsensitive) == 0) { + return LyricsBackend::Local; + } + if (key.compare(u"LRCLIB"_s, Qt::CaseInsensitive) == 0) { + return LyricsBackend::LRCLIB; + } + if (key.compare(u"NetEase"_s, Qt::CaseInsensitive) == 0) { + return LyricsBackend::NetEase; + } + return LyricsBackend::Auto; +} + +const QString& Lyrics::stateDir() { + static const QString k_dir = [] { + QString state = qEnvironmentVariable("XDG_STATE_HOME"); + if (state.isEmpty()) { + state = QDir::homePath() + u"/.local/state"_s; + } + return state + u"/ZShell/lyrics"_s; + }(); + return k_dir; +} + +const QString& Lyrics::cacheDir() { + static const QString k_dir = [] { + QString cache = qEnvironmentVariable("XDG_CACHE_HOME"); + if (cache.isEmpty()) { + cache = QDir::homePath() + u"/.cache"_s; + } + return cache + u"/ZShell/lyrics"_s; + }(); + return k_dir; +} + +QString Lyrics::cachePathFor(LyricsBackend backend, const QString& id) { + if (id.isEmpty() || backend == LyricsBackend::Auto || + backend == LyricsBackend::Local) { + return {}; + } + return u"%1/%2/%3.lrc"_s.arg( + cacheDir(), backendKey(backend), sanitizeFilenamePart(id)); +} + +QString Lyrics::readCachedLrc(LyricsBackend backend, const QString& id) { + const QString path = cachePathFor(backend, id); + if (path.isEmpty()) { + return {}; + } + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + return {}; + } + return QString::fromUtf8(f.readAll()); +} + +void Lyrics::writeCachedLrc( + LyricsBackend backend, const QString& id, const QString& text) { + if (text.isEmpty()) { + return; + } + const QString path = cachePathFor(backend, id); + if (path.isEmpty()) { + return; + } + QDir().mkpath(QFileInfo(path).absolutePath()); + + QSaveFile out(path); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + qCWarning(lcLyrics) + << "cannot open" << path << "for write:" << out.errorString(); + return; + } + const QByteArray bytes = text.toUtf8(); + if (out.write(bytes) != bytes.size()) { + qCWarning(lcLyrics) << "short write to" << path; + out.cancelWriting(); + return; + } + if (!out.commit()) { + qCWarning(lcLyrics) + << "commit failed for" << path << ":" << out.errorString(); + } +} + +QString Lyrics::tryReadLocalLrc( + const QString& dir, const QString& artist, const QString& title) { + if (artist.isEmpty() && title.isEmpty()) { + return {}; + } + const QString flat = u"%1/%2 - %3.lrc"_s.arg( + dir, sanitizeFilenamePart(artist), sanitizeFilenamePart(title)); + return QFile::exists(flat) ? flat : QString(); +} + +QString Lyrics::findLocalLrcRecursive( + const QString& dir, const QString& artist, const QString& title) { + if (dir.isEmpty()) { + return {}; + } + if (artist.isEmpty() && title.isEmpty()) { + return {}; + } + + QDirIterator it( + dir, + QStringList{u"*.lrc"_s}, + QDir::Files | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); + + while (it.hasNext()) { + const QString path = it.next(); + const QString name = it.fileName(); + if ((artist.isEmpty() || containsCi(name, artist)) && + (title.isEmpty() || containsCi(name, title))) { + return path; + } + } + return {}; +} + +QVector Lyrics::parseLrc(const QString& text) { + QVector result; + if (text.isEmpty()) { + return result; + } + + static const QRegularExpression k_timeRegex( + u"\\[(\\d+):(\\d+(?:\\.\\d+)?)\\]"_s); + static const QStringList k_creditKeywords = { + u"作词"_s, + u"作曲"_s, + u"编曲"_s, + u"制作"_s, + u"收录"_s, + u"演奏"_s, + u"词:"_s, + u"曲:"_s, + u"Lyricist"_s, + u"Composer"_s, + u"Arranger"_s, + u"Producer"_s, + u"Mixing"_s, + u"Mastering"_s, + }; + + const QStringList lines = text.split(u'\n'); + for (const QString& line : lines) { + QList matches; + auto it = k_timeRegex.globalMatch(line); + while (it.hasNext()) { + matches.append(it.next()); + } + if (matches.isEmpty()) { + continue; + } + + QString lyric = line; + lyric.replace(k_timeRegex, QString()); + lyric = lyric.trimmed(); + + const qreal firstTime = matches.first().captured(1).toInt() * 60.0 + + matches.first().captured(2).toDouble(); + + if (firstTime < 20.0) { + bool isCredit = false; + for (const QString& k : k_creditKeywords) { + if (lyric.contains(k, Qt::CaseInsensitive)) { + isCredit = true; + break; + } + } + if (isCredit && + (lyric.contains(u':') || lyric.contains(QChar(0xFF1A)) || + lyric.size() < 25)) { + continue; + } + } + + for (const auto& m : matches) { + const qreal t = + m.captured(1).toInt() * 60.0 + m.captured(2).toDouble(); + result.append(LyricLine{.time = t, .text = lyric}); + } + } + + std::ranges::sort(result, [](const LyricLine& a, const LyricLine& b) { + return a.time < b.time; + }); + + return result; +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/lyrics.hpp b/Plugins/ZShell/Services/lyrics.hpp new file mode 100644 index 0000000..864af88 --- /dev/null +++ b/Plugins/ZShell/Services/lyrics.hpp @@ -0,0 +1,162 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "lyriccandidate.hpp" + +namespace ZShell::services { + +struct LyricLine { + qreal time = 0.0; + QString text; +}; + +class Lyrics : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(QStringList lyrics READ lyrics NOTIFY lyricsChanged) + Q_PROPERTY( + ZShell::config::LyricsBackend::Enum backend READ backend NOTIFY + backendChanged) + Q_PROPERTY( + ZShell::config::LyricsBackend::Enum preferredBackend READ + preferredBackend WRITE setPreferredBackend NOTIFY + preferredBackendChanged) + Q_PROPERTY( + QList lyricCandidates READ + lyricCandidates NOTIFY lyricCandidatesChanged) + Q_PROPERTY( + ZShell::services::LyricCandidate selectedCandidate READ selectedCandidate + WRITE setSelectedCandidate NOTIFY selectedCandidateChanged) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + Q_PROPERTY(bool hasLyrics READ hasLyrics NOTIFY hasLyricsChanged) + Q_PROPERTY(qreal offset READ offset WRITE setOffset NOTIFY offsetChanged) + Q_PROPERTY(QString trackArtist READ trackArtist NOTIFY trackChanged) + Q_PROPERTY(QString trackTitle READ trackTitle NOTIFY trackChanged) + + public: + explicit Lyrics(QObject* parent = nullptr); + + [[nodiscard]] QStringList lyrics() const; + [[nodiscard]] LyricsBackend backend() const; + [[nodiscard]] LyricsBackend preferredBackend() const; + void setPreferredBackend(LyricsBackend value); + [[nodiscard]] QList lyricCandidates() const; + [[nodiscard]] LyricCandidate selectedCandidate() const; + void setSelectedCandidate(const LyricCandidate& value); + [[nodiscard]] bool loading() const; + [[nodiscard]] bool hasLyrics() const; + [[nodiscard]] qreal offset() const; + void setOffset(qreal value); + [[nodiscard]] QString trackArtist() const; + [[nodiscard]] QString trackTitle() const; + + [[nodiscard]] Q_INVOKABLE int indexForTime(qreal time) const; + [[nodiscard]] Q_INVOKABLE qreal timeForIndex(int index) const; + Q_INVOKABLE void setTrack( + const QString& artist, + const QString& title, + const QString& album = {}, + qreal duration = 0.0); + Q_INVOKABLE void clearTrack(); + Q_INVOKABLE void refresh(); + + signals: + void lyricsChanged(); + void backendChanged(); + void preferredBackendChanged(); + void lyricCandidatesChanged(); + void selectedCandidateChanged(); + void loadingChanged(); + void hasLyricsChanged(); + void offsetChanged(); + void trackChanged(); + + private: + void setBackend(LyricsBackend value); + void setLoading(bool value); + void setLines(QVector lines, LyricsBackend source); + void clearLines(); + void appendCandidates(const QList& add); + void clearCandidates(); + + void scheduleLoad(); + void doLoad(); + void cancelInFlight(); + int newRequestId(); + + void tryLocal(int reqId); + void tryLrclib(int reqId); + void tryNetEase(int reqId); + void chainNext(LyricsBackend justFailed, int reqId); + + void searchLrclibCandidates(int reqId); + void searchNetEaseCandidates(int reqId); + + void fetchLrclibById(const QString& id, int reqId); + void fetchNetEaseLyricsById(const QString& id, int reqId); + + QNetworkReply* getJson( + const QUrl& url, const QHash& headers = {}); + void trackReply(int reqId, QNetworkReply* reply); + + void onPreferredBackendConfigChanged(); + void onLyricsDirChanged(); + + void loadLyricsMap(); + void persistTrackPrefs(); + + [[nodiscard]] static QString lyricsDir(); + [[nodiscard]] static QString lyricsMapPath(); + [[nodiscard]] QString trackKey() const; + [[nodiscard]] static QString backendKey(LyricsBackend value); + [[nodiscard]] static LyricsBackend backendFromKey(const QString& key); + + [[nodiscard]] static const QString& stateDir(); + [[nodiscard]] static const QString& cacheDir(); + [[nodiscard]] static QString cachePathFor( + LyricsBackend backend, const QString& id); + [[nodiscard]] static QString readCachedLrc( + LyricsBackend backend, const QString& id); + static void writeCachedLrc( + LyricsBackend backend, const QString& id, const QString& text); + + [[nodiscard]] static QVector parseLrc(const QString& text); + [[nodiscard]] static QString tryReadLocalLrc( + const QString& dir, const QString& artist, const QString& title); + [[nodiscard]] static QString findLocalLrcRecursive( + const QString& dir, const QString& artist, const QString& title); + + QNetworkAccessManager* m_nam; + QTimer* m_loadDebounce; + + QVector m_lines; + QStringList m_lyrics; + LyricsBackend m_backend = LyricsBackend::Auto; + LyricsBackend m_preferredBackend = LyricsBackend::Auto; + QList m_candidates; + LyricCandidate m_selected; + bool m_loading = false; + bool m_hasLyrics = false; + qreal m_offset = 0.0; + + QString m_artist; + QString m_title; + QString m_album; + qreal m_duration = 0.0; + + int m_currentRequestId = 0; + QHash>> m_pendingReplies; + + QJsonObject m_lyricsMap; + bool m_lyricsMapLoaded = false; + bool m_settingFromPrefs = false; +}; + +} // namespace ZShell::services diff --git a/Plugins/cmake/qml-module.cmake b/Plugins/cmake/qml-module.cmake new file mode 100644 index 0000000..0b8ac52 --- /dev/null +++ b/Plugins/cmake/qml-module.cmake @@ -0,0 +1,55 @@ +message(STATUS "QML install dir: ${CMAKE_INSTALL_PREFIX}/${INSTALL_QMLDIR}") + +function(qml_module arg_TARGET) + cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI;INCLUDE_PREFIX" "SOURCES;QML_FILES;QML_SINGLETONS;DEPENDENCIES;IMPORTS;OPTIONAL_IMPORTS;DEFAULT_IMPORTS;LIBRARIES") + + set_source_files_properties(${arg_QML_SINGLETONS} PROPERTIES QT_QML_SINGLETON_TYPE TRUE) + + 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 zshell-pch Qt::Core Qt::Qml ${arg_LIBRARIES}) + + if(arg_INCLUDE_PREFIX) + set(include_dir "${CMAKE_CURRENT_BINARY_DIR}/include") + file(MAKE_DIRECTORY "${include_dir}") + file(CREATE_LINK "${CMAKE_CURRENT_SOURCE_DIR}" "${include_dir}/${arg_INCLUDE_PREFIX}" SYMBOLIC) + target_include_directories(${arg_TARGET} PUBLIC "${include_dir}") + endif() + + 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/Services/ShellState.qml b/Services/ShellState.qml new file mode 100644 index 0000000..b806f4d --- /dev/null +++ b/Services/ShellState.qml @@ -0,0 +1,35 @@ +pragma Singleton + +import QtQuick +import Quickshell +import qs.Components +import qs.Helpers + +Singleton { + function anySidebarOpen(): bool { + return states.instances.some(s => s.sidebar); + } + + function forActive(): ScreenState { + const mon = Hypr.focusedMonitor; + for (const s of states.instances) + if (Hypr.monitorFor(s.modelData) === mon) + return s; + return null; + } + + function forScreen(screen: ShellScreen): ScreenState { + for (const s of states.instances) + if (s.modelData === screen) + return s; + return null; + } + + Variants { + id: states + + model: Quickshell.screens + + ScreenState {} + } +} From f825e4d53b90cdf1e9870d7522feb4fb7eaa5f1a Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 21 Sep 2026 15:37:45 +0200 Subject: [PATCH 14/90] feat: fontbuilder to streamline font styling --- Components/BaseStyledSlider.qml | 63 ++-- Components/ButtonBase.qml | 6 +- Components/CustomRadioButton.qml | 7 +- Components/CustomSlider.qml | 16 +- Components/CustomText.qml | 9 +- Components/CustomTextField.qml | 6 +- Components/GeminiGlow.qml | 42 +++ Components/IconButton.qml | 3 +- Components/IconTextButton.qml | 3 +- Components/MaterialIcon.qml | 20 +- Components/SearchBar.qml | 2 +- Components/TextFieldBase.qml | 11 +- Components/Toast/ToastItem.qml | 7 +- Helpers/Picker.qml | 2 +- Helpers/Picker/AnnotationToolbar.qml | 14 +- Modules/Bar/Components/Clock.qml | 9 +- Modules/Bar/Components/Common/BatteryIcon.qml | 6 +- Modules/Bar/Components/HyprsunsetWidget.qml | 5 +- Modules/Bar/Components/MediaWidget.qml | 12 +- Modules/Bar/Components/NotifBell.qml | 8 +- Modules/Bar/Components/Resource.qml | 5 +- .../Components/StatusIcons/AudioWidget.qml | 11 +- .../Bar/Components/StatusIcons/MicWidget.qml | 11 +- .../Components/StatusIcons/UPowerWidget.qml | 14 +- Modules/Bar/Components/UpdatesWidget.qml | 3 +- Modules/Bar/Components/WindowTitle.qml | 6 +- Modules/Bar/Popouts/UPowerPopout.qml | 5 +- Modules/Bar/Popouts/UpdatesPopout.qml | 12 +- Modules/Clipboard/Content.qml | 6 +- Modules/Dashboard/Dash/DateTime.qml | 51 --- Modules/Dashboard/Dash/User.qml | 7 +- Modules/Dashboard/Dash/Weather.qml | 5 +- Modules/Dashboard/Media.qml | 6 +- Modules/Dashboard/Media/Details.qml | 18 +- Modules/Dashboard/Media/LyricList.qml | 8 +- Modules/Dashboard/Media/LyricsAndSelector.qml | 4 +- Modules/Dashboard/Media/LyricsInfo.qml | 4 +- Modules/Dashboard/Tabs.qml | 2 +- Modules/Dashboard/Weather/Graphs.qml | 4 +- Modules/Dashboard/WeatherTab.qml | 29 +- .../DesktopIcons/BackgroundContextMenu.qml | 302 ++++++++------- .../DesktopIcons/DesktopIconContextMenu.qml | 13 +- Modules/Drawing/Content.qml | 4 +- Modules/Drawing/Wrapper.qml | 8 +- Modules/Launcher/ContentList.qml | 16 +- Modules/Launcher/Items/ActionItem.qml | 5 +- Modules/Launcher/Items/AppItem.qml | 3 +- Modules/Launcher/Items/CalcItem.qml | 6 +- Modules/Launcher/Items/FilesItem.qml | 4 +- Modules/Launcher/Items/VariantItem.qml | 7 +- Modules/Launcher/Items/WallpaperTile.qml | 3 +- Modules/Lock/Center.qml | 37 +- Modules/Lock/InputField.qml | 15 +- Modules/Lock/LockSurface.qml | 3 +- Modules/Lock/Media.qml | 3 +- Modules/Lock/NotifDock.qml | 7 +- Modules/Lock/NotifGroup.qml | 20 +- Modules/Lock/Resources.qml | 5 +- Modules/Lock/Weather/BriefInfo.qml | 8 +- Modules/Lock/Weather/Forecast.qml | 13 +- Modules/Notifications/Notification.qml | 31 +- Modules/Notifications/Sidebar/NotifDock.qml | 32 +- .../Sidebar/Utils/Cards/Record.qml | 26 +- .../Sidebar/Utils/Cards/RecordingList.qml | 6 +- .../Sidebar/Utils/Cards/Toggles.qml | 2 +- .../Sidebar/Utils/IdleInhibit.qml | 20 +- Modules/Polkit/Polkit.qml | 12 +- Modules/Resources/Cards/HeroCard.qml | 13 +- Modules/Resources/Cards/MemoryCard.qml | 5 +- Modules/Resources/Cards/StorageCard.qml | 10 +- Modules/Resources/Cards/VramCard.qml | 5 +- Modules/Settings/Common/AudioDeviceList.qml | 9 +- Modules/Settings/Common/BlobPopup.qml | 8 +- Modules/Settings/Common/DialogRowButton.qml | 2 +- .../Settings/Common/DialogSelectButton.qml | 3 +- Modules/Settings/Common/InfoRow.qml | 8 +- Modules/Settings/Common/ItemList.qml | 7 +- Modules/Settings/Common/ListEditor.qml | 13 +- Modules/Settings/Common/NavRow.qml | 69 +--- Modules/Settings/Common/PageBase.qml | 3 +- Modules/Settings/Common/PopupRow.qml | 6 +- Modules/Settings/Common/RowButton.qml | 7 +- Modules/Settings/Common/SearchPopup.qml | 9 +- Modules/Settings/Common/SectionHeader.qml | 2 +- Modules/Settings/Common/SelectRow.qml | 4 +- Modules/Settings/Common/SliderRow.qml | 4 +- Modules/Settings/Common/SpinRow.qml | 4 +- Modules/Settings/Common/TextFieldRow.qml | 1 - Modules/Settings/Common/TimeDialogRow.qml | 2 +- Modules/Settings/Common/TimeDialogToggle.qml | 4 +- Modules/Settings/Common/TimeInput.qml | 4 +- Modules/Settings/Common/ToggleRow.qml | 5 +- Modules/Settings/NavPane.qml | 9 +- Modules/Settings/NavPane/NavLocations.qml | 19 +- Modules/Settings/PageCompRegistry.qml | 2 +- Modules/Settings/Pages/AboutPage.qml | 4 +- Modules/Settings/Pages/Apps/AllApps.qml | 8 +- Modules/Settings/Pages/Apps/AppInfo.qml | 7 +- Modules/Settings/Pages/AppsPage.qml | 8 +- Modules/Settings/Pages/Audio/AppVolumes.qml | 1 - Modules/Settings/Pages/AudioPage.qml | 8 +- Modules/Settings/Pages/Panels/BarPanel.qml | 6 +- Modules/Settings/Pages/PanelsPage.qml | 10 +- Modules/Settings/Pages/ServicesPage.qml | 2 +- .../Pages/Wallpaper/WallpaperSelect.qml | 2 +- Plugins/ZShell/Config/CMakeLists.txt | 4 + Plugins/ZShell/Config/appearance.hpp | 351 +++++++++++++++++- Plugins/ZShell/Config/configobject.cpp | 16 +- Plugins/ZShell/Config/font.cpp | 290 +++++++++++++++ Plugins/ZShell/Config/font.hpp | 200 ++++++++++ Plugins/ZShell/Config/fontbuilder.cpp | 105 ++++++ Plugins/ZShell/Config/fontbuilder.hpp | 41 ++ Plugins/ZShell/Config/tokens.hpp | 137 ++----- assets/shaders/geminiglow.frag | 93 +++++ assets/shaders/geminiglow.frag.qsb | Bin 0 -> 3721 bytes 115 files changed, 1687 insertions(+), 916 deletions(-) create mode 100755 Components/GeminiGlow.qml delete mode 100644 Modules/Dashboard/Dash/DateTime.qml create mode 100644 Plugins/ZShell/Config/font.cpp create mode 100644 Plugins/ZShell/Config/font.hpp create mode 100644 Plugins/ZShell/Config/fontbuilder.cpp create mode 100644 Plugins/ZShell/Config/fontbuilder.hpp create mode 100755 assets/shaders/geminiglow.frag create mode 100644 assets/shaders/geminiglow.frag.qsb diff --git a/Components/BaseStyledSlider.qml b/Components/BaseStyledSlider.qml index 9d3b654..0ebb4ad 100644 --- a/Components/BaseStyledSlider.qml +++ b/Components/BaseStyledSlider.qml @@ -80,49 +80,29 @@ Slider { property bool moving - function update(): void { - animate = !moving; - binding.when = moving; - font.pointSize = moving ? Tokens.font.size.small : Tokens.font.size.larger; - font.family = moving ? Config.appearance.font.family.sans : Config.appearance.font.family.material; - } - anchors.centerIn: parent + anchors.verticalCenterOffset: 1 + text: moving ? Math.round(root.value * 100) : root.icon color: Colors.palette.m3inverseOnSurface - text: root.icon + font: moving ? Tokens.font.body.small : Tokens.font.icon.medium - onMovingChanged: anim.restart() - - Binding { - id: binding - - property: "text" - target: icon - value: Math.round(root.value * root.multiplier) - when: false - } - - SequentialAnimation { - id: anim - - Anim { - duration: Tokens.anim.durations.normal / 2 - easing: Tokens.anim.standardAccel - property: "scale" - target: icon - to: 0 - } - - ScriptAction { - script: icon.update() - } - - Anim { - duration: Tokens.anim.durations.normal / 2 - easing: Tokens.anim.standardDecel - property: "scale" - target: icon - to: 1 + Behavior on moving { + SequentialAnimation { + Anim { + target: icon + property: "scale" + to: 0.3 + duration: Tokens.anim.durations.small / 2 + easing: Tokens.anim.standardAccel + } + PropertyAction {} + Anim { + target: icon + property: "scale" + to: 1 + duration: Tokens.anim.durations.normal / 2 + easing: Tokens.anim.standardDecel + } } } } @@ -130,7 +110,7 @@ Slider { } Behavior on value { Anim { - duration: Tokens.anim.durations.large + type: Anim.StandardLarge } } @@ -138,7 +118,6 @@ Slider { onValueChanged: { if (!initialized) { initialized = true; - oldValue = value; return; } diff --git a/Components/ButtonBase.qml b/Components/ButtonBase.qml index ec11a7d..9b9e7bd 100644 --- a/Components/ButtonBase.qml +++ b/Components/ButtonBase.qml @@ -20,11 +20,7 @@ CustomRect { property color disabledColor: Qt.alpha(Colors.palette.m3onSurface, 0.1) property color disabledOnColor: Qt.alpha(Colors.palette.m3onSurface, 0.38) property bool fillWidth - property font font: ({ - family: Config.appearance.font.family.sans, - pointSize: Tokens.font.size.larger, - bold: false - }) + property font font: Tokens.font.body.small property real horizontalPadding: padding readonly property alias hovered: stateLayer.containsMouse required implicitHeight diff --git a/Components/CustomRadioButton.qml b/Components/CustomRadioButton.qml index aca7899..aca0f4c 100644 --- a/Components/CustomRadioButton.qml +++ b/Components/CustomRadioButton.qml @@ -6,7 +6,7 @@ import qs.Services RadioButton { id: root - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium implicitHeight: Math.max(implicitIndicatorHeight, implicitContentHeight) implicitWidth: implicitIndicatorWidth + implicitContentWidth + contentItem.anchors.leftMargin @@ -14,7 +14,7 @@ RadioButton { anchors.left: outerCircle.right anchors.leftMargin: 10 anchors.verticalCenter: parent.verticalCenter - font.pointSize: root.font.pointSize + font: root.font text: root.text } indicator: Rectangle { @@ -29,8 +29,7 @@ RadioButton { radius: Tokens.rounding.full Behavior on border.color { - CAnim { - } + CAnim {} } StateLayer { diff --git a/Components/CustomSlider.qml b/Components/CustomSlider.qml index 39c5339..5acd92b 100644 --- a/Components/CustomSlider.qml +++ b/Components/CustomSlider.qml @@ -100,15 +100,19 @@ Slider { anchors.bottom: parent.bottom anchors.top: parent.top color: root.insetColor - font.pixelSize: Tokens.font.size.smaller * 2 + fontStyle: { + if (root.height < 96) + return Tokens.font.icon.pixelSize(24).build(); + else + return Tokens.font.icon.pixelSize(32).build(); + } text: root.insetIcon verticalAlignment: Text.AlignVCenter visible: !root.wavy && root.insetIcon !== "" x: Tokens.spacing.extraSmall + dockT * (handle.x + Tokens.spacing.small) Behavior on dockT { - Anim { - } + Anim {} } } @@ -138,8 +142,7 @@ Slider { startX: x Behavior on color { - CAnim { - } + CAnim {} } Anim on waveProgress { duration: root.waveDuration @@ -156,8 +159,7 @@ Slider { Behavior on filledWidth { id: widthBehavior - Anim { - } + Anim {} } Component.onCompleted: { diff --git a/Components/CustomText.qml b/Components/CustomText.qml index 08ae9ee..144665e 100644 --- a/Components/CustomText.qml +++ b/Components/CustomText.qml @@ -14,15 +14,13 @@ Text { property real animateTo: 1 color: Colors.palette.m3onSurface - font.family: Config.appearance.font.family.sans - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.small linkColor: Colors.palette.m3onPrimaryFixedVariant renderType: Text.NativeRendering textFormat: Text.PlainText Behavior on color { - CAnim { - } + CAnim {} } Behavior on text { enabled: root.animate @@ -34,8 +32,7 @@ Text { type: Anim.FastEffects } - PropertyAction { - } + PropertyAction {} TAnim { target: root diff --git a/Components/CustomTextField.qml b/Components/CustomTextField.qml index 66141b8..ab2ff06 100644 --- a/Components/CustomTextField.qml +++ b/Components/CustomTextField.qml @@ -29,7 +29,7 @@ TextFieldBase { property string supportingText property string trailingIcon - property int smallFontSize: Tokens.font.size.small + property int smallFontSize: Tokens.font.label.small.pointSize property int type: CustomTextField.Outlined property var validate // Regex or function @@ -151,6 +151,7 @@ TextFieldBase { sourceComponent: MaterialIcon { color: Colors.palette.m3onSurfaceVariant text: root.leadingIcon + fontStyle: Tokens.font.icon.builders.medium.scale(0.9).build() } } @@ -165,6 +166,7 @@ TextFieldBase { sourceComponent: MaterialIcon { color: Colors.palette.m3onSurfaceVariant text: root.trailingIcon + fontStyle: Tokens.font.icon.builders.medium.scale(0.9).build() } } } @@ -179,7 +181,7 @@ TextFieldBase { sourceComponent: CustomText { color: root.isError ? Colors.palette.m3error : Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: root.effectiveSupportingText } } diff --git a/Components/GeminiGlow.qml b/Components/GeminiGlow.qml new file mode 100755 index 0000000..443ef07 --- /dev/null +++ b/Components/GeminiGlow.qml @@ -0,0 +1,42 @@ +import QtQuick +import Quickshell + +ShaderEffect { + id: root + + property Item target: parent + + property real glowSize: 40 + property real innerGlow: 0 + property real lineWidth: 2 + property real intensity: 1.0 + property real speed: 1.0 + property int period: 6000 + property bool running: true + + property color color1: "#4285F4" + property color color2: "#9B72CB" + property color color3: "#D96570" + property color color4: "#F2A54A" + + readonly property size itemSize: Qt.size(width, height) + readonly property size rectSize: target ? Qt.size(target.width, target.height) : Qt.size(0, 0) + readonly property real radius: (target as Rectangle).radius ?? 0 + property real phase: 0 + + anchors.fill: target + anchors.margins: -glowSize + z: -1 + + enabled: false + + fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/geminiglow.frag.qsb`) + + FrameAnimation { + running: root.running && root.visible + onTriggered: { + const dt = frameTime * 1000; + root.phase = (root.phase + dt * root.speed / root.period) % 1.0; + } + } +} diff --git a/Components/IconButton.qml b/Components/IconButton.qml index 0210520..3bdfc48 100644 --- a/Components/IconButton.qml +++ b/Components/IconButton.qml @@ -8,6 +8,7 @@ ButtonBase { property alias icon: label.text readonly property alias label: label + font: Tokens.font.icon.medium activeColor: type === IconButton.Filled ? Colors.palette.m3primary : Colors.palette.m3secondary activeOnColor: type === IconButton.Filled ? Colors.palette.m3onPrimary : type === IconButton.Tonal ? Colors.palette.m3onSecondary : Colors.palette.m3primary implicitHeight: { @@ -36,7 +37,7 @@ ButtonBase { anchors.verticalCenterOffset: 1 color: root.onColor fill: !root.isToggle || root.internalChecked ? 1 : 0 - font.pointSize: root.font.pointSize + fontStyle: root.font Behavior on fill { Anim { diff --git a/Components/IconTextButton.qml b/Components/IconTextButton.qml index 6190121..fb568f6 100644 --- a/Components/IconTextButton.qml +++ b/Components/IconTextButton.qml @@ -46,10 +46,9 @@ ButtonBase { Layout.alignment: Qt.AlignVCenter color: root.onColor fill: root.internalChecked ? 1 : 0 - font: { + fontStyle: { const f = Qt.font(root.font); f.pointSize = Math.round(root.font.pointSize * 1.2); - f.family = "Material Symbols Rounded"; return f; } diff --git a/Components/MaterialIcon.qml b/Components/MaterialIcon.qml index 0e81d10..b93e38c 100644 --- a/Components/MaterialIcon.qml +++ b/Components/MaterialIcon.qml @@ -1,27 +1,13 @@ +import QtQuick import ZShell.Config import qs.Services CustomText { id: root - readonly property list allowed: [20, 24, 40, 48] property real fill: 0 property int grade: Colors.light ? 0 : -25 - readonly property int opsz: clampOpsz(optical) - property int optical: fontInfo.pixelSize + property font fontStyle: Tokens.font.icon.small - function clampOpsz(value): int { - return allowed.reduce((closest, current) => { - return Math.abs(current - value) < Math.abs(closest - value) ? current : closest; - }); - } - - font.family: "Material Symbols Rounded" - font.pointSize: Tokens.font.size.larger - font.variableAxes: ({ - FILL: fill, - GRAD: grade, - opsz: opsz, - wght: fontInfo.weight - }) + font: Tokens.font.icon.size(fontStyle.pointSize).weight(fontStyle.weight).vaxes(fontStyle.variableAxes).fill(fill.toFixed(1)).grade(grade).build() } diff --git a/Components/SearchBar.qml b/Components/SearchBar.qml index f14b51e..5642d73 100644 --- a/Components/SearchBar.qml +++ b/Components/SearchBar.qml @@ -62,7 +62,7 @@ TextFieldBase { anchors.leftMargin: Tokens.padding.large anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.builders.medium.scale(0.9).build() text: "search" } diff --git a/Components/TextFieldBase.qml b/Components/TextFieldBase.qml index b68318b..0edd409 100644 --- a/Components/TextFieldBase.qml +++ b/Components/TextFieldBase.qml @@ -8,7 +8,7 @@ TextField { color: Colors.palette.m3onSurface cursorVisible: !readOnly - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small implicitHeight: contentHeight + topPadding + bottomPadding implicitWidth: contentWidth + leftPadding + rightPadding placeholderTextColor: Colors.palette.m3onSurfaceVariant // No anim cause placeholder is custom @@ -18,14 +18,11 @@ TextField { verticalAlignment: TextInput.AlignVCenter Behavior on color { - CAnim { - } - } - cursorDelegate: Item { + CAnim {} } + cursorDelegate: Item {} Behavior on selectionColor { - CAnim { - } + CAnim {} } CustomRect { diff --git a/Components/Toast/ToastItem.qml b/Components/Toast/ToastItem.qml index b37ead8..254e20c 100644 --- a/Components/Toast/ToastItem.qml +++ b/Components/Toast/ToastItem.qml @@ -36,8 +36,7 @@ CustomRect { radius: Tokens.rounding.medium Behavior on border.color { - CAnim { - } + CAnim {} } Elevation { @@ -84,7 +83,7 @@ CustomRect { return Colors.palette.m3onError; return Colors.palette.m3onSurfaceVariant; } - font.pointSize: Math.round(Tokens.font.size.large * 1.2) + fontStyle: Tokens.font.icon.builders.large.scale(1.2).build() text: root.modelData.icon } } @@ -107,7 +106,7 @@ CustomRect { return Colors.palette.m3onSurface; } elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal + font: Tokens.font.title.small text: root.modelData.title } diff --git a/Helpers/Picker.qml b/Helpers/Picker.qml index 05b4146..710dd99 100755 --- a/Helpers/Picker.qml +++ b/Helpers/Picker.qml @@ -42,7 +42,7 @@ Item { readonly property real scaleRatio: Hypr.monitorFor(screen).scale required property ShellScreen screen property real sh: Math.abs(sy - ey) - readonly property color shadowColor: Hypr.options.decoration.shadow.color + readonly property color shadowColor: Hypr.options.decoration.shadow?.color ?? "black" readonly property bool shadowEnabled: Hypr.options.decoration.shadow.enabled readonly property var shadowOffset: Hypr.options.decoration.shadow.offset readonly property int shadowRange: Hypr.options.decoration.shadow.range diff --git a/Helpers/Picker/AnnotationToolbar.qml b/Helpers/Picker/AnnotationToolbar.qml index d95eb37..fb6b673 100755 --- a/Helpers/Picker/AnnotationToolbar.qml +++ b/Helpers/Picker/AnnotationToolbar.qml @@ -73,7 +73,7 @@ CustomRect { required property var modelData color: root.tool === modelData.id ? "#3584e4" : "transparent" - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: tool.modelData.glyph inactiveColor: root.tool === modelData.id ? Colors.palette.m3primary : "transparent" inactiveOnColor: root.tool === modelData.id ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface @@ -103,7 +103,7 @@ CustomRect { required property color modelData fillWidth: false - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "" implicitHeight: buttonSize implicitWidth: buttonSize @@ -128,7 +128,7 @@ CustomRect { spacing: Tokens.spacing.extraSmall IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "undo" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface @@ -140,7 +140,7 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "delete_history" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface @@ -152,7 +152,7 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "content_copy" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface @@ -164,7 +164,7 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "delete_forever" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface @@ -176,7 +176,7 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "check" inactiveColor: "transparent" inactiveOnColor: Colors.palette.m3onSurface diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index 7940c1d..4791c73 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -13,6 +13,7 @@ import qs.Services WidgetBase { id: root + readonly property font font: Tokens.font.clock.family(Config.appearance.font.family.clock).build() readonly property color contentColor: screenState.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface readonly property string effectiveFormat: { let fmt = Config.general.dateFormat; @@ -57,7 +58,7 @@ WidgetBase { anchors.centerIn: parent color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font: root.font height: implicitHeight text: Time.dateStr visible: root.horizontal @@ -84,7 +85,7 @@ WidgetBase { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font: root.font text: Qt.formatDateTime(Time.date, modelData) Behavior on color { @@ -114,7 +115,7 @@ WidgetBase { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font: root.font text: { if (modelData.includes("h")) return Time.hourStr; @@ -135,7 +136,7 @@ WidgetBase { CustomText { Layout.alignment: Qt.AlignHCenter color: root.contentColor - font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type + font: root.font text: Qt.formatDateTime(Time.date, "AP") visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0 diff --git a/Modules/Bar/Components/Common/BatteryIcon.qml b/Modules/Bar/Components/Common/BatteryIcon.qml index 2f13bc9..6a78dfa 100644 --- a/Modules/Bar/Components/Common/BatteryIcon.qml +++ b/Modules/Bar/Components/Common/BatteryIcon.qml @@ -28,8 +28,7 @@ Row { CustomText { color: Battery.getColors(root.percentage, root.devState).fg - font.pointSize: Tokens.font.size.larger / 1.5 - font.weight: 800 + font: Tokens.font.body.builders.small.size(11).weight(Font.Bold).build() height: track.height horizontalAlignment: Text.AlignHCenter text: Math.round(root.percentage * 100) @@ -60,8 +59,7 @@ Row { clip: true color: Battery.getColors(root.percentage, root.devState).bg - font.pointSize: Tokens.font.size.larger / 1.5 - font.weight: 800 + font: Tokens.font.body.builders.small.size(11).weight(Font.Bold).build() height: track.height horizontalAlignment: Text.AlignHCenter text: Math.round(root.percentage * 100) diff --git a/Modules/Bar/Components/HyprsunsetWidget.qml b/Modules/Bar/Components/HyprsunsetWidget.qml index 8afd545..f409a29 100644 --- a/Modules/Bar/Components/HyprsunsetWidget.qml +++ b/Modules/Bar/Components/HyprsunsetWidget.qml @@ -25,12 +25,11 @@ WidgetBase { animate: true color: root.tempEnabled ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface fill: root.tempEnabled ? 1 : 0 - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: root.tempEnabled ? "lightbulb" : "light_off" Behavior on fill { - Anim { - } + Anim {} } } } diff --git a/Modules/Bar/Components/MediaWidget.qml b/Modules/Bar/Components/MediaWidget.qml index 32fb007..7d2297f 100644 --- a/Modules/Bar/Components/MediaWidget.qml +++ b/Modules/Bar/Components/MediaWidget.qml @@ -19,14 +19,12 @@ WidgetBase { Behavior on implicitHeight { enabled: !root.horizontal - Anim { - } + Anim {} } Behavior on implicitWidth { enabled: root.horizontal - Anim { - } + Anim {} } TextMetrics { @@ -47,15 +45,14 @@ WidgetBase { columns: root.horizontal ? -1 : 1 Behavior on implicitWidth { - Anim { - } + Anim {} } MaterialIcon { Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter animate: true color: Players.active?.isPlaying ? Colors.palette.m3primary : Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: Players.active?.isPlaying ? "music_note" : "music_off" } @@ -65,7 +62,6 @@ WidgetBase { Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter animate: true color: Players.active?.isPlaying ? Colors.palette.m3primary : Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.normal horizontalAlignment: Text.AlignHCenter implicitHeight: root.horizontal ? root.implicitHeight : root.textWidth implicitWidth: root.horizontal ? root.textWidth : root.implicitWidth diff --git a/Modules/Bar/Components/NotifBell.qml b/Modules/Bar/Components/NotifBell.qml index 34b4c68..dfeb760 100644 --- a/Modules/Bar/Components/NotifBell.qml +++ b/Modules/Bar/Components/NotifBell.qml @@ -22,16 +22,14 @@ WidgetBase { anchors.centerIn: parent color: root.screenState.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface fill: root.screenState.sidebar ? 1 : 0 - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.size(16).build() text: NotifServer.list.length ? "\uf4fe" : "\ue7f4" Behavior on color { - CAnim { - } + CAnim {} } Behavior on fill { - Anim { - } + Anim {} } } diff --git a/Modules/Bar/Components/Resource.qml b/Modules/Bar/Components/Resource.qml index 4314ee7..cce4285 100644 --- a/Modules/Bar/Components/Resource.qml +++ b/Modules/Bar/Components/Resource.qml @@ -27,8 +27,7 @@ GridLayout { percentage: 0 Behavior on animatedPercentage { - Anim { - } + Anim {} } Component.onCompleted: animatedPercentage = percentage @@ -44,7 +43,7 @@ GridLayout { Layout.preferredWidth: 16 color: root.iconColor - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: root.icon } diff --git a/Modules/Bar/Components/StatusIcons/AudioWidget.qml b/Modules/Bar/Components/StatusIcons/AudioWidget.qml index 93c4752..a6fab2a 100644 --- a/Modules/Bar/Components/StatusIcons/AudioWidget.qml +++ b/Modules/Bar/Components/StatusIcons/AudioWidget.qml @@ -13,19 +13,16 @@ MaterialIcon { animate: true color: Audio.muted ? Colors.palette.m3error : Colors.palette.m3onSurface fill: 1 - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: Audio.muted ? "volume_off" : "volume_up" Behavior on Layout.maximumWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } } diff --git a/Modules/Bar/Components/StatusIcons/MicWidget.qml b/Modules/Bar/Components/StatusIcons/MicWidget.qml index 8f58616..cb61bee 100644 --- a/Modules/Bar/Components/StatusIcons/MicWidget.qml +++ b/Modules/Bar/Components/StatusIcons/MicWidget.qml @@ -13,19 +13,16 @@ MaterialIcon { animate: true color: (Audio.sourceMuted ?? false) ? Colors.palette.m3error : Colors.palette.m3onSurface fill: 1 - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: Audio.sourceMuted ? "mic_off" : "mic" Behavior on Layout.maximumWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } } diff --git a/Modules/Bar/Components/StatusIcons/UPowerWidget.qml b/Modules/Bar/Components/StatusIcons/UPowerWidget.qml index 8cdc2a7..48c1b71 100644 --- a/Modules/Bar/Components/StatusIcons/UPowerWidget.qml +++ b/Modules/Bar/Components/StatusIcons/UPowerWidget.qml @@ -15,20 +15,16 @@ Item { implicitWidth: currentItem?.implicitWidth ?? 0 Behavior on Layout.preferredHeight { - Anim { - } + Anim {} } Behavior on Layout.preferredWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } Component.onCompleted: Battery.startDaemon() @@ -67,7 +63,7 @@ Item { Layout.alignment: Qt.AlignVCenter animate: true fill: 1 - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.small text: { if (PowerProfiles.profile === PowerProfile.PowerSaver) return "energy_savings_leaf"; diff --git a/Modules/Bar/Components/UpdatesWidget.qml b/Modules/Bar/Components/UpdatesWidget.qml index a525040..a44c0de 100644 --- a/Modules/Bar/Components/UpdatesWidget.qml +++ b/Modules/Bar/Components/UpdatesWidget.qml @@ -27,14 +27,13 @@ WidgetBase { MaterialIcon { Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter - font.pointSize: root.horizontal ? Tokens.font.size.larger : Tokens.font.size.large + fontStyle: Tokens.font.icon.small text: "package_2" } CustomText { Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter color: root.textColor - font.pointSize: Tokens.font.size.normal text: root.countUpdates } } diff --git a/Modules/Bar/Components/WindowTitle.qml b/Modules/Bar/Components/WindowTitle.qml index c213072..7515093 100644 --- a/Modules/Bar/Components/WindowTitle.qml +++ b/Modules/Bar/Components/WindowTitle.qml @@ -52,8 +52,7 @@ Item { elide: Qt.ElideRight elideWidth: root.maxSize - (root.horizontal ? root.current.anchors.leftMargin : 0) - font.family: "Rubik" - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.small text: Hypr.activeToplevel?.title ?? qsTr("Desktop") onElideWidthChanged: root.current.text = elidedText @@ -81,8 +80,7 @@ Item { width: root.horizontal ? implicitWidth : implicitHeight Behavior on opacity { - Anim { - } + Anim {} } transform: [ Translate { diff --git a/Modules/Bar/Popouts/UPowerPopout.qml b/Modules/Bar/Popouts/UPowerPopout.qml index b702dd7..09c94e1 100644 --- a/Modules/Bar/Popouts/UPowerPopout.qml +++ b/Modules/Bar/Popouts/UPowerPopout.qml @@ -181,8 +181,7 @@ Column { } ] transitions: Transition { - AnchorAnim { - } + AnchorAnim {} } } @@ -246,7 +245,7 @@ Column { anchors.centerIn: parent color: profiles.current === text ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface fill: profiles.current === text ? 1 : 0 - font.pointSize: Tokens.font.size.large * 2 + fontStyle: Tokens.font.icon.extraLarge grade: Colors.light ? 0 : (text === "balance" ? -25 : 0) text: parent.icon diff --git a/Modules/Bar/Popouts/UpdatesPopout.qml b/Modules/Bar/Popouts/UpdatesPopout.qml index 8a143f3..6de1f39 100644 --- a/Modules/Bar/Popouts/UpdatesPopout.qml +++ b/Modules/Bar/Popouts/UpdatesPopout.qml @@ -39,7 +39,7 @@ CustomClippingRect { anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top color: Colors.tPalette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.extraLarge * 3 + fontStyle: Tokens.font.icon.builders.large.size(48).weight(Font.DemiBold).build() horizontalAlignment: Text.AlignHCenter text: "check" } @@ -89,7 +89,7 @@ CustomClippingRect { anchors.rightMargin: Tokens.padding.small MaterialIcon { - font.pointSize: Tokens.font.size.large * 2 + fontStyle: Tokens.font.icon.extraLarge text: "package_2" } @@ -100,7 +100,7 @@ CustomClippingRect { Layout.fillWidth: true Layout.preferredHeight: 25 elide: Text.ElideRight - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.medium text: update.sections[0] } @@ -122,7 +122,7 @@ CustomClippingRect { Layout.preferredWidth: 125 animate: true color: Colors.palette.m3tertiary - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.medium horizontalAlignment: Text.AlignHCenter marqueeEnabled: true pauseMs: 4000 @@ -133,7 +133,7 @@ CustomClippingRect { MaterialIcon { Layout.fillHeight: true color: Colors.palette.m3secondary - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.large horizontalAlignment: Text.AlignHCenter text: "arrow_right_alt" verticalAlignment: Text.AlignVCenter @@ -146,7 +146,7 @@ CustomClippingRect { Layout.preferredWidth: 120 animate: true color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.medium horizontalAlignment: Text.AlignHCenter marqueeEnabled: true pauseMs: 4000 diff --git a/Modules/Clipboard/Content.qml b/Modules/Clipboard/Content.qml index dd5d6a3..f4683c6 100644 --- a/Modules/Clipboard/Content.qml +++ b/Modules/Clipboard/Content.qml @@ -66,8 +66,7 @@ Item { radius: 25 Behavior on implicitWidth { - Anim { - } + Anim {} } Column { @@ -104,7 +103,6 @@ Item { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small text: lineRow.modelData.line } } @@ -252,7 +250,7 @@ Item { anchors.left: parent.left anchors.margins: Tokens.padding.small anchors.verticalCenter: parent.verticalCenter - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: clipItem.isImage ? "image" : "text_fields" } diff --git a/Modules/Dashboard/Dash/DateTime.qml b/Modules/Dashboard/Dash/DateTime.qml deleted file mode 100644 index 3a76d5c..0000000 --- a/Modules/Dashboard/Dash/DateTime.qml +++ /dev/null @@ -1,51 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import qs.Components -import ZShell.Config -import qs.Helpers -import qs.Services - -Item { - id: root - - anchors.bottom: parent.bottom - anchors.top: parent.top - implicitWidth: 110 - - ColumnLayout { - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 0 - - CustomText { - Layout.alignment: Qt.AlignHCenter - Layout.bottomMargin: -(font.pointSize * 0.4) - color: Colors.palette.m3secondary - font.family: "Rubik" - font.pointSize: 18 - font.weight: 600 - text: Time.hourStr - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - color: Colors.palette.m3primary - font.family: "Rubik" - font.pointSize: 18 * 0.9 - text: "•••" - } - - CustomText { - Layout.alignment: Qt.AlignHCenter - Layout.topMargin: -(font.pointSize * 0.4) - color: Colors.palette.m3secondary - font.family: "Rubik" - font.pointSize: 18 - font.weight: 600 - text: Time.minuteStr - } - } -} diff --git a/Modules/Dashboard/Dash/User.qml b/Modules/Dashboard/Dash/User.qml index 1d37230..264ae5f 100644 --- a/Modules/Dashboard/Dash/User.qml +++ b/Modules/Dashboard/Dash/User.qml @@ -23,8 +23,7 @@ Row { MaterialIcon { anchors.centerIn: parent fill: 1 - font.pointSize: Math.floor(info.implicitHeight / 2) || 1 - grade: 200 + fontStyle: Tokens.font.icon.large text: "person" } @@ -65,7 +64,6 @@ Row { anchors.leftMargin: icon.anchors.leftMargin anchors.verticalCenter: icon.verticalCenter elide: Text.ElideRight - font.pointSize: 13 text: `: ${SystemInfo.osPrettyName || SystemInfo.osName}` width: Config.dashboard.sizes.infoWidth } @@ -103,7 +101,7 @@ Row { anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2 color: line.colour fill: 1 - font.pointSize: 13 + fontStyle: Tokens.font.icon.small text: line.icon } @@ -114,7 +112,6 @@ Row { anchors.leftMargin: icon.anchors.leftMargin anchors.verticalCenter: icon.verticalCenter elide: Text.ElideNone - font.pointSize: 13 text: `: ${line.text}` width: Config.dashboard.sizes.infoWidth } diff --git a/Modules/Dashboard/Dash/Weather.qml b/Modules/Dashboard/Dash/Weather.qml index c951fb7..906ac93 100644 --- a/Modules/Dashboard/Dash/Weather.qml +++ b/Modules/Dashboard/Dash/Weather.qml @@ -19,7 +19,7 @@ Item { anchors.verticalCenter: parent.verticalCenter animate: true color: Colors.palette.m3secondary - font.pointSize: 54 + fontStyle: Tokens.font.icon.extraLarge text: Weather.icon } @@ -35,8 +35,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter animate: true color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.extraLarge - font.weight: 500 + font: Tokens.font.body.large text: Weather.temp } diff --git a/Modules/Dashboard/Media.qml b/Modules/Dashboard/Media.qml index f1f98ee..297e996 100644 --- a/Modules/Dashboard/Media.qml +++ b/Modules/Dashboard/Media.qml @@ -108,7 +108,7 @@ Item { anchors.centerIn: parent text: "queue_music" - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large color: Colors.palette.m3onPrimaryContainer } } @@ -116,13 +116,13 @@ Item { CustomText { Layout.alignment: Qt.AlignHCenter text: qsTr("Nothing playing") - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium } CustomText { text: qsTr("Play something for it to show up here!") color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large } } } diff --git a/Modules/Dashboard/Media/Details.qml b/Modules/Dashboard/Media/Details.qml index 39ba295..2d6c6c9 100644 --- a/Modules/Dashboard/Media/Details.qml +++ b/Modules/Dashboard/Media/Details.qml @@ -38,7 +38,7 @@ ColumnLayout { CustomText { Layout.fillWidth: true text: Players.active?.trackTitle ?? "" - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.large elide: Text.ElideRight animate: true } @@ -47,7 +47,7 @@ ColumnLayout { Layout.fillWidth: true text: Players.active?.trackArtist || qsTr("Unknown artist") color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.title.medium elide: Text.ElideRight animate: true } @@ -56,7 +56,7 @@ ColumnLayout { Layout.fillWidth: true text: Players.active?.trackAlbum || qsTr("Unknown album") color: Colors.palette.m3secondary - font.pointSize: Tokens.font.size.normal + font: Tokens.font.title.medium elide: Text.ElideRight animate: true } @@ -70,7 +70,7 @@ ColumnLayout { id: timeMetrics text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00" - font.pointSize: Tokens.font.size.normal + font: Tokens.font.label.medium } CustomText { @@ -128,7 +128,7 @@ ColumnLayout { isRound: true shapeMorph: true checked: Players.active?.shuffle ?? false - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.builders.medium.weight(Font.Medium).build() enabled: Players.active?.shuffleSupported onClicked: Players.active.shuffle = !Players.active?.shuffle implicitWidth: Math.round(implicitHeight * 0.9) @@ -141,7 +141,7 @@ ColumnLayout { icon: "skip_previous" isRound: true shapeMorph: true - font.pointSize: Tokens.font.size.extraLarge + font: Tokens.font.icon.large enabled: Players.active?.canGoPrevious onClicked: Players.active?.previous() } @@ -154,7 +154,7 @@ ColumnLayout { shapeMorph: true fillWidth: true checked: Players.active?.isPlaying ?? false - font.pointSize: Tokens.font.size.extraLarge + font: Tokens.font.icon.large enabled: Players.active?.canTogglePlaying onClicked: Players.active?.togglePlaying() } @@ -166,7 +166,7 @@ ColumnLayout { icon: "skip_next" isRound: true shapeMorph: true - font.pointSize: Tokens.font.size.extraLarge + font: Tokens.font.icon.large enabled: Players.active?.canGoNext onClicked: Players.active?.next() } @@ -177,7 +177,7 @@ ColumnLayout { isRound: true shapeMorph: true checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.builders.medium.weight(Font.Medium).build() enabled: Players.active?.loopSupported onClicked: { const state = Players.active.loopState; diff --git a/Modules/Dashboard/Media/LyricList.qml b/Modules/Dashboard/Media/LyricList.qml index a90aaa2..69ce845 100644 --- a/Modules/Dashboard/Media/LyricList.qml +++ b/Modules/Dashboard/Media/LyricList.qml @@ -179,7 +179,7 @@ Item { CustomText { text: qsTr("Loading lyrics...") color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.normal } } @@ -204,14 +204,14 @@ Item { MaterialIcon { Layout.alignment: Qt.AlignHCenter text: "sentiment_sad" - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge color: Colors.palette.m3outline } CustomText { text: qsTr("No lyrics found") color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium } } } @@ -258,7 +258,7 @@ Item { text: modelData || ". . ." color: ListView.isCurrentItem ? Colors.palette.m3primary : mouse.containsMouse ? Colors.palette.m3onSurface : Colors.palette.m3outline - font.pointSize: Tokens.font.size.medium + font: Tokens.font.body.medium wrapMode: Text.WrapAtWordBoundaryOrAnywhere layer.enabled: effectScale > 0 diff --git a/Modules/Dashboard/Media/LyricsAndSelector.qml b/Modules/Dashboard/Media/LyricsAndSelector.qml index 453ed2c..328cc5b 100644 --- a/Modules/Dashboard/Media/LyricsAndSelector.qml +++ b/Modules/Dashboard/Media/LyricsAndSelector.qml @@ -23,13 +23,13 @@ Item { MaterialIcon { Layout.topMargin: Math.round(fontInfo.pointSize * 0.12) text: "lyrics" - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large } CustomText { Layout.fillWidth: true text: qsTr("Lyrics") - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large } LyricsInfo {} diff --git a/Modules/Dashboard/Media/LyricsInfo.qml b/Modules/Dashboard/Media/LyricsInfo.qml index 2e4437c..cc15f41 100644 --- a/Modules/Dashboard/Media/LyricsInfo.qml +++ b/Modules/Dashboard/Media/LyricsInfo.qml @@ -184,7 +184,7 @@ Item { text: Lyrics.loading ? qsTr("Loading...") : qsTr("No lyrics found") color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium animate: true } } @@ -206,7 +206,7 @@ Item { anchors.centerIn: parent text: "more_vert" - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium } } } diff --git a/Modules/Dashboard/Tabs.qml b/Modules/Dashboard/Tabs.qml index ce04b93..56234fa 100644 --- a/Modules/Dashboard/Tabs.qml +++ b/Modules/Dashboard/Tabs.qml @@ -162,7 +162,7 @@ Item { text: tab.iconName color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant fill: tab.current ? 1 : 0 - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium Behavior on fill { Anim { diff --git a/Modules/Dashboard/Weather/Graphs.qml b/Modules/Dashboard/Weather/Graphs.qml index ff0e0df..f0ac43d 100644 --- a/Modules/Dashboard/Weather/Graphs.qml +++ b/Modules/Dashboard/Weather/Graphs.qml @@ -48,7 +48,7 @@ RowLayout { MaterialIcon { text: "device_thermostat" - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium color: Colors.palette.m3secondary } @@ -99,7 +99,7 @@ RowLayout { MaterialIcon { text: "water_drop" - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium color: Colors.palette.m3tertiary } diff --git a/Modules/Dashboard/WeatherTab.qml b/Modules/Dashboard/WeatherTab.qml index fbd44bc..24538a2 100644 --- a/Modules/Dashboard/WeatherTab.qml +++ b/Modules/Dashboard/WeatherTab.qml @@ -31,13 +31,13 @@ Item { CustomText { text: Weather.city || qsTr("Loading...") - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.builders.large.size(24).weight(Font.DemiBold).build() color: Colors.palette.m3onSurface } CustomText { text: new Date().toLocaleDateString(Qt.locale("en_US"), "dddd, MMMM d") - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small color: Colors.palette.m3onSurfaceVariant } } @@ -114,7 +114,7 @@ Item { Layout.leftMargin: Tokens.padding.medium visible: forecastRepeater.count > 0 text: qsTr("7-day forecast") - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.builders.medium.weight(Font.DemiBold).build() color: Colors.palette.m3onSurface } @@ -148,7 +148,7 @@ Item { CustomText { Layout.alignment: Qt.AlignHCenter text: forecastItem.index === 0 ? qsTr("Today", "forecast column") : new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale("en_US"), "ddd") - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.builders.medium.weight(Font.DemiBold).build() color: Colors.palette.m3primary } @@ -156,7 +156,7 @@ Item { Layout.topMargin: -Tokens.spacing.extraSmall Layout.alignment: Qt.AlignHCenter text: new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale("en_US"), "MMM d") - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small opacity: 0.7 color: Colors.palette.m3onSurfaceVariant } @@ -164,7 +164,7 @@ Item { MaterialIcon { Layout.alignment: Qt.AlignHCenter text: forecastItem.modelData.icon - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge color: Colors.palette.m3secondary } @@ -175,7 +175,7 @@ Item { const max = Weather.formatTemp(forecastItem.modelData.maxTempC, true); return qsTr("%1 / %2", "min/max temperature").arg(min).arg(max); } - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.builders.small.weight(Font.DemiBold).build() color: Colors.palette.m3tertiary } } @@ -204,7 +204,7 @@ Item { MaterialIcon { text: detailRoot.icon color: detailRoot.colour - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large anchors.verticalCenter: parent.verticalCenter } @@ -214,20 +214,20 @@ Item { CustomText { text: detailRoot.label - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small opacity: 0.7 horizontalAlignment: Text.AlignLeft } CustomText { text: detailRoot.value - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.builders.small.weight(Font.DemiBold).build() horizontalAlignment: Text.AlignLeft } } } } - component WeatherStat: Row { + component WeatherStat: RowLayout { id: weatherStat property string icon @@ -239,19 +239,20 @@ Item { MaterialIcon { text: weatherStat.icon - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge color: weatherStat.colour } Column { + Layout.alignment: Qt.AlignVCenter CustomText { text: weatherStat.label - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small color: Colors.palette.m3onSurfaceVariant } CustomText { text: weatherStat.value - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.builders.small.weight(Font.DemiBold).build() color: Colors.palette.m3onSurface } } diff --git a/Modules/DesktopIcons/BackgroundContextMenu.qml b/Modules/DesktopIcons/BackgroundContextMenu.qml index 24f0217..98050f3 100644 --- a/Modules/DesktopIcons/BackgroundContextMenu.qml +++ b/Modules/DesktopIcons/BackgroundContextMenu.qml @@ -11,175 +11,195 @@ import qs.Services import qs.Services Item { - id: root + id: root - anchors.fill: parent - z: 998 - visible: false + anchors.fill: parent + z: 998 + visible: false - property real menuX: 0 - property real menuY: 0 + property real menuX: 0 + property real menuY: 0 - MouseArea { - anchors.fill: parent - onClicked: root.close() - } + MouseArea { + anchors.fill: parent + onClicked: root.close() + } - CustomClippingRect { - id: popupBackground - readonly property real padding: 4 + CustomClippingRect { + id: popupBackground + readonly property real padding: 4 - x: root.menuX - y: root.menuY + x: root.menuX + y: root.menuY - color: Colors.tPalette.m3surface - radius: Tokens.rounding.medium + color: Colors.tPalette.m3surface + radius: Tokens.rounding.medium - implicitWidth: menuLayout.implicitWidth + padding * 2 - implicitHeight: menuLayout.implicitHeight + padding * 2 + implicitWidth: menuLayout.implicitWidth + padding * 2 + implicitHeight: menuLayout.implicitHeight + padding * 2 - Behavior on opacity { Anim {} } - opacity: root.visible ? 1 : 0 + Behavior on opacity { + Anim {} + } + opacity: root.visible ? 1 : 0 - ColumnLayout { - id: menuLayout - anchors.centerIn: parent - spacing: 0 + ColumnLayout { + id: menuLayout + anchors.centerIn: parent + spacing: 0 - CustomRect { - Layout.preferredWidth: 200 - radius: popupBackground.radius - popupBackground.padding - implicitHeight: openTerminalRow.implicitHeight + Tokens.padding.extraSmall * 2 + CustomRect { + Layout.preferredWidth: 200 + radius: popupBackground.radius - popupBackground.padding + implicitHeight: openTerminalRow.implicitHeight + Tokens.padding.extraSmall * 2 - RowLayout { - id: openTerminalRow - spacing: 8 - anchors.fill: parent - anchors.leftMargin: Tokens.padding.small + RowLayout { + id: openTerminalRow + spacing: 8 + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small - MaterialIcon { text: "terminal"; font.pointSize: 20 } - CustomText { text: "Open terminal"; Layout.fillWidth: true } - } + MaterialIcon { + text: "terminal" + fontStyle: Tokens.font.icon.medium + } + CustomText { + text: "Open terminal" + Layout.fillWidth: true + } + } - StateLayer { - anchors.fill: parent + StateLayer { + anchors.fill: parent - onClicked: { - Quickshell.execDetached([Config.general.apps.terminal, "--working-directory", FileUtils.trimFileProtocol(Paths.desktop)]) - root.close() - } - } - } + onClicked: { + Quickshell.execDetached([Config.general.apps.terminal, "--working-directory", FileUtils.trimFileProtocol(Paths.desktop)]); + root.close(); + } + } + } - CustomRect { - Layout.fillWidth: true - implicitHeight: 1 - color: Colors.palette.m3outlineVariant - Layout.topMargin: 4 - Layout.bottomMargin: 4 - } + CustomRect { + Layout.fillWidth: true + implicitHeight: 1 + color: Colors.palette.m3outlineVariant + Layout.topMargin: 4 + Layout.bottomMargin: 4 + } - CustomRect { - Layout.fillWidth: true - radius: popupBackground.radius - popupBackground.padding - implicitHeight: settingsRow.implicitHeight + Tokens.padding.extraSmall * 2 + CustomRect { + Layout.fillWidth: true + radius: popupBackground.radius - popupBackground.padding + implicitHeight: settingsRow.implicitHeight + Tokens.padding.extraSmall * 2 - RowLayout { - id: settingsRow - spacing: 8 - anchors.fill: parent - anchors.leftMargin: Tokens.padding.small + RowLayout { + id: settingsRow + spacing: 8 + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small - MaterialIcon { text: "settings"; font.pointSize: 20 } - CustomText { text: "ZShell settings"; Layout.fillWidth: true } - } + MaterialIcon { + text: "settings" + fontStyle: Tokens.font.icon.medium + } + CustomText { + text: "ZShell settings" + Layout.fillWidth: true + } + } - StateLayer { - anchors.fill: parent + StateLayer { + anchors.fill: parent - onClicked: { - const state = ShellState.forActive(); + onClicked: { + const state = ShellState.forActive(); state.settings = true; - root.close() - } - } - } + root.close(); + } + } + } - CustomRect { - Layout.fillWidth: true - implicitHeight: 1 - color: Colors.palette.m3outlineVariant - Layout.topMargin: 4 - Layout.bottomMargin: 4 - } + CustomRect { + Layout.fillWidth: true + implicitHeight: 1 + color: Colors.palette.m3outlineVariant + Layout.topMargin: 4 + Layout.bottomMargin: 4 + } - CustomRect { - Layout.fillWidth: true - radius: popupBackground.radius - popupBackground.padding - implicitHeight: logoutRow.implicitHeight + Tokens.padding.extraSmall * 2 + CustomRect { + Layout.fillWidth: true + radius: popupBackground.radius - popupBackground.padding + implicitHeight: logoutRow.implicitHeight + Tokens.padding.extraSmall * 2 - RowLayout { - id: logoutRow - spacing: 8 - anchors.fill: parent - anchors.leftMargin: Tokens.padding.small + RowLayout { + id: logoutRow + spacing: 8 + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small - MaterialIcon { text: "logout"; font.pointSize: 20 } - CustomText { text: "Logout"; Layout.fillWidth: true } - } + MaterialIcon { + text: "logout" + fontStyle: Tokens.font.icon.medium + } + CustomText { + text: "Logout" + Layout.fillWidth: true + } + } - StateLayer { - anchors.fill: parent + StateLayer { + anchors.fill: parent - onClicked: { - Hyprland.dispatch("global quickshell:sessionOpen") - root.close() - } - } - } + onClicked: { + Hyprland.dispatch("global quickshell:sessionOpen"); + root.close(); + } + } + } - // CustomRect { - // Layout.fillWidth: true - // implicitHeight: 1 - // color: Colors.palette.m3outlineVariant - // Layout.topMargin: 4 - // Layout.bottomMargin: 4 - // } - // - // CustomRect { - // Layout.fillWidth: true - // radius: popupBackground.radius - popupBackground.padding - // implicitHeight: desktopIconsRow.implicitHeight + Tokens.padding.extraSmall * 2 - // - // RowLayout { - // id: desktopIconsRow - // spacing: 8 - // anchors.fill: parent - // anchors.leftMargin: Tokens.padding.small - // - // MaterialIcon { text: Config.options.background.showDesktopIcons ? "visibility_off" : "visibility"; font.pointSize: 20 } - // CustomText { text: Config.options.background.showDesktopIcons ? "Hide icons" : "Show icons"; Layout.fillWidth: true } - // } - // - // StateLayer { - // anchors.fill: parent - // - // onClicked: { - // Config.options.background.showDesktopIcons = !Config.options.background.showDesktopIcons - // root.close() - // } - // } - // } - } - } + // CustomRect { + // Layout.fillWidth: true + // implicitHeight: 1 + // color: Colors.palette.m3outlineVariant + // Layout.topMargin: 4 + // Layout.bottomMargin: 4 + // } + // + // CustomRect { + // Layout.fillWidth: true + // radius: popupBackground.radius - popupBackground.padding + // implicitHeight: desktopIconsRow.implicitHeight + Tokens.padding.extraSmall * 2 + // + // RowLayout { + // id: desktopIconsRow + // spacing: 8 + // anchors.fill: parent + // anchors.leftMargin: Tokens.padding.small + // + // MaterialIcon { text: Config.options.background.showDesktopIcons ? "visibility_off" : "visibility"; font.pointSize: 20 } + // CustomText { text: Config.options.background.showDesktopIcons ? "Hide icons" : "Show icons"; Layout.fillWidth: true } + // } + // + // StateLayer { + // anchors.fill: parent + // + // onClicked: { + // Config.options.background.showDesktopIcons = !Config.options.background.showDesktopIcons + // root.close() + // } + // } + // } + } + } - function openAt(mouseX, mouseY, parentW, parentH) { - menuX = Math.floor(Math.min(mouseX, parentW - popupBackground.implicitWidth)) - menuY = Math.floor(Math.min(mouseY, parentH - popupBackground.implicitHeight)) - visible = true - } + function openAt(mouseX, mouseY, parentW, parentH) { + menuX = Math.floor(Math.min(mouseX, parentW - popupBackground.implicitWidth)); + menuY = Math.floor(Math.min(mouseY, parentH - popupBackground.implicitHeight)); + visible = true; + } - function close() { - visible = false - } + function close() { + visible = false; + } } diff --git a/Modules/DesktopIcons/DesktopIconContextMenu.qml b/Modules/DesktopIcons/DesktopIconContextMenu.qml index 4b9b570..349d0cb 100644 --- a/Modules/DesktopIcons/DesktopIconContextMenu.qml +++ b/Modules/DesktopIcons/DesktopIconContextMenu.qml @@ -54,8 +54,7 @@ Item { y: contextMenu.menuY Behavior on opacity { - Anim { - } + Anim {} } ColumnLayout { @@ -77,7 +76,7 @@ Item { spacing: 8 MaterialIcon { - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: "open_in_new" } @@ -120,7 +119,7 @@ Item { spacing: 8 MaterialIcon { - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: contextMenu.targetIsDir ? "terminal" : "apps" } @@ -165,7 +164,7 @@ Item { spacing: 8 MaterialIcon { - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: "content_copy" } @@ -199,7 +198,7 @@ Item { spacing: 8 MaterialIcon { - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: "edit" } @@ -241,7 +240,7 @@ Item { MaterialIcon { color: deleteButton.hovered ? Colors.palette.m3onError : Colors.palette.m3error - font.pointSize: 20 + fontStyle: Tokens.font.icon.medium text: "delete" } diff --git a/Modules/Drawing/Content.qml b/Modules/Drawing/Content.qml index c689836..a580337 100644 --- a/Modules/Drawing/Content.qml +++ b/Modules/Drawing/Content.qml @@ -145,7 +145,6 @@ Item { IconTextButton { fillWidth: true - font.pointSize: Tokens.font.size.normal icon: "close" inactiveColor: Colors.palette.m3error inactiveOnColor: Colors.palette.m3onError @@ -158,7 +157,6 @@ Item { IconTextButton { fillWidth: true - font.pointSize: Tokens.font.size.normal icon: "ink_eraser" inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) inactiveOnColor: Colors.palette.m3onSurfaceVariant @@ -193,7 +191,7 @@ Item { required property ButtonRow row fillWidth: false - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium icon: "" implicitHeight: buttonSize implicitWidth: buttonSize diff --git a/Modules/Drawing/Wrapper.qml b/Modules/Drawing/Wrapper.qml index e7835d1..654950e 100644 --- a/Modules/Drawing/Wrapper.qml +++ b/Modules/Drawing/Wrapper.qml @@ -74,11 +74,10 @@ Item { opacity: root.expanded ? 0 : 1 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: MaterialIcon { - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.medium text: "arrow_forward_ios" } } @@ -93,8 +92,7 @@ Item { opacity: root.expanded ? 1 : 0 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: Content { drawing: root.drawing diff --git a/Modules/Launcher/ContentList.qml b/Modules/Launcher/ContentList.qml index 76bfeed..54093de 100644 --- a/Modules/Launcher/ContentList.qml +++ b/Modules/Launcher/ContentList.qml @@ -53,8 +53,7 @@ Item { to: 0 } - PropertyAction { - } + PropertyAction {} Anim { duration: Tokens.anim.durations.small @@ -130,18 +129,16 @@ Item { spacing: Tokens.spacing.medium Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } MaterialIcon { anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge text: root.state === "wallpapers" ? "wallpaper_slideshow" : "manage_search" } @@ -150,14 +147,13 @@ Item { CustomText { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.larger - font.weight: 500 + font: Tokens.font.body.builders.large.weight(Font.Medium).build() text: root.state === "wallpapers" ? qsTr("No wallpapers found") : qsTr("No results") } CustomText { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: root.state === "wallpapers" && Wallpapers.list.length === 0 ? qsTr("Try putting some wallpapers in %1").arg(Paths.shortenHome(Paths.wallsdir)) : qsTr("Try searching for something else") } } diff --git a/Modules/Launcher/Items/ActionItem.qml b/Modules/Launcher/Items/ActionItem.qml index b6ee987..3f8218a 100644 --- a/Modules/Launcher/Items/ActionItem.qml +++ b/Modules/Launcher/Items/ActionItem.qml @@ -33,7 +33,7 @@ Item { id: icon anchors.verticalCenter: parent.verticalCenter - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.large text: root.modelData?.icon ?? "" } @@ -47,7 +47,6 @@ Item { CustomText { id: name - font.pointSize: Tokens.font.size.normal text: root.modelData?.name ?? "" } @@ -57,7 +56,7 @@ Item { anchors.top: name.bottom color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.modelData?.desc ?? "" width: root.width - icon.width - Tokens.rounding.medium * 2 } diff --git a/Modules/Launcher/Items/AppItem.qml b/Modules/Launcher/Items/AppItem.qml index 1a2604a..6303b0f 100644 --- a/Modules/Launcher/Items/AppItem.qml +++ b/Modules/Launcher/Items/AppItem.qml @@ -51,7 +51,6 @@ Item { CustomText { id: name - font.pointSize: Tokens.font.size.normal text: root.modelData?.name ?? "" } @@ -61,7 +60,7 @@ Item { anchors.top: name.bottom color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: (root.modelData?.comment || root.modelData?.genericName || root.modelData?.name) ?? "" width: root.width - icon.width - Tokens.rounding.medium - Tokens.padding.medium * 2 } diff --git a/Modules/Launcher/Items/CalcItem.qml b/Modules/Launcher/Items/CalcItem.qml index 5a81aab..e526b94 100644 --- a/Modules/Launcher/Items/CalcItem.qml +++ b/Modules/Launcher/Items/CalcItem.qml @@ -40,7 +40,7 @@ Item { MaterialIcon { Layout.alignment: Qt.AlignVCenter - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.large text: "function" } @@ -92,7 +92,7 @@ Item { anchors.rightMargin: Tokens.spacing.small anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onTertiary - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium opacity: stateLayer.containsMouse ? 1 : 0 text: qsTr("Open in calculator") @@ -109,7 +109,7 @@ Item { anchors.rightMargin: Tokens.padding.small anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onTertiary - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium text: "open_in_new" } } diff --git a/Modules/Launcher/Items/FilesItem.qml b/Modules/Launcher/Items/FilesItem.qml index ab3b3ba..b878e6e 100644 --- a/Modules/Launcher/Items/FilesItem.qml +++ b/Modules/Launcher/Items/FilesItem.qml @@ -120,7 +120,7 @@ Item { CustomText { id: name - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: root.modelData?.name ?? "" elide: Text.ElideRight width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2 @@ -132,7 +132,7 @@ Item { anchors.top: name.bottom color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.modelData?.path ?? "" width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2 } diff --git a/Modules/Launcher/Items/VariantItem.qml b/Modules/Launcher/Items/VariantItem.qml index ca5d6cd..25caa4f 100644 --- a/Modules/Launcher/Items/VariantItem.qml +++ b/Modules/Launcher/Items/VariantItem.qml @@ -32,7 +32,7 @@ Item { id: icon anchors.verticalCenter: parent.verticalCenter - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.large text: root.modelData?.icon ?? "" } @@ -44,7 +44,6 @@ Item { width: parent.width - icon.width - anchors.leftMargin - (current.active ? current.width + Tokens.spacing.medium : 0) CustomText { - font.pointSize: Tokens.font.size.normal text: root.modelData?.name ?? "" } @@ -53,7 +52,7 @@ Item { anchors.right: parent.right color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.modelData?.description ?? "" } } @@ -67,7 +66,7 @@ Item { sourceComponent: MaterialIcon { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: "check" } } diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index b4a1091..293c48c 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -32,8 +32,7 @@ Item { MaterialIcon { anchors.centerIn: parent color: Colors.tPalette.m3outline - font.pointSize: Tokens.font.size.extraLarge * 2 - font.weight: 600 + fontStyle: Tokens.font.icon.builders.extraLarge.scale(2).weight(Font.DemiBold).build() text: "image" } diff --git a/Modules/Lock/Center.qml b/Modules/Lock/Center.qml index 06ad24b..481d757 100644 --- a/Modules/Lock/Center.qml +++ b/Modules/Lock/Center.qml @@ -28,27 +28,21 @@ ColumnLayout { CustomText { Layout.alignment: Qt.AlignVCenter color: Colors.palette.m3secondary - font.bold: true - font.family: Config.appearance.font.family.clock - font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) + font: Tokens.font.headline.builders.large.scale(3 * root.centerScale).weight(Font.Bold).width(30).build() text: Time.hourStr } CustomText { Layout.alignment: Qt.AlignVCenter color: Colors.palette.m3primary - font.bold: true - font.family: Config.appearance.font.family.clock - font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) + font: Tokens.font.headline.builders.large.scale(3 * root.centerScale).weight(Font.Bold).width(30).build() text: ":" } CustomText { Layout.alignment: Qt.AlignVCenter color: Colors.palette.m3secondary - font.bold: true - font.family: Config.appearance.font.family.clock - font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) + font: Tokens.font.headline.builders.large.scale(3 * root.centerScale).weight(Font.Bold).width(30).build() text: Time.minuteStr } } @@ -57,9 +51,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter Layout.topMargin: -Tokens.padding.large * 2 color: Colors.palette.m3tertiary - font.bold: true - font.family: Config.appearance.font.family.mono - font.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale) + font: Tokens.font.title.builders.medium.weight(Font.DemiBold).build() text: Time.format("dddd, d MMMM yyyy") } @@ -74,7 +66,7 @@ ColumnLayout { MaterialIcon { anchors.centerIn: parent color: Colors.palette.m3onSurfaceVariant - font.pointSize: Math.floor(root.centerWidth / 4) + fontStyle: Tokens.font.icon.size(root.centerWidth / 4).build() text: "person" } @@ -144,8 +136,7 @@ ColumnLayout { } Behavior on opacity { - Anim { - } + Anim {} } } @@ -159,6 +150,7 @@ ColumnLayout { id: inputField pam: root.lock.pam + centerScale: root.centerScale } CustomRect { @@ -180,7 +172,6 @@ ColumnLayout { anchors.centerIn: parent color: root.lock.pam.buffer ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - font.weight: 500 text: "arrow_forward" } } @@ -193,8 +184,7 @@ ColumnLayout { implicitHeight: Math.max(message.implicitHeight, stateMessage.implicitHeight) Behavior on implicitHeight { - Anim { - } + Anim {} } CustomText { @@ -226,7 +216,7 @@ ColumnLayout { anchors.right: parent.right animateProp: "opacity" color: Colors.palette.m3onSurfaceVariant - font.family: Config.appearance.font.family.mono + font: Tokens.font.body.small horizontalAlignment: Qt.AlignHCenter lineHeight: 1.2 opacity: shouldBeVisible && !message.msg ? 1 : 0 @@ -234,12 +224,10 @@ ColumnLayout { wrapMode: Text.WrapAtWordBoundaryOrAnywhere Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } onMsgChanged: { @@ -295,8 +283,7 @@ ColumnLayout { anchors.left: parent.left anchors.right: parent.right color: Colors.palette.m3error - font.family: Config.appearance.font.family.mono - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small horizontalAlignment: Qt.AlignHCenter opacity: 0 scale: 0.7 diff --git a/Modules/Lock/InputField.qml b/Modules/Lock/InputField.qml index 67511c3..b2ca56c 100644 --- a/Modules/Lock/InputField.qml +++ b/Modules/Lock/InputField.qml @@ -14,6 +14,7 @@ Item { property string buffer required property Pam pam + required property real centerScale readonly property alias placeholder: placeholder Layout.fillHeight: true @@ -41,8 +42,7 @@ Item { anchors.centerIn: parent animate: true color: root.pam.passwd.active ? Colors.palette.m3secondary : Colors.palette.m3outline - font.family: Config.appearance.font.family.mono - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.builders.medium.scale(root.centerScale).width(110).build() opacity: root.buffer ? 0 : 1 text: { if (root.pam.passwd.active) @@ -53,8 +53,7 @@ Item { } Behavior on opacity { - Anim { - } + Anim {} } } @@ -84,12 +83,11 @@ Item { implicitHeight: charList.implicitHeight implicitWidth: implicitHeight opacity: 0 - radius: Tokens.rounding.small / 2 + radius: Tokens.rounding.full scale: 0 Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { Anim { @@ -137,8 +135,7 @@ Item { Behavior on implicitWidth { id: imWidthBehavior - Anim { - } + Anim {} } model: ScriptModel { values: root.buffer.split("") diff --git a/Modules/Lock/LockSurface.qml b/Modules/Lock/LockSurface.qml index 6086d68..540e704 100644 --- a/Modules/Lock/LockSurface.qml +++ b/Modules/Lock/LockSurface.qml @@ -236,8 +236,7 @@ WlSessionLockSurface { id: lockIcon anchors.centerIn: parent - font.bold: true - font.pointSize: Tokens.font.size.extraLarge * 4 + fontStyle: Tokens.font.icon.builders.extraLarge.scale(4).weight(Font.Bold).build() text: "lock" } diff --git a/Modules/Lock/Media.qml b/Modules/Lock/Media.qml index d292965..326f5ba 100644 --- a/Modules/Lock/Media.qml +++ b/Modules/Lock/Media.qml @@ -57,7 +57,7 @@ CustomClippingRect { animate: true color: Colors.palette.m3primary elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal + font: Tokens.font.title.large horizontalAlignment: Text.AlignHCenter text: (Players.active?.trackTitle ?? qsTr("Nothing playing")) || qsTr("Unknown track") } @@ -67,7 +67,6 @@ CustomClippingRect { animate: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small horizontalAlignment: Text.AlignHCenter text: (Players.active?.trackArtist ?? qsTr("Try playing some music!")) || qsTr("Unknown artist") } diff --git a/Modules/Lock/NotifDock.qml b/Modules/Lock/NotifDock.qml index 3b468a9..c441ff9 100644 --- a/Modules/Lock/NotifDock.qml +++ b/Modules/Lock/NotifDock.qml @@ -66,9 +66,7 @@ ColumnLayout { CustomText { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3outlineVariant - font.family: Config.appearance.font.family.mono - font.pointSize: Tokens.font.size.large - font.weight: 500 + font: Tokens.font.headline.builders.small.width(90).build() text: qsTr("No Notifications") } } @@ -94,8 +92,7 @@ ColumnLayout { to: 1 } } - delegate: NotifGroup { - } + delegate: NotifGroup {} displaced: Transition { Anim { properties: "opacity,scale" diff --git a/Modules/Lock/NotifGroup.qml b/Modules/Lock/NotifGroup.qml index 09bf5d5..0d6d6ad 100644 --- a/Modules/Lock/NotifGroup.qml +++ b/Modules/Lock/NotifGroup.qml @@ -80,7 +80,7 @@ CustomRect { MaterialIcon { color: root.urgency === "critical" ? Colors.palette.m3onError : root.urgency === "low" ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: Icons.getNotifIcon(root.notifs[0]?.summary, root.urgency) } } @@ -133,14 +133,14 @@ CustomRect { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.modelData } CustomText { animate: true color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.notifs[0]?.timeStr ?? "" } @@ -153,12 +153,10 @@ CustomRect { radius: Tokens.rounding.full Behavior on Layout.preferredWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } StateLayer { @@ -181,7 +179,7 @@ CustomRect { Layout.leftMargin: Tokens.padding.extraSmall / 2 animate: true color: root.urgency === "critical" ? Colors.palette.m3onError : Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: root.notifs.length } @@ -261,8 +259,7 @@ CustomRect { opacity: root.expanded ? 1 : 0 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: ColumnLayout { Repeater { @@ -270,8 +267,7 @@ CustomRect { values: root.notifs.slice(Config.notifs.groupPreviewNum) } - NotifLine { - } + NotifLine {} } } } diff --git a/Modules/Lock/Resources.qml b/Modules/Lock/Resources.qml index e06fff7..7ce6c90 100644 --- a/Modules/Lock/Resources.qml +++ b/Modules/Lock/Resources.qml @@ -70,14 +70,13 @@ CustomRect { implicitSize: width Behavior on clampedVal { - Anim { - } + Anim {} } MaterialIcon { anchors.centerIn: parent color: res.fgColor - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge text: res.icon } } diff --git a/Modules/Lock/Weather/BriefInfo.qml b/Modules/Lock/Weather/BriefInfo.qml index fc6a820..cd92521 100644 --- a/Modules/Lock/Weather/BriefInfo.qml +++ b/Modules/Lock/Weather/BriefInfo.qml @@ -16,7 +16,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.large text: Weather.description } @@ -29,7 +29,7 @@ ColumnLayout { animate: true color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: Weather.temp } @@ -44,7 +44,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: qsTr("Feels like %1").arg(Weather.temp) visible: root.rootHeight > 550 } @@ -53,7 +53,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.normal text: { const today = Weather.forecast[0]; return qsTr("High %1 • Low %2").arg(Weather.formatTemp(today?.maxTempC)).arg(Weather.formatTemp(today?.minTempC)); diff --git a/Modules/Lock/Weather/Forecast.qml b/Modules/Lock/Weather/Forecast.qml index 467879b..dd8cf82 100644 --- a/Modules/Lock/Weather/Forecast.qml +++ b/Modules/Lock/Weather/Forecast.qml @@ -24,14 +24,14 @@ CustomRect { MaterialIcon { Layout.topMargin: Math.round(fontInfo.pointSize * 0.12) - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "schedule" } CustomText { id: title - font.pointSize: Tokens.font.size.normal + font: Tokens.font.title.medium text: qsTr("Hourly forecast") } } @@ -65,8 +65,7 @@ CustomRect { shape: MaterialShape.Cookie4Sided Behavior on color { - CAnim { - } + CAnim {} } CustomText { @@ -74,7 +73,7 @@ CustomRect { anchors.centerIn: parent color: hour.index === Weather.currentHourIndex ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: Weather.formatTemp(hour.cond.tempC).slice(0, -1) // Remove C/F } } @@ -82,7 +81,7 @@ CustomRect { MaterialIcon { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3secondary - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge text: hour.cond.icon } @@ -96,7 +95,7 @@ CustomRect { Layout.alignment: Qt.AlignHCenter Layout.topMargin: Tokens.spacing.extraSmall color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: hour.index === 0 ? qsTr("Now") : Qt.formatDateTime(new Date(hour.cond.timestamp.replace("T", " ")), Config.services.useTwelveHourClock ? "ha" : "hh:00") } } diff --git a/Modules/Notifications/Notification.qml b/Modules/Notifications/Notification.qml index 46e2bc4..0cf7d74 100644 --- a/Modules/Notifications/Notification.qml +++ b/Modules/Notifications/Notification.qml @@ -94,8 +94,7 @@ CustomRect { implicitHeight: root.nonAnimHeight Behavior on implicitHeight { - Anim { - } + Anim {} } Loader { @@ -171,6 +170,7 @@ CustomRect { sourceComponent: MaterialIcon { color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer text: Icons.getNotifIcon(root.modelData.summary, root.modelData.urgency) + fontStyle: Tokens.font.icon.medium } } } @@ -222,6 +222,7 @@ CustomRect { maximumLineCount: 1 opacity: root.expanded ? 1 : 0 text: appNameMetrics.elidedText + font: Tokens.font.label.medium Behavior on opacity { Anim { @@ -246,14 +247,12 @@ CustomRect { anchors.leftMargin: Tokens.spacing.small anchors.top: parent.top animate: true - font.pointSize: Tokens.font.size.small height: implicitHeight maximumLineCount: 1 text: summaryMetrics.elidedText Behavior on height { - Anim { - } + Anim {} } states: State { name: "expanded" @@ -281,8 +280,7 @@ CustomRect { property: "topMargin" } - AnchorAnim { - } + AnchorAnim {} } } @@ -303,6 +301,7 @@ CustomRect { anchors.top: parent.top color: Colors.palette.m3onSurfaceVariant text: "•" + font: Tokens.font.body.small states: State { name: "expanded" @@ -314,8 +313,7 @@ CustomRect { } } transitions: Transition { - AnchorAnim { - } + AnchorAnim {} } } @@ -327,7 +325,7 @@ CustomRect { anchors.top: parent.top animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small horizontalAlignment: Text.AlignLeft text: root.modelData.timeStr } @@ -355,14 +353,13 @@ CustomRect { anchors.verticalCenterOffset: root.expanded ? -1 : 1 rotation: root.expanded ? 180 : 0 text: "expand_more" + fontStyle: Tokens.font.icon.medium Behavior on anchors.verticalCenterOffset { - Anim { - } + Anim {} } Behavior on rotation { - Anim { - } + Anim {} } } } @@ -376,7 +373,7 @@ CustomRect { anchors.top: summary.bottom animate: true color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small opacity: root.expanded ? 0 : 1 text: bodyPreviewMetrics.elidedText textFormat: Text.MarkdownText @@ -407,6 +404,7 @@ CustomRect { animate: true color: Colors.palette.m3onSurfaceVariant height: text ? implicitHeight : 0 + font: Tokens.font.body.medium opacity: root.expanded ? 1 : 0 text: root.modelData.body textFormat: Text.MarkdownText @@ -447,6 +445,7 @@ CustomRect { enabled: root.expanded fillWidth: root.modelData.actions.length === 0 icon: "close" + font: Tokens.font.icon.builders.small.size(14).build() inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant isRound: true @@ -464,7 +463,6 @@ CustomRect { enabled: root.expanded fillWidth: true - font.pointSize: Tokens.font.size.small implicitWidth: label.implicitWidth inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant @@ -493,6 +491,7 @@ CustomRect { label.animate: true padding: Tokens.padding.extraSmall shapeMorph: true + font: Tokens.font.icon.builders.small.size(14).build() onClicked: { Quickshell.clipboardText = root.modelData.body; diff --git a/Modules/Notifications/Sidebar/NotifDock.qml b/Modules/Notifications/Sidebar/NotifDock.qml index 6792807..e0cbdf0 100644 --- a/Modules/Notifications/Sidebar/NotifDock.qml +++ b/Modules/Notifications/Sidebar/NotifDock.qml @@ -37,19 +37,15 @@ Item { anchors.leftMargin: root.notifCount > 0 ? 0 : -width - titleText.anchors.leftMargin anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3outline - font.family: "CaskaydiaCove NF" - font.pointSize: 13 - font.weight: 500 + font: Tokens.font.label.large opacity: root.notifCount > 0 ? 1 : 0 text: root.notifCount Behavior on anchors.leftMargin { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } } @@ -62,9 +58,7 @@ Item { anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3outline elide: Text.ElideRight - font.family: "CaskaydiaCove NF" - font.pointSize: 13 - font.weight: 500 + font: Tokens.font.label.large text: root.notifCount > 0 ? qsTr("notification%1").arg(root.notifCount === 1 ? "" : "s") : qsTr("Notifications") } } @@ -86,8 +80,7 @@ Item { opacity: root.notifCount > 0 ? 0 : 1 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: ColumnLayout { spacing: 20 @@ -95,9 +88,7 @@ Item { CustomText { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3outlineVariant - font.family: "CaskaydiaCove NF" - font.pointSize: 18 - font.weight: 500 + font: Tokens.font.headline.builders.small.width(90).build() text: qsTr("No Notifications") } } @@ -160,20 +151,17 @@ Item { scale: root.notifCount > 0 ? 1 : 0.5 Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } sourceComponent: IconButton { id: clearBtn - font.pointSize: Math.round(18 * 1.2) + font: Tokens.font.icon.large icon: "clear_all" - padding: 8 - radius: Tokens.rounding.full + isRound: true onClicked: clearTimer.start() diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Record.qml b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml index 0f90322..5094d86 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Record.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml @@ -13,7 +13,6 @@ CustomRect { required property ScreenState screenState - Layout.fillWidth: true color: Colors.tPalette.m3surfaceContainer implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 @@ -46,7 +45,7 @@ CustomRect { anchors.horizontalCenterOffset: -0.5 anchors.verticalCenterOffset: 1.5 color: Recorder.running ? Colors.palette.m3onSecondary : Colors.palette.m3onSecondaryContainer - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: "screen_record" } } @@ -58,7 +57,7 @@ CustomRect { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: qsTr("Screen Recorder") } @@ -66,7 +65,7 @@ CustomRect { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: Recorder.paused ? qsTr("Recording paused") : Recorder.running ? qsTr("Recording running") : qsTr("Recording off") } } @@ -126,8 +125,7 @@ CustomRect { enabled: false - Anim { - } + Anim {} } Behavior on running { SequentialAnimation { @@ -155,8 +153,7 @@ CustomRect { value: true } - PropertyAction { - } + PropertyAction {} PropertyAction { property: "enabled" @@ -207,8 +204,7 @@ CustomRect { radius: Tokens.rounding.full Behavior on implicitWidth { - Anim { - } + Anim {} } SequentialAnimation on opacity { alwaysRunToEnd: true @@ -236,13 +232,13 @@ CustomRect { anchors.centerIn: parent animate: true color: Recorder.paused ? Colors.palette.m3onTertiary : Colors.palette.m3onError - font.family: Config.appearance.font.family.mono + font: Tokens.font.mono.small text: Recorder.paused ? "PAUSED" : "REC" } } CustomText { - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: { const elapsed = Recorder.elapsed; @@ -266,8 +262,9 @@ CustomRect { IconButton { checked: Recorder.paused - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: Recorder.paused ? "play_arrow" : "pause" + isRound: true isToggle: true label.animate: true type: IconButton.Tonal @@ -279,8 +276,9 @@ CustomRect { } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "stop" + isRound: true inactiveColor: Colors.palette.m3error inactiveOnColor: Colors.palette.m3onError diff --git a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml index 876c9d4..5c28149 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml @@ -29,14 +29,14 @@ ColumnLayout { MaterialIcon { Layout.alignment: Qt.AlignVCenter - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: "list" } CustomText { Layout.alignment: Qt.AlignVCenter Layout.fillWidth: true - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: qsTr("Recordings") } @@ -177,7 +177,7 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter Layout.preferredHeight: root.screenState.recordingListExpanded ? implicitHeight : 0 color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge opacity: root.screenState.recordingListExpanded ? 1 : 0 scale: root.screenState.recordingListExpanded ? 1 : 0 text: "scan_delete" diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml index a84f644..e0128f9 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml @@ -53,7 +53,7 @@ CustomRect { spacing: Tokens.spacing.medium CustomText { - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: qsTr("Quick Toggles") } diff --git a/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml b/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml index ae190e1..7cb4b6e 100644 --- a/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml +++ b/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml @@ -15,8 +15,7 @@ CustomRect { radius: Tokens.rounding.small Behavior on implicitHeight { - Anim { - } + Anim {} } RowLayout { @@ -39,7 +38,7 @@ CustomRect { anchors.centerIn: parent color: IdleInhibitor.enabled ? Colors.palette.m3onSecondary : Colors.palette.m3onSecondaryContainer - font.pointSize: 18 + fontStyle: Tokens.font.icon.large text: "coffee" } } @@ -51,7 +50,7 @@ CustomRect { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: 13 + font: Tokens.font.body.medium text: qsTr("Keep Awake") } @@ -59,7 +58,7 @@ CustomRect { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: 11 + font: Tokens.font.body.small text: IdleInhibitor.enabled ? qsTr("Preventing sleep mode") : qsTr("Normal power management") } } @@ -83,16 +82,13 @@ CustomRect { scale: IdleInhibitor.enabled ? 1 : 0.5 Behavior on anchors.bottomMargin { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } sourceComponent: CustomRect { color: Colors.palette.m3primary @@ -105,7 +101,7 @@ CustomRect { anchors.centerIn: parent color: Colors.palette.m3onPrimary - font.pointSize: Math.round(11 * 0.9) + font: Tokens.font.body.builders.small.size(Math.round(Tokens.font.body.small.pointSize * 0.9)).build() text: qsTr("Active since %1").arg(Qt.formatTime(IdleInhibitor.enabledSince, Config.services.useTwelveHourClock ? "hh:mm a" : "hh:mm")) } } diff --git a/Modules/Polkit/Polkit.qml b/Modules/Polkit/Polkit.qml index 7bff94e..c598cd5 100644 --- a/Modules/Polkit/Polkit.qml +++ b/Modules/Polkit/Polkit.qml @@ -112,7 +112,7 @@ Scope { MaterialIcon { anchors.fill: parent - font.pointSize: 64 + fontStyle: Tokens.font.icon.size(64).build() horizontalAlignment: Text.AlignHCenter text: "security" verticalAlignment: Text.AlignVCenter @@ -129,8 +129,7 @@ Scope { CustomText { Layout.alignment: Qt.AlignLeft Layout.preferredWidth: Math.min(600, contentWidth) - font.bold: true - font.pointSize: 16 + font: Tokens.font.title.builders.medium.weight(Font.Bold).build() text: polkitAgent.flow?.message ?? "" wrapMode: Text.WordWrap } @@ -139,8 +138,7 @@ Scope { Layout.alignment: Qt.AlignLeft Layout.preferredWidth: Math.min(600, contentWidth) color: Colors.tPalette.m3onSurfaceVariant - font.bold: true - font.pointSize: 12 + font: Tokens.font.title.builders.small.weight(Font.Bold).build() text: polkitAgent.flow?.supplementaryMessage || "No Additional Information" wrapMode: Text.WordWrap } @@ -155,6 +153,7 @@ Scope { placeholderText: polkitAgent.flow?.failed ? " Incorrect Password" : " Input Password" placeholderTextColor: polkitAgent.flow?.failed ? Colors.palette.m3onError : Colors.tPalette.m3onSurfaceVariant selectByMouse: true + font: Tokens.font.body.small background: CustomRect { color: (polkitAgent.flow?.failed && passInput.text === "") ? Colors.palette.m3error : Colors.tPalette.m3surfaceVariant @@ -342,8 +341,7 @@ Scope { visible: panelWindow.visible Behavior on color { - CAnim { - } + CAnim {} } anchors { diff --git a/Modules/Resources/Cards/HeroCard.qml b/Modules/Resources/Cards/HeroCard.qml index 668a329..2b488de 100644 --- a/Modules/Resources/Cards/HeroCard.qml +++ b/Modules/Resources/Cards/HeroCard.qml @@ -27,8 +27,7 @@ CustomClippingRect { implicitWidth: parent.width * root.usage Behavior on implicitWidth { - Anim { - } + Anim {} } } @@ -45,8 +44,7 @@ CustomClippingRect { value: root.usage Behavior on clampedVal { - Anim { - } + Anim {} } MaterialIcon { @@ -54,7 +52,7 @@ CustomClippingRect { anchors.centerIn: parent color: root.accent - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: root.icon } } @@ -125,15 +123,14 @@ CustomClippingRect { anchors.right: parent.right color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: qsTr("Usage") } CustomText { anchors.right: parent.right color: root.accent - font.pointSize: Tokens.font.size.extraLarge - font.weight: Font.Medium + font: Tokens.font.headline.builders.small.width(50).build() text: isNaN(root.usage) ? "...%" : Math.round(root.usage * 100) + "%" } } diff --git a/Modules/Resources/Cards/MemoryCard.qml b/Modules/Resources/Cards/MemoryCard.qml index 168663d..bb56af0 100644 --- a/Modules/Resources/Cards/MemoryCard.qml +++ b/Modules/Resources/Cards/MemoryCard.qml @@ -51,8 +51,7 @@ CustomRect { value: Memory.percentage Behavior on clampedVal { - Anim { - } + Anim {} } ColumnLayout { @@ -65,7 +64,7 @@ CustomRect { CustomText { Layout.alignment: Qt.AlignHCenter color: root.accent - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: Math.round(Memory.percentage * 100) + "%" } diff --git a/Modules/Resources/Cards/StorageCard.qml b/Modules/Resources/Cards/StorageCard.qml index 238bed7..83b4ecc 100644 --- a/Modules/Resources/Cards/StorageCard.qml +++ b/Modules/Resources/Cards/StorageCard.qml @@ -43,8 +43,7 @@ CustomRect { value: root.percentage Behavior on clampedVal { - Anim { - } + Anim {} } ColumnLayout { @@ -56,14 +55,14 @@ CustomRect { MaterialIcon { Layout.alignment: Qt.AlignHCenter color: root.accent - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: "hard_drive" } CustomText { Layout.alignment: Qt.AlignHCenter color: root.accent - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: Math.round(root.percentage * 100) + "%" } @@ -112,8 +111,7 @@ CustomRect { model: Storage.disks - DiskItem { - } + DiskItem {} } } } diff --git a/Modules/Resources/Cards/VramCard.qml b/Modules/Resources/Cards/VramCard.qml index 789bc39..034f00f 100644 --- a/Modules/Resources/Cards/VramCard.qml +++ b/Modules/Resources/Cards/VramCard.qml @@ -53,8 +53,7 @@ CustomRect { value: Gpu.memoryUsed / Gpu.memoryTotal Behavior on clampedVal { - Anim { - } + Anim {} } ColumnLayout { @@ -67,7 +66,7 @@ CustomRect { CustomText { Layout.alignment: Qt.AlignHCenter color: root.accent - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: Math.round(circularIndicator.value * 100) + "%" } diff --git a/Modules/Settings/Common/AudioDeviceList.qml b/Modules/Settings/Common/AudioDeviceList.qml index febdd2a..d0d21bb 100644 --- a/Modules/Settings/Common/AudioDeviceList.qml +++ b/Modules/Settings/Common/AudioDeviceList.qml @@ -60,12 +60,11 @@ ItemList { anchors.centerIn: parent color: device.active ? Colors.palette.m3onPrimary : Colors.palette.m3onSecondaryContainer fill: device.active ? 1 : 0 - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium text: root.iconName Behavior on fill { - Anim { - } + Anim {} } } } @@ -73,13 +72,13 @@ ItemList { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller + font: Tokens.font.body.small text: device.modelData?.description || device.modelData?.name || qsTr("Unknown") } MaterialIcon { color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.medium opacity: device.active ? 1 : 0 text: "check" diff --git a/Modules/Settings/Common/BlobPopup.qml b/Modules/Settings/Common/BlobPopup.qml index 1978d10..bad56b1 100644 --- a/Modules/Settings/Common/BlobPopup.qml +++ b/Modules/Settings/Common/BlobPopup.qml @@ -35,8 +35,7 @@ Item { smoothing: Tokens.rounding.largeIncreased Behavior on color { - CAnim { - } + CAnim {} } } @@ -49,8 +48,7 @@ Item { radius: root.open ? Tokens.rounding.largeIncreased : Tokens.rounding.largeIncreased Behavior on anchors.margins { - Anim { - } + Anim {} } Behavior on radius { Anim { @@ -122,7 +120,7 @@ Item { anchors.centerIn: parent color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "view_apps" } } diff --git a/Modules/Settings/Common/DialogRowButton.qml b/Modules/Settings/Common/DialogRowButton.qml index 80cc881..ee9d4cb 100644 --- a/Modules/Settings/Common/DialogRowButton.qml +++ b/Modules/Settings/Common/DialogRowButton.qml @@ -185,7 +185,7 @@ Item { spacing: 0 CustomText { - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.builders.large.weight(Font.Normal).build() text: root.header } diff --git a/Modules/Settings/Common/DialogSelectButton.qml b/Modules/Settings/Common/DialogSelectButton.qml index a3a9c74..5866d6b 100644 --- a/Modules/Settings/Common/DialogSelectButton.qml +++ b/Modules/Settings/Common/DialogSelectButton.qml @@ -65,7 +65,6 @@ DialogRowButton { anchors.verticalCenter: parent.verticalCenter color: item.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller text: root.labelFor(item.modelData) } @@ -76,7 +75,7 @@ DialogRowButton { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onTertiaryContainer - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium opacity: item.selected ? 1 : 0 text: "check" diff --git a/Modules/Settings/Common/InfoRow.qml b/Modules/Settings/Common/InfoRow.qml index 0cec60b..63f510d 100644 --- a/Modules/Settings/Common/InfoRow.qml +++ b/Modules/Settings/Common/InfoRow.qml @@ -24,7 +24,7 @@ ConnectedRect { MaterialIcon { color: root.iconColor - font.pointSize: Tokens.font.size.small + fontStyle: Tokens.font.icon.small text: root.icon } } @@ -53,14 +53,14 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small } CustomText { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: root.subtext visible: root.subtext } @@ -72,7 +72,7 @@ ConnectedRect { Layout.maximumWidth: root.width / 2 color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small horizontalAlignment: Text.AlignRight } } diff --git a/Modules/Settings/Common/ItemList.qml b/Modules/Settings/Common/ItemList.qml index b5fcd41..d32f678 100644 --- a/Modules/Settings/Common/ItemList.qml +++ b/Modules/Settings/Common/ItemList.qml @@ -23,8 +23,7 @@ ConnectedRect { implicitHeight: (showList && list.count > 0 ? list.contentHeight : placeholder.implicitHeight + Tokens.padding.extraLarge * 2) + extraHeight Behavior on implicitHeight { - Anim { - } + Anim {} } Loader { @@ -46,7 +45,7 @@ ConnectedRect { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: root.placeholderIcon } @@ -54,7 +53,7 @@ ConnectedRect { Layout.alignment: Qt.AlignHCenter animate: true color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: root.placeholderText } } diff --git a/Modules/Settings/Common/ListEditor.qml b/Modules/Settings/Common/ListEditor.qml index e9e7705..9685068 100644 --- a/Modules/Settings/Common/ListEditor.qml +++ b/Modules/Settings/Common/ListEditor.qml @@ -54,14 +54,12 @@ ListView { } } Behavior on implicitHeight { - Anim { - } + Anim {} } model: DelegateModel { id: visualModel - delegate: ListRow { - } + delegate: ListRow {} model: ScriptModel { id: valuesModel } @@ -280,7 +278,7 @@ ListView { id: dragIcon color: Qt.alpha(Colors.palette.m3onSurfaceVariant, enabledSwitch.checked ? 1 : 0.5) - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "drag_indicator" } @@ -290,7 +288,6 @@ ListView { Layout.fillWidth: true color: Qt.alpha(Colors.palette.m3onSurface, enabledSwitch.checked ? 1 : 0.5) elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller text: root.labelFor(item.modelData) } @@ -298,13 +295,13 @@ ListView { id: enabledSwitch checked: root.toggledFor(item.modelData) - font.pointSize: Tokens.font.size.normal + font: Tokens.font.icon.medium onToggled: root.itemToggled(item.DelegateModel.itemsIndex, checked) } IconButton { - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "delete" inactiveOnColor: Colors.palette.m3error isRound: true diff --git a/Modules/Settings/Common/NavRow.qml b/Modules/Settings/Common/NavRow.qml index a496c53..7d0ca3a 100644 --- a/Modules/Settings/Common/NavRow.qml +++ b/Modules/Settings/Common/NavRow.qml @@ -1,71 +1,8 @@ pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Layouts -import qs.Components -import ZShell.Config -import qs.Services -ConnectedRect { - id: root - - property alias icon: icon.text - property alias status: status.text - property alias text: label.text - - signal clicked - - Layout.fillWidth: true - implicitHeight: navLayout.implicitHeight + navLayout.anchors.margins * 2 - - StateLayer { - onClicked: root.clicked() - } - - RowLayout { - id: navLayout - - anchors.fill: parent - anchors.leftMargin: Tokens.padding.largeIncreased - anchors.margins: Tokens.padding.small - anchors.rightMargin: Tokens.padding.largeIncreased - spacing: Tokens.spacing.medium - - MaterialIcon { - id: icon - - color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.large - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - - CustomText { - id: label - - Layout.fillWidth: true - elide: Text.ElideRight - font.pointSize: Tokens.font.size.small - } - - CustomText { - id: status - - Layout.fillWidth: true - animate: true - color: Colors.palette.m3outline - elide: Text.ElideRight - font.pointSize: Tokens.font.size.small - visible: text - } - } - - MaterialIcon { - color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal - text: "chevron_right" - } - } +RowButton { + trailingIcon: "chevron_right" + subLabel.animate: true } diff --git a/Modules/Settings/Common/PageBase.qml b/Modules/Settings/Common/PageBase.qml index 5b4200c..de02ceb 100644 --- a/Modules/Settings/Common/PageBase.qml +++ b/Modules/Settings/Common/PageBase.qml @@ -134,6 +134,7 @@ ColumnLayout { inactiveOnColor: Colors.palette.m3onSurfaceVariant isRound: true type: IconButton.Tonal + font: Tokens.font.icon.medium onClicked: root.sState.closeSubPage() } @@ -142,7 +143,7 @@ ColumnLayout { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.large text: root.title } } diff --git a/Modules/Settings/Common/PopupRow.qml b/Modules/Settings/Common/PopupRow.qml index 3e7394f..0b3a22a 100644 --- a/Modules/Settings/Common/PopupRow.qml +++ b/Modules/Settings/Common/PopupRow.qml @@ -40,7 +40,7 @@ ConnectedRect { id: icon color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.larger + fontStyle: Tokens.font.icon.medium } ColumnLayout { @@ -52,7 +52,7 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small } CustomText { @@ -62,7 +62,7 @@ ConnectedRect { animate: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small visible: text } } diff --git a/Modules/Settings/Common/RowButton.qml b/Modules/Settings/Common/RowButton.qml index 50d1c3d..419fae8 100644 --- a/Modules/Settings/Common/RowButton.qml +++ b/Modules/Settings/Common/RowButton.qml @@ -51,7 +51,7 @@ ConnectedRect { visible: root.icon !== "" fill: 1 text: root.icon - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium } Column { @@ -66,7 +66,6 @@ ConnectedRect { anchors.left: parent.left anchors.right: parent.right elide: Text.ElideRight - font.pointSize: Tokens.font.size.small } CustomText { @@ -76,7 +75,7 @@ ConnectedRect { anchors.right: parent.right color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small visible: text } } @@ -108,7 +107,7 @@ ConnectedRect { sourceComponent: MaterialIcon { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: root.trailingIcon } } diff --git a/Modules/Settings/Common/SearchPopup.qml b/Modules/Settings/Common/SearchPopup.qml index 193fd1a..4bfc086 100644 --- a/Modules/Settings/Common/SearchPopup.qml +++ b/Modules/Settings/Common/SearchPopup.qml @@ -70,7 +70,6 @@ DialogRowButton { anchors.verticalCenter: parent.verticalCenter color: item.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller text: item.modelData } @@ -81,7 +80,7 @@ DialogRowButton { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onTertiaryContainer - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium opacity: item.selected ? 1 : 0 text: "check" @@ -100,12 +99,12 @@ DialogRowButton { Layout.fillWidth: true bg.border.color: Colors.palette.m3outlineVariant bg.color: Colors.tPalette.m3surfaceContainerLowest - clearIcon.font.pointSize: Tokens.font.size.large + clearIcon.font: Tokens.font.icon.medium clearIcon.padding: Tokens.padding.extraSmall - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small placeholderText: qsTr("Search...") searchIcon.anchors.leftMargin: Tokens.padding.large - searchIcon.font.pointSize: Tokens.font.size.large + searchIcon.fontStyle: Tokens.font.icon.medium Behavior on bg.border.color { CAnim {} diff --git a/Modules/Settings/Common/SectionHeader.qml b/Modules/Settings/Common/SectionHeader.qml index 0433f65..b826399 100644 --- a/Modules/Settings/Common/SectionHeader.qml +++ b/Modules/Settings/Common/SectionHeader.qml @@ -13,5 +13,5 @@ CustomText { Layout.topMargin: first ? 0 : Tokens.spacing.largeIncreased - ((parent as ColumnLayout).spacing ?? 0) color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal + font: Tokens.font.label.medium } diff --git a/Modules/Settings/Common/SelectRow.qml b/Modules/Settings/Common/SelectRow.qml index 0689182..ce97cad 100644 --- a/Modules/Settings/Common/SelectRow.qml +++ b/Modules/Settings/Common/SelectRow.qml @@ -40,14 +40,14 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller + font: Tokens.font.body.small } CustomText { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: root.subtext visible: root.subtext } diff --git a/Modules/Settings/Common/SliderRow.qml b/Modules/Settings/Common/SliderRow.qml index d4669ea..10c69b1 100644 --- a/Modules/Settings/Common/SliderRow.qml +++ b/Modules/Settings/Common/SliderRow.qml @@ -40,14 +40,14 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small } CustomText { id: valueLabel color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small } } diff --git a/Modules/Settings/Common/SpinRow.qml b/Modules/Settings/Common/SpinRow.qml index 93c9d13..429d4d1 100644 --- a/Modules/Settings/Common/SpinRow.qml +++ b/Modules/Settings/Common/SpinRow.qml @@ -39,14 +39,14 @@ ConnectedRect { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller + font: Tokens.font.body.small } CustomText { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: root.subtext visible: root.subtext } diff --git a/Modules/Settings/Common/TextFieldRow.qml b/Modules/Settings/Common/TextFieldRow.qml index 38dd9a8..32ec8a4 100644 --- a/Modules/Settings/Common/TextFieldRow.qml +++ b/Modules/Settings/Common/TextFieldRow.qml @@ -62,7 +62,6 @@ ConnectedRect { visible: root.subtext text: input.isError && root.errorText ? root.errorText : root.subtext color: input.isError && root.errorText ? Colors.palette.m3error : Colors.palette.m3outline - font.pointSize: Tokens.font.size.small elide: Text.ElideRight animate: root.errorText } diff --git a/Modules/Settings/Common/TimeDialogRow.qml b/Modules/Settings/Common/TimeDialogRow.qml index 25bc7cb..701b2ed 100644 --- a/Modules/Settings/Common/TimeDialogRow.qml +++ b/Modules/Settings/Common/TimeDialogRow.qml @@ -193,7 +193,7 @@ Item { spacing: 0 CustomText { - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.builders.large.weight(Font.Normal).build() text: root.header } diff --git a/Modules/Settings/Common/TimeDialogToggle.qml b/Modules/Settings/Common/TimeDialogToggle.qml index 0b540fa..9d75041 100644 --- a/Modules/Settings/Common/TimeDialogToggle.qml +++ b/Modules/Settings/Common/TimeDialogToggle.qml @@ -34,14 +34,14 @@ ConnectedRect { CustomText { id: text - font.pointSize: Tokens.font.size.smaller + font: Tokens.font.body.small } CustomText { id: subtext color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small wrapMode: Text.WordWrap } } diff --git a/Modules/Settings/Common/TimeInput.qml b/Modules/Settings/Common/TimeInput.qml index c8bfd2b..f01100f 100644 --- a/Modules/Settings/Common/TimeInput.qml +++ b/Modules/Settings/Common/TimeInput.qml @@ -137,7 +137,7 @@ ColumnLayout { CustomText { id: startSeparator - font.pointSize: Tokens.font.size.extraLarge + font: Tokens.font.body.builders.large.size(28).weight(Font.DemiBold).build() text: ":" } @@ -360,7 +360,7 @@ ColumnLayout { CustomText { id: endSeparator - font.pointSize: Tokens.font.size.extraLarge + font: Tokens.font.body.builders.large.size(28).weight(Font.DemiBold).build() text: ":" } diff --git a/Modules/Settings/Common/ToggleRow.qml b/Modules/Settings/Common/ToggleRow.qml index 36fb583..2eea3f5 100644 --- a/Modules/Settings/Common/ToggleRow.qml +++ b/Modules/Settings/Common/ToggleRow.qml @@ -20,6 +20,7 @@ CustomSwitch { Layout.fillWidth: true cLayer: 2 + font: Tokens.font.body.small horizontalPadding: Tokens.padding.largeIncreased implicitHeight: Math.max(implicitContentHeight, implicitIndicatorHeight) + verticalPadding * 2 implicitWidth: implicitContentWidth + implicitIndicatorWidth + horizontalPadding * 2 @@ -59,7 +60,7 @@ CustomSwitch { anchors.left: parent.left anchors.right: parent.right elide: Text.ElideRight - font.pointSize: Tokens.font.size.smaller + font: root.font opacity: root.enabled ? 1 : 0.5 text: root.text @@ -75,7 +76,7 @@ CustomSwitch { anchors.right: parent.right color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small opacity: root.enabled ? 1 : 0.5 text: root.subtext visible: root.subtext diff --git a/Modules/Settings/NavPane.qml b/Modules/Settings/NavPane.qml index e802071..820e3e3 100644 --- a/Modules/Settings/NavPane.qml +++ b/Modules/Settings/NavPane.qml @@ -18,16 +18,15 @@ ColumnLayout { Layout.fillWidth: true bg.border.color: Colors.palette.m3outlineVariant bg.color: Colors.tPalette.m3surfaceContainerLowest - clearIcon.font.pointSize: Tokens.font.size.large + clearIcon.font: Tokens.font.icon.medium clearIcon.padding: Tokens.padding.extraSmall - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.large placeholderText: qsTr("Search settings") searchIcon.anchors.leftMargin: Tokens.padding.largeIncreased - searchIcon.font.pointSize: Tokens.font.size.large + searchIcon.fontStyle: Tokens.font.icon.medium Behavior on bg.border.color { - CAnim { - } + CAnim {} } Binding { diff --git a/Modules/Settings/NavPane/NavLocations.qml b/Modules/Settings/NavPane/NavLocations.qml index 5ccf726..bbd096b 100644 --- a/Modules/Settings/NavPane/NavLocations.qml +++ b/Modules/Settings/NavPane/NavLocations.qml @@ -107,7 +107,7 @@ VerticalFadeFlickable { anchors.fill: parent anchors.margins: Tokens.padding.large - spacing: Tokens.spacing.small + spacing: Tokens.spacing.medium CustomRect { Layout.bottomMargin: -1 @@ -122,7 +122,7 @@ VerticalFadeFlickable { anchors.verticalCenterOffset: 1 color: item.isCurrentPage ? Colors.palette.m3onPrimary : Colors.palette.m3onSecondaryContainer fill: item.modelData.noFill ? 0 : 1 - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.builders.medium.weight(Font.Medium).build() grade: 25 text: item.modelData.icon } @@ -135,6 +135,7 @@ VerticalFadeFlickable { CustomText { Layout.fillWidth: true elide: Text.ElideRight + font: Tokens.font.body.medium text: item.modelData.name } @@ -142,6 +143,7 @@ VerticalFadeFlickable { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight + font: Tokens.font.label.small text: item.modelData.description } } @@ -198,7 +200,7 @@ VerticalFadeFlickable { MaterialIcon { color: Colors.palette.m3primary fill: 1 - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.builders.medium.weight(Font.Medium).build() text: group.modelData.icon } @@ -206,7 +208,7 @@ VerticalFadeFlickable { Layout.fillWidth: true color: Colors.palette.m3secondary elide: Text.ElideRight - font.pointSize: Tokens.font.size.large + font: Tokens.font.body.large text: group.modelData.page } } @@ -283,7 +285,7 @@ VerticalFadeFlickable { Layout.fillWidth: true color: Colors.palette.m3onSurfaceVariant elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: { const labels = result.modelData.crumbLabels.slice(1); const section = result.modelData.section; @@ -297,7 +299,6 @@ VerticalFadeFlickable { Layout.fillWidth: true color: Colors.palette.m3onSurface elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal text: SettingsSearcher.highlight(result.modelData.title, root.search, Colors.palette.m3primary) textFormat: text.includes(" 0 @@ -319,7 +320,8 @@ VerticalFadeFlickable { z: 1 onClicked: { - root.sState.jumpToSetting(result.modelData.pageIdx, result.modelData.subPath, result.modelData.anchor); + const target = result.modelData.targetSubPath; + root.sState.jumpToSetting(result.modelData.pageIdx, target.length > 0 ? target : result.modelData.subPath, target.length > 0 ? "" : result.modelData.anchor); } } @@ -349,7 +351,6 @@ VerticalFadeFlickable { Layout.fillWidth: true Layout.topMargin: Tokens.padding.large color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal horizontalAlignment: Text.AlignHCenter text: qsTr("No matching settings") visible: root.searching && root.results.length === 0 diff --git a/Modules/Settings/PageCompRegistry.qml b/Modules/Settings/PageCompRegistry.qml index 444fb1e..46a2249 100644 --- a/Modules/Settings/PageCompRegistry.qml +++ b/Modules/Settings/PageCompRegistry.qml @@ -182,7 +182,7 @@ QtObject { MaterialIcon { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3outlineVariant - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge text: "handyman" } diff --git a/Modules/Settings/Pages/AboutPage.qml b/Modules/Settings/Pages/AboutPage.qml index 1fae077..53bc19e 100644 --- a/Modules/Settings/Pages/AboutPage.qml +++ b/Modules/Settings/Pages/AboutPage.qml @@ -56,14 +56,14 @@ PageBase { CustomText { Layout.alignment: Qt.AlignHCenter Layout.topMargin: Tokens.spacing.small - font.pointSize: Tokens.font.size.large + font: Tokens.font.headline.large text: "ZShell" } CustomText { Layout.alignment: Qt.AlignHCenter color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: ZUtils.version ? `v${ZUtils.version}` : "…" } } diff --git a/Modules/Settings/Pages/Apps/AllApps.qml b/Modules/Settings/Pages/Apps/AllApps.qml index dc05f73..0dde289 100644 --- a/Modules/Settings/Pages/Apps/AllApps.qml +++ b/Modules/Settings/Pages/Apps/AllApps.qml @@ -67,7 +67,7 @@ PageBase { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: appItem.modelData.name } @@ -75,7 +75,7 @@ PageBase { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: (appItem.modelData.comment || appItem.modelData.genericName) ?? "" visible: text } @@ -84,14 +84,14 @@ PageBase { MaterialIcon { color: Colors.palette.m3primary fill: 1 - font.pointSize: Tokens.font.size.small + fontStyle: Tokens.font.icon.small text: "favorite" visible: Strings.testRegexList(Config.launcher.favoriteApps, appItem.modelData.id) } MaterialIcon { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "chevron_right" } } diff --git a/Modules/Settings/Pages/Apps/AppInfo.qml b/Modules/Settings/Pages/Apps/AppInfo.qml index b3273f4..333a880 100644 --- a/Modules/Settings/Pages/Apps/AppInfo.qml +++ b/Modules/Settings/Pages/Apps/AppInfo.qml @@ -57,7 +57,7 @@ PageBase { CustomText { Layout.fillWidth: true - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: root.app?.name ?? "" wrapMode: Text.WordWrap } @@ -65,7 +65,7 @@ PageBase { CustomText { Layout.fillWidth: true color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: (root.app?.comment || root.app?.genericName) ?? "" visible: text wrapMode: Text.WordWrap @@ -152,7 +152,7 @@ PageBase { id: label Layout.alignment: Qt.AlignTop - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small } Item { @@ -165,7 +165,6 @@ PageBase { Layout.fillWidth: true Layout.maximumWidth: implicitWidth + 1 // Whyyyyyyyyy color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small wrapMode: Text.WrapAtWordBoundaryOrAnywhere } } diff --git a/Modules/Settings/Pages/AppsPage.qml b/Modules/Settings/Pages/AppsPage.qml index a9ff2f7..43cf99f 100644 --- a/Modules/Settings/Pages/AppsPage.qml +++ b/Modules/Settings/Pages/AppsPage.qml @@ -76,7 +76,7 @@ PageBase { icon: "apps" last: true settingAnchor: "apps-all-apps" - status: qsTr("Browse installed apps, set favorites and hidden") + subtext: qsTr("Browse installed apps, set favorites and hidden") text: qsTr("All apps") onClicked: root.sState.openSubPage(1) @@ -154,7 +154,7 @@ PageBase { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: appItem.modelData.name } @@ -162,7 +162,7 @@ PageBase { Layout.fillWidth: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: (appItem.modelData.comment || appItem.modelData.genericName) ?? "" visible: text } @@ -171,7 +171,7 @@ PageBase { MaterialIcon { color: Colors.palette.m3primary fill: 1 - font.pointSize: Tokens.font.size.small + fontStyle: Tokens.font.icon.small text: "favorite" visible: Strings.testRegexList(Config.launcher.favoriteApps, appItem.modelData.id) } diff --git a/Modules/Settings/Pages/Audio/AppVolumes.qml b/Modules/Settings/Pages/Audio/AppVolumes.qml index 59d634b..9d59da6 100644 --- a/Modules/Settings/Pages/Audio/AppVolumes.qml +++ b/Modules/Settings/Pages/Audio/AppVolumes.qml @@ -28,7 +28,6 @@ PageBase { Layout.fillWidth: true Layout.leftMargin: Tokens.padding.extraSmall color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small text: qsTr("Adjust the volume of individual apps currently playing audio.") wrapMode: Text.WordWrap } diff --git a/Modules/Settings/Pages/AudioPage.qml b/Modules/Settings/Pages/AudioPage.qml index fa8f225..7c6e0f7 100644 --- a/Modules/Settings/Pages/AudioPage.qml +++ b/Modules/Settings/Pages/AudioPage.qml @@ -103,7 +103,7 @@ PageBase { spacing: Tokens.spacing.small MaterialIcon { - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "tune" } @@ -114,7 +114,7 @@ PageBase { CustomText { Layout.fillWidth: true elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: qsTr("App volumes") } @@ -123,14 +123,14 @@ PageBase { animate: true color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small text: Audio.streams.length === 0 ? qsTr("No apps playing audio") : Audio.streams.length === 1 ? qsTr("1 app playing audio") : qsTr("%1 apps playing audio").arg(Audio.streams.length) } } MaterialIcon { color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.medium text: "chevron_right" } } diff --git a/Modules/Settings/Pages/Panels/BarPanel.qml b/Modules/Settings/Pages/Panels/BarPanel.qml index c14f3bd..8f6ddb6 100644 --- a/Modules/Settings/Pages/Panels/BarPanel.qml +++ b/Modules/Settings/Pages/Panels/BarPanel.qml @@ -91,7 +91,7 @@ PageBase { first: true icon: "widgets" settingAnchor: "bar-tray" - status: qsTr("System tray icons") + subtext: qsTr("System tray icons") text: qsTr("Tray") onClicked: root.sState.openSubPage(6) @@ -100,7 +100,7 @@ PageBase { NavRow { icon: "signal_cellular_alt" settingAnchor: "bar-statusicons" - status: qsTr("Visible indicators") + subtext: qsTr("Visible indicators") text: qsTr("Status icons") onClicked: root.sState.openSubPage(7) @@ -110,7 +110,7 @@ PageBase { icon: "schedule" last: true settingAnchor: "bar-clock" - status: qsTr("Date, icon, background") + subtext: qsTr("Date, icon, background") text: qsTr("Clock") onClicked: root.sState.openSubPage(8) diff --git a/Modules/Settings/Pages/PanelsPage.qml b/Modules/Settings/Pages/PanelsPage.qml index eeb33ba..6a16a5b 100644 --- a/Modules/Settings/Pages/PanelsPage.qml +++ b/Modules/Settings/Pages/PanelsPage.qml @@ -17,7 +17,7 @@ PageBase { first: true icon: "dock_to_bottom" settingAnchor: "panels-bar" - status: !Config.bar.autoHide ? qsTr("Always visible") : qsTr("Reveal on hover") + subtext: !Config.bar.autoHide ? qsTr("Always visible") : qsTr("Reveal on hover") text: qsTr("Bar") onClicked: root.sState.openSubPage(1) @@ -26,7 +26,7 @@ PageBase { NavRow { icon: "dashboard" settingAnchor: "panels-dashboard" - status: Config.dashboard.enabled ? qsTr("Enabled") : qsTr("Disabled") + subtext: Config.dashboard.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Dashboard") onClicked: root.sState.openSubPage(2) @@ -35,7 +35,7 @@ PageBase { NavRow { icon: "insert_chart" settingAnchor: "panels-resources" - status: Config.dashboard.performance.enabled ? qsTr("Enabled") : qsTr("Disabled") + subtext: Config.dashboard.performance.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Resources") onClicked: root.sState.openSubPage(3) @@ -44,7 +44,7 @@ PageBase { NavRow { icon: "apps" settingAnchor: "panels-launcher" - status: Config.launcher.enabled ? qsTr("Enabled") : qsTr("Disabled") + subtext: Config.launcher.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Launcher") onClicked: root.sState.openSubPage(4) @@ -54,7 +54,7 @@ PageBase { icon: "dock_to_right" last: true settingAnchor: "panels-sidebar" - status: Config.sidebar.enabled ? qsTr("Enabled") : qsTr("Disabled") + subtext: Config.sidebar.enabled ? qsTr("Enabled") : qsTr("Disabled") text: qsTr("Sidebar") onClicked: root.sState.openSubPage(5) diff --git a/Modules/Settings/Pages/ServicesPage.qml b/Modules/Settings/Pages/ServicesPage.qml index e179635..11b655e 100644 --- a/Modules/Settings/Pages/ServicesPage.qml +++ b/Modules/Settings/Pages/ServicesPage.qml @@ -72,7 +72,7 @@ PageBase { icon: "notifications" last: true settingAnchor: "services-notifications" - status: qsTr("Notifications, toasts, timeouts") + subtext: qsTr("Notifications, toasts, timeouts") text: qsTr("Notifications") onClicked: root.sState.openSubPage(1) diff --git a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml index 18016d2..a4d4906 100644 --- a/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml +++ b/Modules/Settings/Pages/Wallpaper/WallpaperSelect.qml @@ -25,7 +25,7 @@ PageBase { CustomText { Layout.topMargin: Tokens.spacing.largeIncreased - font.pointSize: Tokens.font.size.large + font: Tokens.font.title.small text: qsTr("Wallpapers") } diff --git a/Plugins/ZShell/Config/CMakeLists.txt b/Plugins/ZShell/Config/CMakeLists.txt index 339a024..fdb1ea2 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -7,6 +7,8 @@ qml_module(ZShell-config configlist.hpp configlist.cpp config.hpp config.cpp anim.hpp anim.cpp + fontbuilder.hpp fontbuilder.cpp + font.hpp font.cpp tokens.hpp appearance.hpp background.hpp @@ -25,4 +27,6 @@ qml_module(ZShell-config services.hpp sidebar.hpp utilities.hpp + LIBRARIES + Qt::Gui ) diff --git a/Plugins/ZShell/Config/appearance.hpp b/Plugins/ZShell/Config/appearance.hpp index b4b0e5a..5181423 100644 --- a/Plugins/ZShell/Config/appearance.hpp +++ b/Plugins/ZShell/Config/appearance.hpp @@ -1,19 +1,78 @@ #pragma once #include "configobject.hpp" +#include +#include +#include #include namespace ZShell::config { +class AnimDurations : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) + Q_PROPERTY( + int expressiveDefaultEffects READ expressiveDefaultEffects NOTIFY + scaleChanged) + Q_PROPERTY( + int expressiveDefaultSpatial READ expressiveDefaultSpatial NOTIFY + scaleChanged) + Q_PROPERTY( + int expressiveFastEffects READ expressiveFastEffects NOTIFY scaleChanged) + Q_PROPERTY( + int expressiveFastSpatial READ expressiveFastSpatial NOTIFY scaleChanged) + Q_PROPERTY( + int expressiveSlowEffects READ expressiveSlowEffects NOTIFY scaleChanged) + Q_PROPERTY( + int expressiveSlowSpatial READ expressiveSlowSpatial NOTIFY scaleChanged) + Q_PROPERTY(int large READ large NOTIFY scaleChanged) + Q_PROPERTY(int normal READ normal NOTIFY scaleChanged) + Q_PROPERTY(int small READ small NOTIFY scaleChanged) + + public: + explicit AnimDurations(QObject* parent = nullptr) : ConfigObject(parent) {} + + [[nodiscard]] int small() const { return static_cast(200 * scale()); } + [[nodiscard]] int normal() const { return static_cast(400 * scale()); } + [[nodiscard]] int large() const { return static_cast(600 * scale()); } + [[nodiscard]] int extraLarge() const { + return static_cast(1000 * scale()); + } + [[nodiscard]] int expressiveFastSpatial() const { + return static_cast(350 * scale()); + } + [[nodiscard]] int expressiveDefaultSpatial() const { + return static_cast(500 * scale()); + } + [[nodiscard]] int expressiveSlowSpatial() const { + return static_cast(650 * scale()); + } + [[nodiscard]] int expressiveFastEffects() const { + return static_cast(150 * scale()); + } + [[nodiscard]] int expressiveDefaultEffects() const { + return static_cast(200 * scale()); + } + [[nodiscard]] int expressiveSlowEffects() const { + return static_cast(300 * scale()); + } +}; + class Anim : public ConfigObject { Q_OBJECT QML_ANONYMOUS CFG_PROPERTY(int, mediaGifSpeedAdjustment, 300) CFG_PROPERTY(qreal, sessionGifSpeed, 0.7) + CONFIG_SUBOBJECT(AnimDurations, durations) public: - explicit Anim(QObject* parent = nullptr) : ConfigObject(parent) {} + explicit Anim(QObject* parent = nullptr) + : ConfigObject(parent), m_durations(new AnimDurations(this)) {} }; class Deform : public ConfigObject { @@ -39,15 +98,161 @@ class FontFamily : public ConfigObject { explicit FontFamily(QObject* parent = nullptr) : ConfigObject(parent) {} }; -class Font : public ConfigObject { +namespace detail { + +struct FontConfig { + QString family = {}; + int size; + int weight = QFont::Normal; + bool italic = false; + QVariantMap vaxes = {{QStringLiteral("ROND"), 25}}; +}; + +} // namespace detail + +class FontSizeNode : public ConfigObject { + public: + explicit FontSizeNode(QObject* parent = nullptr) : ConfigObject(parent) {} +}; + +class FontStyleNode : public ConfigObject { + public: + explicit FontStyleNode(QObject* parent = nullptr) : ConfigObject(parent) {} + + virtual FontSizeNode* largeNode() const = 0; + virtual FontSizeNode* mediumNode() const = 0; + virtual FontSizeNode* smallNode() const = 0; + virtual FontSizeNode* extraLargeNode() const { return nullptr; } +}; + +#define ARG(...) __VA_ARGS__ +#define FONT(...) ZShell::config::detail::FontConfig __VA_ARGS__ +#define FONT_CONFIG(Style, Size, props) \ + class FontConfig##Style##Size : public FontSizeNode { \ + Q_OBJECT QML_ANONYMOUS \ + \ + CFG_PROPERTY(QString, family, ARG(props).family) \ + CFG_PROPERTY(int, size, ARG(props).size) \ + CFG_PROPERTY(int, weight, ARG(props).weight) \ + CFG_PROPERTY(bool, italic, ARG(props).italic) \ + CFG_PROPERTY(QVariantMap, vaxes, ARG(props).vaxes) \ + \ + public \ + : explicit FontConfig##Style##Size(QObject* parent = nullptr) \ + : FontSizeNode(parent) {} \ + }; +#define FONT_STYLE(Style, family_, large_, medium_, small_) \ + FONT_CONFIG(Style, Large, ARG(large_)) \ + FONT_CONFIG(Style, Medium, ARG(medium_)) \ + FONT_CONFIG(Style, Small, ARG(small_)) \ + \ + class FontStyle##Style : public FontStyleNode { \ + Q_OBJECT QML_ANONYMOUS \ + \ + CFG_PROPERTY(QString, family, family_) \ + CONFIG_SUBOBJECT(FontConfig##Style##Large, large) \ + CONFIG_SUBOBJECT(FontConfig##Style##Medium, medium) \ + CONFIG_SUBOBJECT(FontConfig##Style##Small, small) \ + \ + public \ + : explicit FontStyle##Style(QObject* parent = nullptr) \ + : FontStyleNode(parent) \ + , m_large(new FontConfig##Style##Large(this)) \ + , m_medium(new FontConfig##Style##Medium(this)) \ + , m_small(new FontConfig##Style##Small(this)) {} \ + \ + FontSizeNode* largeNode() const override { return m_large; } \ + FontSizeNode* mediumNode() const override { return m_medium; } \ + FontSizeNode* smallNode() const override { return m_small; } \ + }; + +// clang-format off +FONT_STYLE(Headline, QStringLiteral("GoogleSansFlex"), + FONT({ .size = 32, .weight = QFont::Medium }), + FONT({ .size = 28, .weight = QFont::Medium }), + FONT({ .size = 24, .weight = QFont::Medium }) +) +FONT_STYLE(Title, QStringLiteral("GoogleSansFlex"), + FONT({ .size = 22, .weight = QFont::Medium }), + FONT({ .size = 16, .weight = QFont::Medium }), + FONT({ .size = 14, .weight = QFont::Medium }) +) +FONT_STYLE(Body, QStringLiteral("GoogleSansFlex"), + FONT({ .size = 16 }), + FONT({ .size = 14 }), + FONT({ .size = 12 }) +) +FONT_STYLE(Label, QStringLiteral("GoogleSansFlex"), + FONT({ .size = 14, .weight = QFont::Medium }), + FONT({ .size = 12, .weight = QFont::Medium }), + FONT({ .size = 11 }) +) +FONT_STYLE(Mono, QStringLiteral("CaskaydiaCove NF"), + FONT({ .size = 16, .vaxes = {} }), + FONT({ .size = 14, .vaxes = {} }), + FONT({ .size = 12, .vaxes = {} }) +) +// clang-format on + +FONT_CONFIG(Icon, ExtraLarge, FONT({.size = static_cast(48 / 1.33)})) +FONT_CONFIG(Icon, Large, FONT({.size = static_cast(32 / 1.33)})) +FONT_CONFIG(Icon, Medium, FONT({.size = static_cast(24 / 1.33)})) +FONT_CONFIG(Icon, Small, FONT({.size = static_cast(20 / 1.33)})) + +class FontStyleIcon : public FontStyleNode { Q_OBJECT QML_ANONYMOUS - CONFIG_SUBOBJECT(FontFamily, family) + CFG_PROPERTY(QString, family, QStringLiteral("Material Symbols Rounded")) + CONFIG_SUBOBJECT(FontConfigIconExtraLarge, extraLarge) + CONFIG_SUBOBJECT(FontConfigIconLarge, large) + CONFIG_SUBOBJECT(FontConfigIconMedium, medium) + CONFIG_SUBOBJECT(FontConfigIconSmall, small) public: - explicit Font(QObject* parent = nullptr) - : ConfigObject(parent), m_family(new FontFamily(this)) {} + explicit FontStyleIcon(QObject* parent = nullptr) + : FontStyleNode(parent) + , m_extraLarge(new FontConfigIconExtraLarge(this)) + , m_large(new FontConfigIconLarge(this)) + , m_medium(new FontConfigIconMedium(this)) + , m_small(new FontConfigIconSmall(this)) {} + + FontSizeNode* extraLargeNode() const override { return m_extraLarge; } + FontSizeNode* largeNode() const override { return m_large; } + FontSizeNode* mediumNode() const override { return m_medium; } + FontSizeNode* smallNode() const override { return m_small; } +}; + +#undef ARG +#undef FONT +#undef FONT_CONFIG +#undef FONT_STYLE + +class AppearanceFont : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(qreal, scale, 1) + CONFIG_SUBOBJECT(FontFamily, family) + CONFIG_SUBOBJECT(FontStyleHeadline, headline) + CONFIG_SUBOBJECT(FontStyleTitle, title) + CONFIG_SUBOBJECT(FontStyleBody, body) + CONFIG_SUBOBJECT(FontStyleLabel, label) + CONFIG_SUBOBJECT(FontStyleMono, mono) + CONFIG_SUBOBJECT(FontStyleIcon, icon) + CFG_PROPERTY(QString, clock, QStringLiteral("Rubik")) + CFG_PROPERTY(QString, workspaces, QStringLiteral("Rubik")) + + public: + explicit AppearanceFont(QObject* parent = nullptr) + : ConfigObject(parent) + , m_family(new FontFamily(this)) + , m_headline(new FontStyleHeadline(this)) + , m_title(new FontStyleTitle(this)) + , m_body(new FontStyleBody(this)) + , m_label(new FontStyleLabel(this)) + , m_mono(new FontStyleMono(this)) + , m_icon(new FontStyleIcon(this)) {} }; class Transparency : public ConfigObject { @@ -62,13 +267,140 @@ class Transparency : public ConfigObject { explicit Transparency(QObject* parent = nullptr) : ConfigObject(parent) {} }; +class AppearanceRounding : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY scaleChanged) + Q_PROPERTY(int small READ small NOTIFY scaleChanged) + Q_PROPERTY(int medium READ medium NOTIFY scaleChanged) + Q_PROPERTY(int large READ large NOTIFY scaleChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY scaleChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) + Q_PROPERTY( + int extraLargeIncreased READ extraLargeIncreased NOTIFY scaleChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY scaleChanged) + Q_PROPERTY(int full READ full CONSTANT) + + public: + explicit AppearanceRounding(QObject* parent = nullptr) + : ConfigObject(parent) {} + + [[nodiscard]] int extraSmall() const { + return static_cast(4 * scale()); + } + [[nodiscard]] int small() const { return static_cast(8 * scale()); } + [[nodiscard]] int medium() const { return static_cast(12 * scale()); } + [[nodiscard]] int large() const { return static_cast(16 * scale()); } + [[nodiscard]] int largeIncreased() const { + return static_cast(20 * scale()); + } + [[nodiscard]] int extraLarge() const { + return static_cast(28 * scale()); + } + [[nodiscard]] int extraLargeIncreased() const { + return static_cast(32 * scale()); + } + [[nodiscard]] int extraExtraLarge() const { + return static_cast(48 * scale()); + } + [[nodiscard]] int full() const { return std::numeric_limits::max(); } +}; + +class AppearanceSpacing : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY scaleChanged) + Q_PROPERTY(int small READ small NOTIFY scaleChanged) + Q_PROPERTY(int medium READ medium NOTIFY scaleChanged) + Q_PROPERTY(int large READ large NOTIFY scaleChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY scaleChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) + Q_PROPERTY( + int extraLargeIncreased READ extraLargeIncreased NOTIFY scaleChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY scaleChanged) + + public: + explicit AppearanceSpacing(QObject* parent = nullptr) + : ConfigObject(parent) {} + + [[nodiscard]] int extraSmall() const { + return static_cast(4 * scale()); + } + [[nodiscard]] int small() const { return static_cast(8 * scale()); } + [[nodiscard]] int medium() const { return static_cast(12 * scale()); } + [[nodiscard]] int large() const { return static_cast(16 * scale()); } + [[nodiscard]] int largeIncreased() const { + return static_cast(20 * scale()); + } + [[nodiscard]] int extraLarge() const { + return static_cast(28 * scale()); + } + [[nodiscard]] int extraLargeIncreased() const { + return static_cast(32 * scale()); + } + [[nodiscard]] int extraExtraLarge() const { + return static_cast(48 * scale()); + } +}; + +class AppearancePadding : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY scaleChanged) + Q_PROPERTY(int smaller READ smaller NOTIFY scaleChanged) + Q_PROPERTY(int small READ small NOTIFY scaleChanged) + Q_PROPERTY(int medium READ medium NOTIFY scaleChanged) + Q_PROPERTY(int large READ large NOTIFY scaleChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY scaleChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) + Q_PROPERTY( + int extraLargeIncreased READ extraLargeIncreased NOTIFY scaleChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY scaleChanged) + + public: + explicit AppearancePadding(QObject* parent = nullptr) + : ConfigObject(parent) {} + + [[nodiscard]] int extraSmall() const { + return static_cast(4 * scale()); + } + [[nodiscard]] int smaller() const { return static_cast(5 * scale()); } + [[nodiscard]] int small() const { return static_cast(8 * scale()); } + [[nodiscard]] int medium() const { return static_cast(12 * scale()); } + [[nodiscard]] int large() const { return static_cast(16 * scale()); } + [[nodiscard]] int largeIncreased() const { + return static_cast(20 * scale()); + } + [[nodiscard]] int extraLarge() const { + return static_cast(28 * scale()); + } + [[nodiscard]] int extraLargeIncreased() const { + return static_cast(32 * scale()); + } + [[nodiscard]] int extraExtraLarge() const { + return static_cast(48 * scale()); + } +}; + class Appearance : public ConfigObject { Q_OBJECT QML_ANONYMOUS CONFIG_SUBOBJECT(Anim, anim) CONFIG_SUBOBJECT(Deform, deform) - CONFIG_SUBOBJECT(Font, font) + CONFIG_SUBOBJECT(AppearanceFont, font) + CONFIG_SUBOBJECT(AppearanceRounding, rounding) + CONFIG_SUBOBJECT(AppearanceSpacing, spacing) + CONFIG_SUBOBJECT(AppearancePadding, padding) CONFIG_SUBOBJECT(Transparency, transparency) public: @@ -76,8 +408,11 @@ class Appearance : public ConfigObject { : ConfigObject(parent) , m_anim(new Anim(this)) , m_deform(new Deform(this)) - , m_font(new Font(this)) - , m_transparency(new Transparency(this)) {} + , m_font(new AppearanceFont(this)) + , m_rounding(new AppearanceRounding(this)) + , m_spacing(new AppearanceSpacing(this)) + , m_padding(new AppearancePadding(this)) + , m_transparency(new Transparency(this)) {}; }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/configobject.cpp b/Plugins/ZShell/Config/configobject.cpp index 9ee0517..1b0d01d 100644 --- a/Plugins/ZShell/Config/configobject.cpp +++ b/Plugins/ZShell/Config/configobject.cpp @@ -65,13 +65,12 @@ void ConfigObject::loadFromJson(const QJsonValue& json) { auto prop = meta->property(i); const auto key = QString::fromUtf8(prop.name()); - known.insert(key); - if (!obj.contains(key)) continue; const auto jsonVal = obj.value(key); if (prop.metaType().flags().testFlag(QMetaType::PointerToQObject)) { + known.insert(key); if (auto* const node = prop.read(this).value()) { node->loadFromJson(jsonVal); continue; @@ -80,6 +79,8 @@ void ConfigObject::loadFromJson(const QJsonValue& json) { if (!prop.isWritable()) continue; + known.insert(key); + if (!jsonValueMatchesType(jsonVal, prop.metaType())) { qWarning() << "Config: type mismatch for" << key << "in" << meta->className() << "- expected" @@ -218,18 +219,9 @@ void ConfigObject::materializeDefaults() { for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { const auto prop = meta->property(i); - const auto key = QString::fromUtf8(prop.name()); - if (auto* const node = prop.read(this).value()) { + if (auto* const node = prop.read(this).value()) node->materializeDefaults(); - continue; - } - - if (!prop.isWritable()) continue; - - if (m_global) continue; - - m_loadedKeys.insert(key); } } diff --git a/Plugins/ZShell/Config/font.cpp b/Plugins/ZShell/Config/font.cpp new file mode 100644 index 0000000..57038f5 --- /dev/null +++ b/Plugins/ZShell/Config/font.cpp @@ -0,0 +1,290 @@ +#include "font.hpp" + +#include "appearance.hpp" +#include "config.hpp" + +namespace ZShell::config { + +FontStyleBase::FontStyleBase(QObject* parent) : QObject(parent) {} + +QFont FontStyleBase::large() const { + return m_large; +} + +QFont FontStyleBase::medium() const { + return m_medium; +} + +QFont FontStyleBase::small() const { + return m_small; +} + +QFont FontStyleBase::buildFont( + const FontSizeNode* cfg, const QString& fallbackFamily, qreal scale) { + const auto family = cfg->property("family").toString(); + const auto size = cfg->property("size").toInt(); + const auto weight = cfg->property("weight").value(); + const auto italic = cfg->property("italic").toBool(); + const auto vaxes = cfg->property("vaxes").toMap(); + + QFont font; + font.setFamily(family.isEmpty() ? fallbackFamily : family); + const int scaledSize = static_cast(size * scale); + const int cappedSize = scaledSize > 0 ? scaledSize : 1; + font.setPointSize(cappedSize); + font.setVariableAxis("opsz", static_cast(cappedSize)); + font.setWeight(weight); + font.setVariableAxis("wght", static_cast(weight)); + font.setItalic(italic); + + for (auto it = vaxes.constBegin(); it != vaxes.constEnd(); ++it) { + if (auto tag = QFont::Tag::fromString(it.key())) + font.setVariableAxis(*tag, it.value().toFloat()); + } + + return font; +} + +void FontStyleBase::bind(FontStyleNode* cfg) { + if (m_cfg == cfg) return; + + if (m_cfg) { + disconnect(m_cfg, nullptr, this, nullptr); + disconnect(m_cfg->largeNode(), nullptr, this, nullptr); + disconnect(m_cfg->mediumNode(), nullptr, this, nullptr); + disconnect(m_cfg->smallNode(), nullptr, this, nullptr); + } + + m_cfg = cfg; + + if (cfg) { + connect( + cfg, &ConfigNode::propertiesChanged, this, &FontStyleBase::rebuild); + connect( + cfg->largeNode(), + &ConfigNode::propertiesChanged, + this, + &FontStyleBase::rebuild); + connect( + cfg->mediumNode(), + &ConfigNode::propertiesChanged, + this, + &FontStyleBase::rebuild); + connect( + cfg->smallNode(), + &ConfigNode::propertiesChanged, + this, + &FontStyleBase::rebuild); + } + + rebuild(); +} + +void FontStyleBase::rebuild() { + if (m_cfg) { + const auto family = m_cfg->property("family").toString(); + m_large = buildFont(m_cfg->largeNode(), family, m_scale); + m_medium = buildFont(m_cfg->mediumNode(), family, m_scale); + m_small = buildFont(m_cfg->smallNode(), family, m_scale); + } else { + m_large = QFont(); + m_medium = QFont(); + m_small = QFont(); + } + emit fontsChanged(); +} + +void FontStyleBase::setScale(qreal scale) { + if (qFuzzyCompare(m_scale + 1.0, scale + 1.0)) return; + m_scale = scale; + rebuild(); +} + +FontStyle::FontStyle(QObject* parent) + : FontStyleBase(parent), m_builders(new FontBuilders(this, this)) {} + +FontBuilders* FontStyle::builders() const { + return m_builders; +} + +IconFontStyle::IconFontStyle(QObject* parent) + : FontStyleBase(parent), m_builders(new IconFontBuilders(this, this)) {} + +FontBuilder IconFontStyle::size(int pointSize) { + return FontBuilder(m_small).size(pointSize); +} + +FontBuilder IconFontStyle::pixelSize(int pixelSize) { + return FontBuilder(m_small).pixelSize(pixelSize); +} + +void IconFontStyle::bind(FontStyleNode* cfg) { + if (m_cfg == cfg) return; + + if (m_cfg) disconnect(m_cfg->extraLargeNode(), nullptr, this, nullptr); + + FontStyleBase::bind(cfg); + + if (cfg) + connect( + cfg->extraLargeNode(), + &ConfigNode::propertiesChanged, + this, + &IconFontStyle::rebuild); +} + +QFont IconFontStyle::extraLarge() const { + return m_extraLarge; +} + +IconFontBuilders* IconFontStyle::builders() const { + return m_builders; +} + +void IconFontStyle::rebuild() { + if (m_cfg) { + const auto family = m_cfg->property("family").toString(); + m_extraLarge = buildFont(m_cfg->extraLargeNode(), family, m_scale); + } else { + m_extraLarge = QFont(); + } + FontStyleBase::rebuild(); +} + +FontBuilders::FontBuilders(const FontStyleBase* style, QObject* parent) + : QObject(parent), m_style(style) { + connect( + style, + &FontStyleBase::fontsChanged, + this, + &FontBuilders::buildersChanged); +} + +FontBuilder FontBuilders::large() const { + return FontBuilder(m_style->large()); +} + +FontBuilder FontBuilders::medium() const { + return FontBuilder(m_style->medium()); +} + +FontBuilder FontBuilders::small() const { + return FontBuilder(m_style->small()); +} + +IconFontBuilders::IconFontBuilders(const IconFontStyle* style, QObject* parent) + : FontBuilders(style, parent) {} + +FontBuilder IconFontBuilders::extraLarge() const { + return FontBuilder( + static_cast(m_style)->extraLarge()); +} + +FontTokens::FontTokens(QObject* parent) + : ConfigObject(parent) + , m_size(new FontSizeTokens(this)) + , m_headline(new FontStyle(this)) + , m_title(new FontStyle(this)) + , m_body(new FontStyle(this)) + , m_label(new FontStyle(this)) + , m_mono(new FontStyle(this)) + , m_icon(new IconFontStyle(this)) { + if (!Config::instance()) new Config(); + bindFont(Config::instance()->appearance()->font()); +} + +FontStyle* FontTokens::headline() const { + return m_headline; +} + +FontStyle* FontTokens::title() const { + return m_title; +} + +FontStyle* FontTokens::body() const { + return m_body; +} + +FontStyle* FontTokens::label() const { + return m_label; +} + +FontStyle* FontTokens::mono() const { + return m_mono; +} + +IconFontStyle* FontTokens::icon() const { + return m_icon; +} + +FontBuilder FontTokens::clock() const { + return FontBuilder(m_clock); +} + +QString FontTokens::workspaces() const { + return m_font ? m_font->workspaces() : QString(); +} + +void FontTokens::bindFont(AppearanceFont* font) { + if (m_font == font) return; + + if (m_font) disconnect(m_font, nullptr, this, nullptr); + + m_font = font; + + if (font) { + rebuildScale(); + m_headline->bind(font->headline()); + m_title->bind(font->title()); + m_body->bind(font->body()); + m_label->bind(font->label()); + m_mono->bind(font->mono()); + m_icon->bind(font->icon()); + + connect( + font, + &AppearanceFont::clockChanged, + this, + &FontTokens::rebuildClock); + connect( + font, + &AppearanceFont::scaleChanged, + this, + &FontTokens::rebuildScale); + connect( + font, + &AppearanceFont::workspacesChanged, + this, + &FontTokens::workspacesChanged); + } else { + rebuildScale(); + m_headline->bind(nullptr); + m_title->bind(nullptr); + m_body->bind(nullptr); + m_label->bind(nullptr); + m_mono->bind(nullptr); + m_icon->bind(nullptr); + } + + rebuildClock(); + emit workspacesChanged(); +} + +void FontTokens::rebuildScale() { + const qreal s = m_font ? m_font->scale() : 1; + m_headline->setScale(s); + m_title->setScale(s); + m_body->setScale(s); + m_label->setScale(s); + m_mono->setScale(s); + m_icon->setScale(s); +} + +void FontTokens::rebuildClock() { + QFont f; + if (m_font) f.setFamily(m_font->clock()); + m_clock = f; + emit clockChanged(); +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/font.hpp b/Plugins/ZShell/Config/font.hpp new file mode 100644 index 0000000..9625457 --- /dev/null +++ b/Plugins/ZShell/Config/font.hpp @@ -0,0 +1,200 @@ +#pragma once + +#include "configobject.hpp" +#include "fontbuilder.hpp" + +#include +#include +#include +#include + +namespace ZShell::config { + +class AppearanceFont; +class FontBuilders; +class FontStyleBase; +class FontSizeNode; +class FontStyleNode; +class IconFontStyle; + +class FontSizeTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(int, small, 11) + CFG_PROPERTY(int, smaller, 12) + CFG_PROPERTY(int, normal, 13) + CFG_PROPERTY(int, larger, 15) + CFG_PROPERTY(int, large, 18) + CFG_PROPERTY(int, extraLarge, 28) + + public: + explicit FontSizeTokens(QObject* parent = nullptr) : ConfigObject(parent) {} +}; + +class FontBuilders : public QObject { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY( + ZShell::config::FontBuilder large READ large NOTIFY buildersChanged + FINAL) + Q_PROPERTY( + ZShell::config::FontBuilder medium READ medium NOTIFY buildersChanged + FINAL) + Q_PROPERTY( + ZShell::config::FontBuilder small READ small NOTIFY buildersChanged + FINAL) + + public: + explicit FontBuilders(const FontStyleBase* style, QObject* parent = nullptr); + + [[nodiscard]] FontBuilder large() const; + [[nodiscard]] FontBuilder medium() const; + [[nodiscard]] FontBuilder small() const; + + signals: + void buildersChanged(); + + protected: + const FontStyleBase* m_style; +}; + +class IconFontBuilders : public FontBuilders { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY( + ZShell::config::FontBuilder extraLarge READ extraLarge NOTIFY + buildersChanged FINAL) + + public: + explicit IconFontBuilders( + const IconFontStyle* style, QObject* parent = nullptr); + + [[nodiscard]] FontBuilder extraLarge() const; +}; + +class FontStyleBase : public QObject { + Q_OBJECT + + Q_PROPERTY(QFont large READ large NOTIFY fontsChanged FINAL) + Q_PROPERTY(QFont medium READ medium NOTIFY fontsChanged FINAL) + Q_PROPERTY(QFont small READ small NOTIFY fontsChanged FINAL) + + public: + explicit FontStyleBase(QObject* parent = nullptr); + + virtual void bind(FontStyleNode* cfg); + void setScale(qreal scale); + + [[nodiscard]] QFont large() const; + [[nodiscard]] QFont medium() const; + [[nodiscard]] QFont small() const; + + signals: + void fontsChanged(); + + protected: + virtual void rebuild(); + + static QFont buildFont( + const FontSizeNode* cfg, const QString& fallbackFamily, qreal scale); + + FontStyleNode* m_cfg = nullptr; + qreal m_scale = 1; + QFont m_large; + QFont m_medium; + QFont m_small; +}; + +class FontStyle : public FontStyleBase { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY(FontBuilders* builders READ builders CONSTANT FINAL) + + public: + explicit FontStyle(QObject* parent = nullptr); + + [[nodiscard]] FontBuilders* builders() const; + + private: + FontBuilders* m_builders; +}; + +class IconFontStyle : public FontStyleBase { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY(QFont extraLarge READ extraLarge NOTIFY fontsChanged FINAL) + Q_PROPERTY(IconFontBuilders* builders READ builders CONSTANT FINAL) + + public: + explicit IconFontStyle(QObject* parent = nullptr); + + Q_INVOKABLE FontBuilder size(int pointSize); + Q_INVOKABLE FontBuilder pixelSize(int pixelSize); + + void bind(FontStyleNode* cfg) override; + + [[nodiscard]] QFont extraLarge() const; + [[nodiscard]] IconFontBuilders* builders() const; + + protected: + void rebuild() override; + + private: + QFont m_extraLarge; + IconFontBuilders* m_builders; +}; + +class FontTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CONFIG_SUBOBJECT(FontSizeTokens, size) + + Q_PROPERTY(FontStyle* headline READ headline CONSTANT FINAL) + Q_PROPERTY(FontStyle* title READ title CONSTANT FINAL) + Q_PROPERTY(FontStyle* body READ body CONSTANT FINAL) + Q_PROPERTY(FontStyle* label READ label CONSTANT FINAL) + Q_PROPERTY(FontStyle* mono READ mono CONSTANT FINAL) + Q_PROPERTY(IconFontStyle* icon READ icon CONSTANT FINAL) + Q_PROPERTY( + ZShell::config::FontBuilder clock READ clock NOTIFY clockChanged FINAL) + Q_PROPERTY(QString workspaces READ workspaces NOTIFY workspacesChanged FINAL) + + public: + explicit FontTokens(QObject* parent = nullptr); + + void bindFont(AppearanceFont* font); + + [[nodiscard]] FontStyle* headline() const; + [[nodiscard]] FontStyle* title() const; + [[nodiscard]] FontStyle* body() const; + [[nodiscard]] FontStyle* label() const; + [[nodiscard]] FontStyle* mono() const; + [[nodiscard]] IconFontStyle* icon() const; + [[nodiscard]] FontBuilder clock() const; + [[nodiscard]] QString workspaces() const; + + signals: + void clockChanged(); + void workspacesChanged(); + + private: + void rebuildClock(); + void rebuildScale(); + + AppearanceFont* m_font = nullptr; + FontStyle* m_headline; + FontStyle* m_title; + FontStyle* m_body; + FontStyle* m_label; + FontStyle* m_mono; + IconFontStyle* m_icon; + QFont m_clock; +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/fontbuilder.cpp b/Plugins/ZShell/Config/fontbuilder.cpp new file mode 100644 index 0000000..3726711 --- /dev/null +++ b/Plugins/ZShell/Config/fontbuilder.cpp @@ -0,0 +1,105 @@ +#include "fontbuilder.hpp" + +#include + +namespace { + +Q_LOGGING_CATEGORY(lcFontBuilder, "zshell.fontbuilder", QtInfoMsg) + +} // namespace + +namespace ZShell::config { + +FontBuilder::FontBuilder(const QFont& font) : m_font(font) {} + +FontBuilder FontBuilder::family(const QString& family) { + m_font.setFamily(family); + return *this; +} + +FontBuilder FontBuilder::size(int pointSize) { + if (pointSize <= 0) pointSize = 1; + m_font.setPointSize(pointSize); + m_font.setVariableAxis("opsz", static_cast(pointSize)); + return *this; +} + +FontBuilder FontBuilder::pixelSize(int pixelSize) { + if (pixelSize <= 0) pixelSize = 1; + m_font.setPixelSize(pixelSize); + return *this; +} + +FontBuilder FontBuilder::weight(QFont::Weight weight) { + m_font.setWeight(weight); + m_font.setVariableAxis("wght", static_cast(weight)); + return *this; +} + +FontBuilder FontBuilder::italic(bool on) { + m_font.setItalic(on); + return *this; +} + +FontBuilder FontBuilder::stretch(int stretch) { + m_font.setStretch(stretch); + return *this; +} + +FontBuilder FontBuilder::letterSpacing(qreal spacing, bool absolute) { + m_font.setLetterSpacing( + absolute ? QFont::AbsoluteSpacing : QFont::PercentageSpacing, spacing); + return *this; +} + +FontBuilder FontBuilder::capitalisation(QFont::Capitalization cap) { + m_font.setCapitalization(cap); + return *this; +} + +FontBuilder FontBuilder::vaxis(const QString& tag, float value) { + if (auto t = QFont::Tag::fromString(tag)) + m_font.setVariableAxis(*t, value); + else + qCWarning(lcFontBuilder) + << "Unable to convert tag" << tag << "to QFont::Tag"; + return *this; +} + +FontBuilder FontBuilder::vaxes(const QVariantMap& axes) { + for (auto it = axes.constBegin(); it != axes.constEnd(); ++it) { + if (it.value().canConvert()) { + if (auto tag = QFont::Tag::fromString(it.key())) + m_font.setVariableAxis(*tag, it.value().toFloat()); + else + qCWarning(lcFontBuilder) + << "Unable to convert tag" << it.key() << "to QFont::Tag"; + } else { + qCWarning(lcFontBuilder) + << "Unable to convert value" << it.value() << "to float"; + } + } + return *this; +} + +QFont FontBuilder::build() const { + return m_font; +} + +FontBuilder FontBuilder::fill(float value) { + return vaxis(QStringLiteral("FILL"), value); +} + +FontBuilder FontBuilder::grade(float value) { + return vaxis(QStringLiteral("GRAD"), value); +} + +FontBuilder FontBuilder::width(float value) { + return vaxis(QStringLiteral("wdth"), value); +} + +FontBuilder FontBuilder::scale(qreal factor) { + return size(static_cast(m_font.pointSize() * factor)); +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/fontbuilder.hpp b/Plugins/ZShell/Config/fontbuilder.hpp new file mode 100644 index 0000000..d474f92 --- /dev/null +++ b/Plugins/ZShell/Config/fontbuilder.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include + +namespace ZShell::config { + +class FontBuilder { + Q_GADGET + QML_ANONYMOUS + + public: + FontBuilder() = default; + explicit FontBuilder(const QFont& font); + + [[nodiscard]] Q_INVOKABLE FontBuilder family(const QString& family); + [[nodiscard]] Q_INVOKABLE FontBuilder size(int pointSize); + [[nodiscard]] Q_INVOKABLE FontBuilder pixelSize(int pixelSize); + [[nodiscard]] Q_INVOKABLE FontBuilder weight(QFont::Weight weight); + [[nodiscard]] Q_INVOKABLE FontBuilder italic(bool on = true); + [[nodiscard]] Q_INVOKABLE FontBuilder stretch(int stretch); + [[nodiscard]] Q_INVOKABLE FontBuilder + letterSpacing(qreal spacing, bool absolute = true); + [[nodiscard]] Q_INVOKABLE FontBuilder + capitalisation(QFont::Capitalization cap); + [[nodiscard]] Q_INVOKABLE FontBuilder vaxis(const QString& tag, float value); + [[nodiscard]] Q_INVOKABLE FontBuilder vaxes(const QVariantMap& axes); + [[nodiscard]] Q_INVOKABLE QFont build() const; + + [[nodiscard]] Q_INVOKABLE FontBuilder fill(float value); + [[nodiscard]] Q_INVOKABLE FontBuilder grade(float value); + [[nodiscard]] Q_INVOKABLE FontBuilder width(float value); + + [[nodiscard]] Q_INVOKABLE FontBuilder scale(qreal factor); + + private: + QFont m_font; +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/tokens.hpp b/Plugins/ZShell/Config/tokens.hpp index b58d8ea..07dd953 100644 --- a/Plugins/ZShell/Config/tokens.hpp +++ b/Plugins/ZShell/Config/tokens.hpp @@ -2,6 +2,9 @@ #include "configobject.hpp" #include "anim.hpp" +#include "appearance.hpp" +#include "config.hpp" +#include "font.hpp" #include #include @@ -13,89 +16,6 @@ namespace ZShell::config { -class AppearanceRounding : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(qreal, extraSmall, 4) - CFG_PROPERTY(qreal, full, std::numeric_limits::max()) - CFG_PROPERTY(qreal, large, 16) - CFG_PROPERTY(qreal, largeIncreased, 20) - CFG_PROPERTY(qreal, extraLarge, 28) - CFG_PROPERTY(qreal, extraLargeIncreased, 32) - CFG_PROPERTY(qreal, extraExtraLarge, 48) - CFG_PROPERTY(qreal, medium, 12) - CFG_PROPERTY(qreal, scale, 1) - CFG_PROPERTY(qreal, small, 8) - - public: - explicit AppearanceRounding(QObject* parent = nullptr) - : ConfigObject(parent) {} -}; - -class AppearancePadding : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(int, extraSmall, 4) - CFG_PROPERTY(int, smaller, 5) - CFG_PROPERTY(int, small, 8) - CFG_PROPERTY(int, medium, 12) - CFG_PROPERTY(int, large, 16) - CFG_PROPERTY(int, largeIncreased, 20) - CFG_PROPERTY(int, extraLarge, 28) - CFG_PROPERTY(int, extraLargeIncreased, 32) - CFG_PROPERTY(int, extraExtraLarge, 48) - - public: - explicit AppearancePadding(QObject* parent = nullptr) - : ConfigObject(parent) {} -}; - -class AppearanceSpacing : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(int, extraSmall, 4) - CFG_PROPERTY(int, small, 8) - CFG_PROPERTY(int, medium, 12) - CFG_PROPERTY(int, large, 16) - CFG_PROPERTY(int, largeIncreased, 20) - CFG_PROPERTY(int, extraLarge, 28) - CFG_PROPERTY(int, extraLargeIncreased, 32) - CFG_PROPERTY(int, extraExtraLarge, 48) - - public: - explicit AppearanceSpacing(QObject* parent = nullptr) - : ConfigObject(parent) {} -}; - -class FontSizeTokens : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(int, small, 11) - CFG_PROPERTY(int, smaller, 12) - CFG_PROPERTY(int, normal, 13) - CFG_PROPERTY(int, larger, 15) - CFG_PROPERTY(int, large, 18) - CFG_PROPERTY(int, extraLarge, 28) - - public: - explicit FontSizeTokens(QObject* parent = nullptr) : ConfigObject(parent) {} -}; - -class FontTokens : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CONFIG_SUBOBJECT(FontSizeTokens, size) - - public: - explicit FontTokens(QObject* parent = nullptr) - : ConfigObject(parent), m_size(new FontSizeTokens(this)) {} -}; - class AnimCurves : public ConfigObject { Q_OBJECT QML_ANONYMOUS @@ -142,31 +62,11 @@ class AnimCurves : public ConfigObject { , m_expressiveSlowEffects({0.34, 0.88, 0.34, 1, 1, 1}) {} }; -class AnimDurations : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(int, small, 200) - CFG_PROPERTY(int, scale, 1) - CFG_PROPERTY(int, normal, 400) - CFG_PROPERTY(int, large, 600) - CFG_PROPERTY(int, extraLarge, 1000) - CFG_PROPERTY(int, expressiveFastSpatial, 350) - CFG_PROPERTY(int, expressiveDefaultSpatial, 500) - CFG_PROPERTY(int, expressiveSlowSpatial, 650) - CFG_PROPERTY(int, expressiveFastEffects, 150) - CFG_PROPERTY(int, expressiveDefaultEffects, 200) - CFG_PROPERTY(int, expressiveSlowEffects, 300) - - public: - explicit AnimDurations(QObject* parent = nullptr) : ConfigObject(parent) {} -}; - class AnimTokens : public ConfigObject { Q_OBJECT QML_ANONYMOUS - CONFIG_SUBOBJECT(AnimDurations, durations) + Q_PROPERTY(ZShell::config::AnimDurations* durations READ durations CONSTANT) Q_PROPERTY(QEasingCurve emphasized READ emphasized NOTIFY curvesChanged) Q_PROPERTY( @@ -201,7 +101,6 @@ class AnimTokens : public ConfigObject { explicit AnimTokens(QObject* parent = nullptr) : ConfigObject(parent) , m_curves(new AnimCurves(this)) - , m_durations(new AnimDurations(this)) , m_easing(new AnimEasing(this)) { connect( m_easing, @@ -211,6 +110,10 @@ class AnimTokens : public ConfigObject { m_easing->bindCurves(m_curves); } + [[nodiscard]] AnimDurations* durations() const { + return Config::instance()->appearance()->anim()->durations(); + } + [[nodiscard]] QEasingCurve emphasized() const { return m_easing->emphasized(); } @@ -269,9 +172,10 @@ class Tokens : public ConfigObject { QML_ELEMENT QML_SINGLETON - CONFIG_SUBOBJECT(AppearanceRounding, rounding) - CONFIG_SUBOBJECT(AppearancePadding, padding) - CONFIG_SUBOBJECT(AppearanceSpacing, spacing) + Q_PROPERTY( + ZShell::config::AppearanceRounding* rounding READ rounding CONSTANT) + Q_PROPERTY(ZShell::config::AppearancePadding* padding READ padding CONSTANT) + Q_PROPERTY(ZShell::config::AppearanceSpacing* spacing READ spacing CONSTANT) CONFIG_SUBOBJECT(FontTokens, font) CONFIG_SUBOBJECT(AnimTokens, anim) CONFIG_SUBOBJECT(BarTokens, bar) @@ -279,12 +183,21 @@ class Tokens : public ConfigObject { public: explicit Tokens(QObject* parent = nullptr) : ConfigObject(parent) - , m_rounding(new AppearanceRounding(this)) - , m_padding(new AppearancePadding(this)) - , m_spacing(new AppearanceSpacing(this)) , m_font(new FontTokens(this)) , m_anim(new AnimTokens(this)) - , m_bar(new BarTokens(this)) {} + , m_bar(new BarTokens(this)) { + if (!Config::instance()) new Config(); + } + + [[nodiscard]] AppearanceRounding* rounding() const { + return Config::instance()->appearance()->rounding(); + } + [[nodiscard]] AppearancePadding* padding() const { + return Config::instance()->appearance()->padding(); + } + [[nodiscard]] AppearanceSpacing* spacing() const { + return Config::instance()->appearance()->spacing(); + } static Tokens* create(QQmlEngine*, QJSEngine*) { return new Tokens(); } }; diff --git a/assets/shaders/geminiglow.frag b/assets/shaders/geminiglow.frag new file mode 100755 index 0000000..ad9de36 --- /dev/null +++ b/assets/shaders/geminiglow.frag @@ -0,0 +1,93 @@ +#version 440 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +// NOTE: std140 layout. Order/types must match the ShaderEffect properties by name. +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + vec2 itemSize; // full ShaderEffect size in px (rect + glow padding) + vec2 rectSize; // size of the target rectangle in px + float radius; // corner radius of the target rectangle in px + float glowSize; // how far the glow extends outside the rectangle (px) + float innerGlow; // how far the glow bleeds inside (px), 0 = none + float lineWidth; // width of the bright edge line (px), 0 = none + float phase; // 0..1, loops seamlessly + float intensity; // overall brightness + vec4 color1; + vec4 color2; + vec4 color3; + vec4 color4; +}; + +const float TAU = 6.28318530718; + +// Signed distance to a rounded box centred at the origin. <0 inside, >0 outside. +float sdRoundBox(vec2 p, vec2 halfSize, float r) +{ + vec2 q = abs(p) - (halfSize - r); + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; +} + +// Cyclic 4-colour gradient, t is wrapped to [0,1) +vec3 palette(float t) +{ + t = fract(t) * 4.0; + float f = smoothstep(0.0, 1.0, fract(t)); + if (t < 1.0) return mix(color1.rgb, color2.rgb, f); + if (t < 2.0) return mix(color2.rgb, color3.rgb, f); + if (t < 3.0) return mix(color3.rgb, color4.rgb, f); + return mix(color4.rgb, color1.rgb, f); +} + +// Cheap hash for dithering (avoids banding in soft gradients) +float hash(vec2 p) +{ + return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); +} + +void main() +{ + // Pixel position relative to the centre of the item + vec2 p = (qt_TexCoord0 - 0.5) * itemSize; + + float r = clamp(radius, 0.0, min(rectSize.x, rectSize.y) * 0.5); + float d = sdRoundBox(p, rectSize * 0.5, r); + + // ---- colour: rotates around the rectangle, slightly wobbling ---- + float ang = atan(p.y, p.x); + float a2 = phase * TAU; + float u = ang / TAU + 0.5 + phase; + u += 0.07 * sin(ang * 2.0 + a2) + 0.04 * sin(ang * 3.0 - a2 * 2.0); + vec3 col = palette(u); + + // ---- intensity falloff ---- + float g = 0.0; + if (d > 0.0) { + float t = clamp(d / max(glowSize, 0.001), 0.0, 1.0); + g = pow(1.0 - t, 3.0); + } else if (innerGlow > 0.0) { + g = exp(d / innerGlow * 3.0); + } + + // Slow "breathing" so the glow feels alive + float pulse = 0.9 + 0.1 * sin(a2 * 2.0 + ang); + g *= pulse; + + // Crisp bright line along the edge + float line = 0.0; + if (lineWidth > 0.0) + line = 1.0 - smoothstep(0.0, lineWidth, abs(d)); + + float a = clamp(g * intensity + line * 0.9, 0.0, 1.0); + + // Brighten the colour a bit where the line is + col = mix(col, vec3(1.0), line * 0.25); + + a += (hash(gl_FragCoord.xy) - 0.5) / 255.0; + a = clamp(a, 0.0, 1.0); + + // Premultiplied alpha output + fragColor = vec4(col * a, a) * qt_Opacity; +} diff --git a/assets/shaders/geminiglow.frag.qsb b/assets/shaders/geminiglow.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..233240e6fd7552d50ef537e097b4a5c3c2609215 GIT binary patch literal 3721 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 literal 0 HcmV?d00001 From 0bfda04e919004fcf262f231fb8762f6d18be5a1 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 22 Sep 2026 02:35:18 +0200 Subject: [PATCH 15/90] intermediary changes, plan to revert to mousearea for gestures + config load bugfixes --- Drawers/Interactions.qml | 50 +-- Drawers/Interactions2.qml | 312 ++++++++++++++++++ Modules/Bar/Components/Clock.qml | 12 +- Modules/Dashboard/Media/LyricList.qml | 2 +- Modules/Launcher/Items/FilesItem.qml | 6 +- Modules/Lock/InputField.qml | 2 +- .../Sidebar/Utils/Cards/RecordingList.qml | 2 +- Modules/Settings/Pages/Apps/AllApps.qml | 2 +- Modules/Settings/Pages/Apps/AppInfo.qml | 2 +- Modules/Settings/Pages/AppsPage.qml | 2 +- Plugins/ZShell/Services/gpu.cpp | 2 +- Plugins/ZShell/Services/tickingservice.cpp | 2 +- 12 files changed, 351 insertions(+), 45 deletions(-) create mode 100644 Drawers/Interactions2.qml diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index a8a1bbc..327289a 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -87,15 +87,27 @@ Item { if (root.singleGestureTriggered) return; - // if (root.geometry.insetTop(root.borderThickness) > pressY) { - // if (dragY > 20) { - // root.screenState.settings = true; - // root.singleGestureTriggered = true; - // } else if (dragY < -20) { - // root.screenState.settings = false; - // root.singleGestureTriggered = true; - // } - // } + const inDashboard = root.inTopPanel(root.panels.dashboardWrapper, pressX, pressY); + + if (inDashboard && root.withinPanelWidth(root.panels.dashboardWrapper, x, y)) { + if (dragY > Config.dashboard.dragThreshold) { + root.screenState.dashboard = true; + root.singleGestureTriggered = true; + } else if (dragY < -Config.dashboard.dragThreshold) { + root.screenState.dashboard = false; + root.singleGestureTriggered = true; + } + } + + if (root.geometry.insetTop(root.borderThickness) > pressY && (Config.dashboard.showOnHover || !inDashboard)) { + if (dragY > 20) { + root.screenState.settings = true; + root.singleGestureTriggered = true; + } else if (dragY < -20) { + root.screenState.settings = false; + root.singleGestureTriggered = true; + } + } if (pressY > root.screen.height - root.geometry.insetBottom(root.borderThickness) && pressX < root.screen.width / 5 && dragY < -50) root.screenState.clipboard = true; @@ -120,16 +132,6 @@ Item { Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`); root.singleGestureTriggered = true; } - - if (root.inTopPanel(root.panels.dashboardWrapper, pressX, pressY) && root.withinPanelWidth(root.panels.dashboardWrapper, x, y)) { - if (dragY > Config.dashboard.dragThreshold) { - root.screenState.dashboard = true; - root.singleGestureTriggered = true; - } else if (dragY < -Config.dashboard.dragThreshold) { - root.screenState.dashboard = false; - root.singleGestureTriggered = true; - } - } } } @@ -276,6 +278,7 @@ Item { root.screenState.settings = false; root.screenState.sidebar = false; + root.screenState.resources = false; root.popouts.hasCurrent = false; } else { root.dashboardShortcutActive = false; @@ -323,12 +326,13 @@ Item { } function onResourcesChanged() { - if (root.screenState.resources && (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates"))) { - root.popouts.hasCurrent = false; - } + if (root.screenState.resources) { + if (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates")) + root.popouts.hasCurrent = false; - if (root.screenState.resources) root.screenState.settings = false; + root.screenState.dashboard = false; + } } function onSettingsChanged() { diff --git a/Drawers/Interactions2.qml b/Drawers/Interactions2.qml new file mode 100644 index 0000000..b35cf72 --- /dev/null +++ b/Drawers/Interactions2.qml @@ -0,0 +1,312 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import ZShell.Config +import qs.Components +import qs.Modules.Bar as Bar +import qs.Modules.Bar.Popouts as BarPopouts + +CustomMouseArea { + id: root + + required property ShellScreen screen + required property BarPopouts.Wrapper popouts + required property ScreenState screenState + required property Panels panels + required property Bar.BarLoader bar + required property real borderThickness + required property bool fullscreen + + property point dragStart + property bool dashboardShortcutActive + property bool osdShortcutActive + property bool utilitiesShortcutActive + + function withinPanelHeight(panel: Item, x: real, y: real): bool { + const panelY = root.borderThickness + panel.y; + return y >= panelY - Config.border.rounding && y <= panelY + panel.height + Config.border.rounding; + } + + function withinPanelWidth(panel: Item, x: real, y: real): bool { + const panelX = bar.implicitWidth + panel.x; + return x >= panelX - Config.border.rounding && x <= panelX + panel.width + Config.border.rounding; + } + + function inLeftPanel(panel: Item, x: real, y: real): bool { + return x < bar.implicitWidth + panel.x + panel.width && withinPanelHeight(panel, x, y); + } + + function inRightPanel(panel: Item, x: real, y: real): bool { + return x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panel.x) && withinPanelHeight(panel, x, y); + } + + function inTopPanel(panel: Item, x: real, y: real): bool { + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); // qmllint disable missing-property + return y < Math.max(Config.border.minThickness, Config.border.thickness + panelHeight) && withinPanelWidth(panel, x, y); + } + + function inBottomPanel(panel: Item, x: real, y: real, isCorner = false): bool { + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); // qmllint disable missing-property + return y > height - Math.max(Config.border.minThickness, Config.border.thickness + panelHeight) - (isCorner ? Config.border.rounding : 0) && withinPanelWidth(panel, x, y); + } + + function onWheel(event: WheelEvent): void { + if (fullscreen) + return; + if (event.x < bar.implicitWidth) { + bar.handleWheel(event.y, event.angleDelta); + } + } + + anchors.fill: parent + acceptedButtons: fullscreen ? Qt.NoButton : Qt.AllButtons + hoverEnabled: true + + onPressed: event => dragStart = Qt.point(event.x, event.y) + onContainsMouseChanged: { + if (!containsMouse) { + // Only hide if not activated by shortcut + if (!osdShortcutActive) { + screenState.osd = false; + root.panels.osd.hovered = false; + } + + if (!dashboardShortcutActive) + screenState.dashboard = false; + + if (!utilitiesShortcutActive) + screenState.utilities = false; + + if (!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) { + popouts.hasCurrent = false; + bar.closeTray(); + } + + if (Config.bar.showOnHover) + bar.isHovered = false; + + if (Config.sidebar.showOnHover) + screenState.sidebar = false; + } + } + + onPositionChanged: event => { + if (popouts.isDetached) + return; + + const x = event.x; + const y = event.y; + const dragX = x - dragStart.x; + const dragY = y - dragStart.y; + + if (fullscreen) { + root.panels.osd.hovered = inRightPanel(panels.osdWrapper, x, y); + return; + } + + // Show bar in non-exclusive mode on hover + if (!screenState.bar && Config.bar.showOnHover && x < bar.clampedWidth) + bar.isHovered = true; + + // Show/hide bar on drag + if (pressed && dragStart.x < bar.clampedWidth) { + if (dragX > Config.bar.dragThreshold) + screenState.bar = true; + else if (dragX < -Config.bar.dragThreshold) + screenState.bar = false; + } + + if (panels.sidebar.offsetScale === 1) { + // Show osd on hover + const showOsd = inRightPanel(panels.osdWrapper, x, y); + + // Always update visibility based on hover if not in shortcut mode + if (!osdShortcutActive) { + screenState.osd = showOsd; + root.panels.osd.hovered = showOsd; + } else if (showOsd) { + // If hovering over OSD area while in shortcut mode, transition to hover control + osdShortcutActive = false; + root.panels.osd.hovered = true; + } + + const showSidebar = pressed && dragStart.x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panels.sidebar.x); + + // Show sidebar on hover (top-right corner, bounded by notification panel height) + if (Config.sidebar.showOnHover) { + const sidebarTriggerY = Math.max(Config.sidebar.minHoverThreshold, panels.notifications.y + panels.notifications.height + borderThickness); + const showSidebarHover = x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panels.sidebar.x) && y <= sidebarTriggerY; + if (showSidebarHover && !screenState.sidebar) + screenState.sidebar = true; + } + + // Show/hide session on drag + if (pressed && inRightPanel(panels.sessionWrapper, dragStart.x, dragStart.y) && withinPanelHeight(panels.sessionWrapper, x, y)) { + if (dragX < -Config.session.dragThreshold) + screenState.session = true; + else if (dragX > Config.session.dragThreshold) + screenState.session = false; + + // Show sidebar on drag if in session area and session is nearly fully visible + if (showSidebar && panels.session.offsetScale <= 0 && dragX < -Config.sidebar.dragThreshold) + screenState.sidebar = true; + } else if (showSidebar && dragX < -Config.sidebar.dragThreshold) { + // Show sidebar on drag if not in session area + screenState.sidebar = true; + } + } else { + const outOfSidebar = x < width - panels.sidebar.width * (1 - panels.sidebar.offsetScale); + // Show osd on hover + const showOsd = outOfSidebar && inRightPanel(panels.osdWrapper, x, y); + + // Always update visibility based on hover if not in shortcut mode + if (!osdShortcutActive) { + screenState.osd = showOsd; + root.panels.osd.hovered = showOsd; + } else if (showOsd) { + // If hovering over OSD area while in shortcut mode, transition to hover control + osdShortcutActive = false; + root.panels.osd.hovered = true; + } + + // Show/hide session on drag + if (pressed && outOfSidebar && inRightPanel(panels.sessionWrapper, dragStart.x, dragStart.y) && withinPanelHeight(panels.sessionWrapper, x, y)) { + if (dragX < -Config.session.dragThreshold) + screenState.session = true; + else if (dragX > Config.session.dragThreshold) + screenState.session = false; + } + + // Show/hide sidebar on hover + if (Config.sidebar.showOnHover && !pressed) { + const sidebarTriggerY = Math.max(Config.sidebar.minHoverThreshold, panels.notifications.y + panels.notifications.height + borderThickness); + const showSidebarHover = x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panels.sidebar.x) && y <= sidebarTriggerY; + if (showSidebarHover && !screenState.sidebar) { + screenState.sidebar = true; + } else { + const inSidebarArea = inRightPanel(panels.sidebar, x, y) || inRightPanel(panels.sessionWrapper, x, y); + if (!inSidebarArea) + screenState.sidebar = false; + } + } + + // Hide sidebar on drag + if (pressed && inRightPanel(panels.sidebar, dragStart.x, 0) && dragX > Config.sidebar.dragThreshold) + screenState.sidebar = false; + } + + // Show launcher on hover, or show/hide on drag if hover is disabled + if (Config.launcher.showOnHover) { + if (!screenState.launcher && inBottomPanel(panels.launcher, x, y)) + screenState.launcher = true; + } else if (pressed && inBottomPanel(panels.launcher, dragStart.x, dragStart.y) && withinPanelWidth(panels.launcher, x, y)) { + if (dragY < -Config.launcher.dragThreshold) + screenState.launcher = true; + else if (dragY > Config.launcher.dragThreshold) + screenState.launcher = false; + } + + // Show dashboard on hover + const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboard, x, y); + + // Always update visibility based on hover if not in shortcut mode + if (!dashboardShortcutActive) { + screenState.dashboard = showDashboard; + } else if (showDashboard) { + // If hovering over dashboard area while in shortcut mode, transition to hover control + dashboardShortcutActive = false; + } + + // Show/hide dashboard on drag (for touchscreen devices) + if (pressed && inTopPanel(panels.dashboard, dragStart.x, dragStart.y) && withinPanelWidth(panels.dashboard, x, y)) { + if (dragY > Config.dashboard.dragThreshold) + screenState.dashboard = true; + else if (dragY < -Config.dashboard.dragThreshold) + screenState.dashboard = false; + } + + // Show utilities on hover + const showUtilities = inBottomPanel(panels.utilities, x, y, true); + + // Always update visibility based on hover if not in shortcut mode + if (!utilitiesShortcutActive) { + screenState.utilities = showUtilities; + } else if (showUtilities) { + // If hovering over utilities area while in shortcut mode, transition to hover control + utilitiesShortcutActive = false; + } + + // Show popouts on hover + if (x < bar.implicitWidth) { + bar.checkPopout(y); + } else if ((!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) && !inLeftPanel(panels.popoutsWrapper, x, y)) { + popouts.hasCurrent = false; + bar.closeTray(); + } + } + + // Monitor individual visibility changes + Connections { + function onLauncherChanged() { + // If launcher is hidden, clear shortcut flags for dashboard and OSD + if (!root.screenState.launcher) { + root.dashboardShortcutActive = false; + root.osdShortcutActive = false; + root.utilitiesShortcutActive = false; + + // Also hide dashboard and OSD if they're not being hovered + const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); + const inOsdArea = root.inRightPanel(root.panels.osdWrapper, root.mouseX, root.mouseY); + + if (!inDashboardArea) { + root.screenState.dashboard = false; + } + if (!inOsdArea) { + root.screenState.osd = false; + root.panels.osd.hovered = false; + } + } + } + + function onDashboardChanged() { + if (root.screenState.dashboard) { + // Dashboard became visible, immediately check if this should be shortcut mode + const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); + if (!inDashboardArea) { + root.dashboardShortcutActive = true; + } + } else { + // Dashboard hidden, clear shortcut flag + root.dashboardShortcutActive = false; + } + } + + function onOsdChanged() { + if (root.screenState.osd) { + // OSD became visible, immediately check if this should be shortcut mode + const inOsdArea = root.inRightPanel(root.panels.osdWrapper, root.mouseX, root.mouseY); + if (!inOsdArea) { + root.osdShortcutActive = true; + } + } else { + // OSD hidden, clear shortcut flag + root.osdShortcutActive = false; + } + } + + function onUtilitiesChanged() { + if (root.screenState.utilities) { + // Utilities became visible, immediately check if this should be shortcut mode + const inUtilitiesArea = root.inBottomPanel(root.panels.utilities, root.mouseX, root.mouseY); + if (!inUtilitiesArea) { + root.utilitiesShortcutActive = true; + } + } else { + // Utilities hidden, clear shortcut flag + root.utilitiesShortcutActive = false; + } + } + + target: root.screenState + } +} diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index 4791c73..3f9f3e3 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -14,7 +14,7 @@ WidgetBase { id: root readonly property font font: Tokens.font.clock.family(Config.appearance.font.family.clock).build() - readonly property color contentColor: screenState.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + readonly property color contentColor: Colors.palette.m3onSurface readonly property string effectiveFormat: { let fmt = Config.general.dateFormat; if (!root.horizontal) @@ -48,7 +48,6 @@ WidgetBase { readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2 required property ScreenState screenState - color: screenState.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer implicitHeight: horizontal ? baseSize : size implicitWidth: horizontal ? size : baseSize radius: Tokens.rounding.full @@ -146,13 +145,4 @@ WidgetBase { } } } - - StateLayer { - acceptedButtons: Qt.LeftButton - color: root.contentColor - - onClicked: { - root.screenState.dashboard = !root.screenState.dashboard; - } - } } diff --git a/Modules/Dashboard/Media/LyricList.qml b/Modules/Dashboard/Media/LyricList.qml index 69ce845..cdec3dc 100644 --- a/Modules/Dashboard/Media/LyricList.qml +++ b/Modules/Dashboard/Media/LyricList.qml @@ -179,7 +179,7 @@ Item { CustomText { text: qsTr("Loading lyrics...") color: Colors.palette.m3onSurfaceVariant - font: Tokens.font.body.normal + font: Tokens.font.body.medium } } diff --git a/Modules/Launcher/Items/FilesItem.qml b/Modules/Launcher/Items/FilesItem.qml index b878e6e..e84ca5a 100644 --- a/Modules/Launcher/Items/FilesItem.qml +++ b/Modules/Launcher/Items/FilesItem.qml @@ -76,7 +76,7 @@ Item { id: icon anchors.verticalCenter: parent.verticalCenter - implicitSize: Tokens.font.size.extraLarge + implicitSize: Tokens.font.icon.extraLarge.pointSize source: Quickshell.iconPath(root.modelData?.icon) ?? "" visible: !root.modelData?.isImage } @@ -94,14 +94,14 @@ Item { } sourceComponent: LoadingIndicator { color: Colors.palette.m3primaryContainer - implicitSize: Tokens.font.size.extraLarge + implicitSize: Tokens.font.icon.extraLarge.pointSize } } FadeImage { id: image - property int implicitSize: Tokens.font.size.extraLarge + property int implicitSize: Tokens.font.icon.extraLarge.pointSize width: implicitSize fillMode: Image.PreserveAspectFit diff --git a/Modules/Lock/InputField.qml b/Modules/Lock/InputField.qml index b2ca56c..c7ac527 100644 --- a/Modules/Lock/InputField.qml +++ b/Modules/Lock/InputField.qml @@ -70,7 +70,7 @@ Item { anchors.centerIn: parent anchors.horizontalCenterOffset: implicitWidth > root.width ? -(implicitWidth - root.width) / 2 : 0 - implicitHeight: Tokens.font.size.normal + implicitHeight: Tokens.font.body.medium.pointSize implicitWidth: fullWidth interactive: false orientation: Qt.Horizontal diff --git a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml index 5c28149..2b2d344 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml @@ -56,7 +56,7 @@ ColumnLayout { Layout.fillWidth: true Layout.rightMargin: -Tokens.spacing.small clip: true - implicitHeight: (Tokens.font.size.larger + Tokens.padding.extraSmall) * (root.screenState.recordingListExpanded ? 10 : 3) + implicitHeight: (Tokens.font.body.large.pointSize + Tokens.padding.extraSmall) * (root.screenState.recordingListExpanded ? 10 : 3) CustomScrollBar.vertical: CustomScrollBar { flickable: list diff --git a/Modules/Settings/Pages/Apps/AllApps.qml b/Modules/Settings/Pages/Apps/AllApps.qml index 0dde289..4f9e685 100644 --- a/Modules/Settings/Pages/Apps/AllApps.qml +++ b/Modules/Settings/Pages/Apps/AllApps.qml @@ -56,7 +56,7 @@ PageBase { IconImage { asynchronous: true - implicitSize: Math.round(Tokens.font.size.large * 1.8) + implicitSize: Math.round(Tokens.font.icon.large * 1.8) source: Quickshell.iconPath(appItem.modelData.icon, "image-missing") } diff --git a/Modules/Settings/Pages/Apps/AppInfo.qml b/Modules/Settings/Pages/Apps/AppInfo.qml index 333a880..4f549da 100644 --- a/Modules/Settings/Pages/Apps/AppInfo.qml +++ b/Modules/Settings/Pages/Apps/AppInfo.qml @@ -47,7 +47,7 @@ PageBase { IconImage { asynchronous: true - implicitSize: Math.round(Tokens.font.size.large * 3) + implicitSize: Math.round(Tokens.font.icon.large.pointSize * 3) source: Quickshell.iconPath(root.app?.icon, "image-missing") } diff --git a/Modules/Settings/Pages/AppsPage.qml b/Modules/Settings/Pages/AppsPage.qml index 43cf99f..32206a0 100644 --- a/Modules/Settings/Pages/AppsPage.qml +++ b/Modules/Settings/Pages/AppsPage.qml @@ -143,7 +143,7 @@ PageBase { IconImage { asynchronous: true - implicitSize: Math.round(Tokens.font.size.large * 1.8) + implicitSize: Math.round(Tokens.font.icon.large.pointSize * 1.8) source: Quickshell.iconPath(appItem.modelData.icon, "image-missing") } diff --git a/Plugins/ZShell/Services/gpu.cpp b/Plugins/ZShell/Services/gpu.cpp index 8d3d1da..c12d896 100644 --- a/Plugins/ZShell/Services/gpu.cpp +++ b/Plugins/ZShell/Services/gpu.cpp @@ -60,7 +60,7 @@ Gpu::Gpu(QObject* parent) : TickingService(parent) { [this, configPath]() { QTimer::singleShot(100, this, [this, configPath]() { QFile file(configPath); - if (file.exists()) { + if (file.open(QIODevice::ReadOnly)) { QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); if (!doc.isNull()) { diff --git a/Plugins/ZShell/Services/tickingservice.cpp b/Plugins/ZShell/Services/tickingservice.cpp index 5c51cdc..96083d5 100644 --- a/Plugins/ZShell/Services/tickingservice.cpp +++ b/Plugins/ZShell/Services/tickingservice.cpp @@ -44,7 +44,7 @@ TickingService::TickingService(QObject* parent) [this, configPath]() { QTimer::singleShot(100, this, [this, configPath]() { QFile file(configPath); - if (!file.exists()) { + if (!file.open(QIODevice::ReadOnly)) { return; } QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); From 359f832609694346a0fb2fe68ad16554f448e80a Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 25 Sep 2026 16:51:28 +0200 Subject: [PATCH 16/90] more unfinished changes, process tab for dashboard --- Components/ButtonBase.qml | 2 +- Components/StateLayer.qml | 4 +- Drawers/Windows.qml | 17 +- Helpers/ResourceUsage.qml | 86 ++ Modules/Bar/BarLoader.qml | 2 +- Modules/Bar/Components/Common/WidgetBase.qml | 2 +- Modules/Bar/Components/Workspaces.qml | 8 +- Modules/Dashboard/Content.qml | 210 +-- .../Dashboard/Processes/ProcessesColumns.qml | 209 +++ .../Dashboard/Processes/ProcessesHeader.qml | 712 ++++++++++ Modules/Dashboard/Processes/ProcessesList.qml | 140 ++ Modules/Dashboard/Processes/SystemInfo.qml | 186 +++ Modules/Dashboard/ProcessesTab.qml | 64 + Modules/Resources/Cards/NetworkCard.qml | 5 +- Plugins/ZShell/CMakeLists.txt | 11 + Plugins/ZShell/Components/CMakeLists.txt | 10 +- .../ZShell/Components/sparklinecontent.cpp | 250 ++++ .../ZShell/Components/sparklinecontent.hpp | 69 + Plugins/ZShell/Components/sparklineitem.cpp | 179 +++ .../sparklineitem.hpp | 47 +- .../ZShell/Components/sparklinesceneitem.cpp | 447 +++++++ .../ZShell/Components/sparklinesceneitem.hpp | 167 +++ Plugins/ZShell/Config/CMakeLists.txt | 20 +- Plugins/ZShell/Config/anim.cpp | 103 +- Plugins/ZShell/Config/anim.hpp | 75 +- Plugins/ZShell/Config/appearance.cpp | 229 ++++ Plugins/ZShell/Config/appearance.hpp | 482 +++---- Plugins/ZShell/Config/background.hpp | 14 +- Plugins/ZShell/Config/bar.hpp | 72 +- Plugins/ZShell/Config/clipboard.hpp | 31 +- Plugins/ZShell/Config/colors.hpp | 34 +- Plugins/ZShell/Config/common.cpp | 22 + Plugins/ZShell/Config/common.hpp | 54 + Plugins/ZShell/Config/config.cpp | 278 ---- Plugins/ZShell/Config/config.hpp | 108 -- Plugins/ZShell/Config/configlist.cpp | 314 ----- Plugins/ZShell/Config/configlist.hpp | 139 -- Plugins/ZShell/Config/confignode.cpp | 89 -- Plugins/ZShell/Config/confignode.hpp | 65 - Plugins/ZShell/Config/configobject.cpp | 336 ----- Plugins/ZShell/Config/configobject.hpp | 101 -- Plugins/ZShell/Config/dashboard.hpp | 99 +- Plugins/ZShell/Config/display.hpp | 24 + Plugins/ZShell/Config/dock.hpp | 22 +- Plugins/ZShell/Config/font.cpp | 18 +- Plugins/ZShell/Config/font.hpp | 20 +- Plugins/ZShell/Config/general.hpp | 152 +-- Plugins/ZShell/Config/launcher.hpp | 119 +- Plugins/ZShell/Config/lock.hpp | 37 +- Plugins/ZShell/Config/notifs.hpp | 43 +- Plugins/ZShell/Config/osd.hpp | 33 +- Plugins/ZShell/Config/rootnodes.cpp | 191 +++ Plugins/ZShell/Config/rootnodes.hpp | 168 +++ Plugins/ZShell/Config/screenshot.hpp | 30 +- Plugins/ZShell/Config/services.hpp | 60 +- Plugins/ZShell/Config/sidebar.hpp | 25 +- Plugins/ZShell/Config/tokens.hpp | 320 ++--- Plugins/ZShell/Config/utilities.hpp | 91 +- Plugins/ZShell/Internal/CMakeLists.txt | 3 +- Plugins/ZShell/Internal/circularbuffer.cpp | 60 + Plugins/ZShell/Internal/circularbuffer.hpp | 17 + Plugins/ZShell/Internal/cursor.hpp | 36 + Plugins/ZShell/Internal/sparklineitem.cpp | 322 ----- Plugins/ZShell/Services/CMakeLists.txt | 2 + Plugins/ZShell/Services/lyrics.cpp | 10 +- Plugins/ZShell/Services/memory.cpp | 54 +- Plugins/ZShell/Services/memory.hpp | 12 + Plugins/ZShell/Services/processes.cpp | 1161 +++++++++++++++++ Plugins/ZShell/Services/processes.hpp | 259 ++++ Plugins/ZShell/Services/tickingservice.cpp | 5 +- Plugins/ZShell/Services/tickingservice.hpp | 1 + Plugins/ZShell/Services/usagefmt.cpp | 18 + Plugins/ZShell/Services/usagefmt.hpp | 2 + Plugins/ZShell/Settings/CMakeLists.txt | 17 + Plugins/ZShell/Settings/changebatcher.cpp | 25 + Plugins/ZShell/Settings/changebatcher.hpp | 24 + Plugins/ZShell/Settings/codecs.cpp | 371 ++++++ Plugins/ZShell/Settings/codecs.hpp | 101 ++ Plugins/ZShell/Settings/common.cpp | 131 ++ Plugins/ZShell/Settings/common.hpp | 99 ++ Plugins/ZShell/Settings/layerregistry.hpp | 75 ++ Plugins/ZShell/Settings/listnode.cpp | 445 +++++++ Plugins/ZShell/Settings/listnode.hpp | 69 + Plugins/ZShell/Settings/macros.hpp | 193 +++ Plugins/ZShell/Settings/node.cpp | 280 ++++ Plugins/ZShell/Settings/node.hpp | 100 ++ Plugins/ZShell/Settings/objectnode.cpp | 219 ++++ Plugins/ZShell/Settings/objectnode.hpp | 35 + Plugins/ZShell/Settings/quarantine.cpp | 27 + Plugins/ZShell/Settings/quarantine.hpp | 33 + Plugins/ZShell/Settings/rootnode.cpp | 46 + Plugins/ZShell/Settings/rootnode.hpp | 35 + Plugins/ZShell/Settings/schema.cpp | 133 ++ Plugins/ZShell/Settings/schema.hpp | 81 ++ Plugins/ZShell/Settings/settingsfile.cpp | 213 +++ Plugins/ZShell/Settings/settingsfile.hpp | 51 + Plugins/ZShell/toaster.cpp | 5 + Plugins/ZShell/toaster.hpp | 1 + 98 files changed, 8633 insertions(+), 2983 deletions(-) create mode 100644 Helpers/ResourceUsage.qml create mode 100644 Modules/Dashboard/Processes/ProcessesColumns.qml create mode 100644 Modules/Dashboard/Processes/ProcessesHeader.qml create mode 100644 Modules/Dashboard/Processes/ProcessesList.qml create mode 100644 Modules/Dashboard/Processes/SystemInfo.qml create mode 100644 Modules/Dashboard/ProcessesTab.qml create mode 100644 Plugins/ZShell/Components/sparklinecontent.cpp create mode 100644 Plugins/ZShell/Components/sparklinecontent.hpp create mode 100644 Plugins/ZShell/Components/sparklineitem.cpp rename Plugins/ZShell/{Internal => Components}/sparklineitem.hpp (76%) create mode 100644 Plugins/ZShell/Components/sparklinesceneitem.cpp create mode 100644 Plugins/ZShell/Components/sparklinesceneitem.hpp create mode 100644 Plugins/ZShell/Config/appearance.cpp create mode 100644 Plugins/ZShell/Config/common.cpp create mode 100644 Plugins/ZShell/Config/common.hpp delete mode 100644 Plugins/ZShell/Config/config.cpp delete mode 100644 Plugins/ZShell/Config/config.hpp delete mode 100644 Plugins/ZShell/Config/configlist.cpp delete mode 100644 Plugins/ZShell/Config/configlist.hpp delete mode 100644 Plugins/ZShell/Config/confignode.cpp delete mode 100644 Plugins/ZShell/Config/confignode.hpp delete mode 100644 Plugins/ZShell/Config/configobject.cpp delete mode 100644 Plugins/ZShell/Config/configobject.hpp create mode 100644 Plugins/ZShell/Config/display.hpp create mode 100644 Plugins/ZShell/Config/rootnodes.cpp create mode 100644 Plugins/ZShell/Config/rootnodes.hpp create mode 100644 Plugins/ZShell/Internal/cursor.hpp delete mode 100644 Plugins/ZShell/Internal/sparklineitem.cpp create mode 100644 Plugins/ZShell/Services/processes.cpp create mode 100644 Plugins/ZShell/Services/processes.hpp create mode 100644 Plugins/ZShell/Settings/CMakeLists.txt create mode 100644 Plugins/ZShell/Settings/changebatcher.cpp create mode 100644 Plugins/ZShell/Settings/changebatcher.hpp create mode 100644 Plugins/ZShell/Settings/codecs.cpp create mode 100644 Plugins/ZShell/Settings/codecs.hpp create mode 100644 Plugins/ZShell/Settings/common.cpp create mode 100644 Plugins/ZShell/Settings/common.hpp create mode 100644 Plugins/ZShell/Settings/layerregistry.hpp create mode 100644 Plugins/ZShell/Settings/listnode.cpp create mode 100644 Plugins/ZShell/Settings/listnode.hpp create mode 100644 Plugins/ZShell/Settings/macros.hpp create mode 100644 Plugins/ZShell/Settings/node.cpp create mode 100644 Plugins/ZShell/Settings/node.hpp create mode 100644 Plugins/ZShell/Settings/objectnode.cpp create mode 100644 Plugins/ZShell/Settings/objectnode.hpp create mode 100644 Plugins/ZShell/Settings/quarantine.cpp create mode 100644 Plugins/ZShell/Settings/quarantine.hpp create mode 100644 Plugins/ZShell/Settings/rootnode.cpp create mode 100644 Plugins/ZShell/Settings/rootnode.hpp create mode 100644 Plugins/ZShell/Settings/schema.cpp create mode 100644 Plugins/ZShell/Settings/schema.hpp create mode 100644 Plugins/ZShell/Settings/settingsfile.cpp create mode 100644 Plugins/ZShell/Settings/settingsfile.hpp diff --git a/Components/ButtonBase.qml b/Components/ButtonBase.qml index 9b9e7bd..462b867 100644 --- a/Components/ButtonBase.qml +++ b/Components/ButtonBase.qml @@ -51,7 +51,7 @@ CustomRect { if (internalChecked) return checkedRadius; if (isRound) - return (height || implicitHeight) / 2 * Math.min(1, Tokens.rounding.scale); + return implicitHeight / 2 * Math.min(1, Tokens.rounding.scale); return defaultRadius; } diff --git a/Components/StateLayer.qml b/Components/StateLayer.qml index 523f6e0..8857eb0 100644 --- a/Components/StateLayer.qml +++ b/Components/StateLayer.qml @@ -99,13 +99,13 @@ MouseArea { id: base anchors.fill: parent - bottomLeftRadius: root.parent?.bottomLeftRadius ?? radius ?? 0 - bottomRightRadius: root.parent?.bottomRightRadius ?? radius ?? 0 color: Colors.palette.m3onSurface opacity: root.stateOpacity // Pick up radius from parent if it has one (parent can be anything with radius props) // qmllint disable missing-property radius: root.parent?.radius ?? 0 + bottomLeftRadius: root.parent?.bottomLeftRadius ?? radius ?? 0 + bottomRightRadius: root.parent?.bottomRightRadius ?? radius ?? 0 topLeftRadius: root.parent?.topLeftRadius ?? radius ?? 0 topRightRadius: root.parent?.topRightRadius ?? radius ?? 0 // qmllint enable missing-property diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index 161027a..b2c9887 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -145,8 +145,23 @@ CustomWindow { HyprlandFocusGrab { id: focusGrab - active: root.screenState.dock || root.screenState.resources || root.screenState.launcher || root.screenState.sidebar || (!Config.dashboard.showOnHover && root.screenState.dashboard) || root.screenState.settings || root.screenState.clipboard || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") && (!Config.bar.tray.showOnHover || (panels.popouts.current as StackView)?.depth > 1)) windows: [root] + active: { + const s = root.screenState; + const c = Config; + + if ((s.launcher && c.launcher.enabled) || (s.sidebar && c.sidebar.enabled) || (s.resources && c.dashboard.performance.enabled) || (s.clipboard && c.clipboard.enabled)) + return true; + if (s.dashboard && !c.dashboard.showOnHover && c.dashboard.enabled) + return true; + if (!c.dock.hoverToReveal && s.dock && c.dock.enable) + return true; + if (panels.popouts.currentName.startsWith("traymenu") && (panels.popouts.current as StackView)?.depth > 1) + return true; + if (s.settings) + return true; + return false; + } onCleared: { root.screenState.launcher = false; diff --git a/Helpers/ResourceUsage.qml b/Helpers/ResourceUsage.qml new file mode 100644 index 0000000..70de2df --- /dev/null +++ b/Helpers/ResourceUsage.qml @@ -0,0 +1,86 @@ +pragma Singleton + +import QtQuick +import Quickshell +import ZShell.Internal +import ZShell.Services + +Singleton { + id: root + + readonly property int historyLength: 30 + readonly property CircularBuffer cpuBuffer: _cpuBuffer + readonly property CircularBuffer memoryBuffer: _memoryBuffer + + readonly property list cpuInfo: [ + { + label: qsTr("Usage"), + value: `${Math.round(Cpu.percentage * 100)}%` + }, + { + label: qsTr("Temp"), + value: `${Math.ceil(Cpu.temperature)}°${"C"}` + } + ] + + readonly property list memoryInfo: [ + { + label: qsTr("Used"), + value: memoryUsageFormat(Memory.used, Memory.total) + }, + { + label: qsTr("Cached"), + value: memoryTotalFormat(Memory.cached) + }, + { + label: qsTr("Writes"), + value: memoryTotalFormat(Memory.writeTotal) + }, + { + label: qsTr("Reads"), + value: memoryTotalFormat(Memory.readTotal) + } + ] + + function memoryTotalFormat(kib: int): string { + const fmt = UsageFmt.formatKib(kib); + return `${fmt.value.toFixed(1)} ${fmt.unit}`; + } + + function memoryUsageFormat(used: int, total: int): string { + const fmt = UsageFmt.formatKib(used, total); + return `${fmt.value.toFixed(1)} / ${Math.floor(fmt.total)} ${fmt.unit}`; + } + + Connections { + function onUpdateTriggered(): void { + if (Cpu.percentage >= 0 && isFinite(Cpu.percentage)) + _cpuBuffer.push(Cpu.percentage); + } + + target: Cpu + } + + Connections { + function onUpdateTriggered(): void { + if (Memory.percentage >= 0 && isFinite(Memory.percentage)) + _memoryBuffer.push(Memory.percentage); + } + + target: Memory + } + + CircularBuffer { + id: _cpuBuffer + + capacity: root.historyLength + 1 + smoothingRadius: 2 + } + + CircularBuffer { + id: _memoryBuffer + + capacity: root.historyLength + 1 + smoothingRadius: 2 + } +} diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index 41e52fb..82ff8e8 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -10,7 +10,7 @@ Item { id: root readonly property int clampedExtent: Math.max(Config.bar.border, extent) - readonly property int contentThickness: Math.floor(Math.max(Config.bar.height, Tokens.bar.innerSize * (horizontal ? 1 : 1.5))) + padding * 2 + readonly property int contentThickness: Math.floor(Math.max(Config.bar.height, Tokens.sizes.bar.innerSize * (horizontal ? 1 : 1.5))) + padding * 2 readonly property int exclusiveZone: Config.bar.autoHide ? Config.bar.border : contentThickness property real extent: fullscreen ? 0 : Config.bar.border required property bool fullscreen diff --git a/Modules/Bar/Components/Common/WidgetBase.qml b/Modules/Bar/Components/Common/WidgetBase.qml index 2a0fb95..3795a31 100644 --- a/Modules/Bar/Components/Common/WidgetBase.qml +++ b/Modules/Bar/Components/Common/WidgetBase.qml @@ -6,7 +6,7 @@ import qs.Services CustomRect { id: root - readonly property int baseSize: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : Math.max(Config.bar.height, Math.floor(Tokens.bar.innerSize * 1.5)) + readonly property int baseSize: horizontal ? Math.max(Config.bar.height, Tokens.sizes.bar.innerSize) : Math.max(Config.bar.height, Math.floor(Tokens.sizes.bar.innerSize * 1.5)) required property bool horizontal color: Colors.tPalette.m3surfaceContainer diff --git a/Modules/Bar/Components/Workspaces.qml b/Modules/Bar/Components/Workspaces.qml index cfa9a76..9c5d448 100644 --- a/Modules/Bar/Components/Workspaces.qml +++ b/Modules/Bar/Components/Workspaces.qml @@ -17,7 +17,7 @@ Item { required property bool horizontal readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen) required property ShellScreen screen - readonly property real shortSize: Math.max(Config.bar.height, Tokens.bar.innerSize) + readonly property real shortSize: Math.max(Config.bar.height, Tokens.sizes.bar.innerSize) readonly property real size: (workspaceButtonWidth * workspacesShown) + activeWorkspaceMargin * 2 property int workspaceButtonWidth: (horizontal ? bgRect.implicitHeight : bgRect.implicitWidth) - root.activeWorkspaceMargin * 2 property int workspaceIndexInGroup: (effectiveActiveWorkspaceId - 1) % root.workspacesShown @@ -30,14 +30,12 @@ Item { Behavior on implicitHeight { enabled: !root.horizontal - Anim { - } + Anim {} } Behavior on implicitWidth { enabled: root.horizontal - Anim { - } + Anim {} } CustomRect { diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index 20c418a..d34b536 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -11,8 +11,8 @@ import qs.Components Item { id: root - readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 - readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins * 2 + readonly property real nonAnimWidth: view.implicitWidth + clipWrapper.anchors.margins * 2 + readonly property real nonAnimHeight: tabs.implicitHeight + clipWrapper.anchors.topMargin + view.implicitHeight + clipWrapper.anchors.margins * 2 required property ScreenState screenState @@ -36,12 +36,12 @@ Item { text: qsTr("Media"), enabled: true }, - // { - // component: testingComponent, - // iconName: "speed", - // text: qsTr("Testing"), - // enabled: true - // }, + { + component: procsComponent, + iconName: "speed", + text: qsTr("Processes"), + enabled: true + }, ]; return allTabs.filter(tab => tab.enabled); } @@ -50,130 +50,138 @@ Item { implicitHeight: nonAnimHeight Behavior on implicitHeight { - Anim {} + Anim { + type: Anim.SlowEffects + } } Behavior on implicitWidth { - Anim {} + Anim { + type: Anim.SlowEffects + } } - Tabs { - id: tabs - - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins) - anchors.margins: Tokens.padding.large - - nonAnimWidth: root.nonAnimWidth - anchors.margins * 2 - screenState: root.screenState - tabs: root.dashboardTabs - } - - ClippingRectangle { - id: viewWrapper - - anchors.top: tabs.bottom - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom + Item { + id: clipWrapper + anchors.fill: parent anchors.margins: Tokens.padding.small - color: "transparent" - radius: Tokens.rounding.extraLarge - anchors.margins + // radius: Tokens.rounding.extraLarge - anchors.margins + anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins) - Flickable { - id: view + Tabs { + id: tabs - readonly property int currentIndex: root.screenState.dashboardTab - readonly property Item currentItem: { - repeater.count; - return repeater.itemAt(currentIndex); - } + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right - anchors.fill: parent + nonAnimWidth: view.implicitWidth + screenState: root.screenState + tabs: root.dashboardTabs + } - implicitWidth: currentItem?.implicitWidth ?? 0 - implicitHeight: currentItem?.implicitHeight ?? 0 + Item { + id: viewWrapper - contentX: currentItem?.x ?? 0 - contentWidth: row.implicitWidth - contentHeight: row.implicitHeight + anchors.top: tabs.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.topMargin: Tokens.spacing.small - interactive: false + Flickable { + id: view - onContentXChanged: { - if (!moving || !currentItem) - return; + readonly property int currentIndex: root.screenState.dashboardTab + readonly property Item currentItem: { + repeater.count; + return repeater.itemAt(currentIndex); + } - const x = contentX - currentItem.x; - if (x > currentItem.implicitWidth / 2) - root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1); - else if (x < -currentItem.implicitWidth / 2) - root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); - } + anchors.fill: parent - RowLayout { - id: row + implicitWidth: currentItem?.implicitWidth ?? 0 + implicitHeight: currentItem?.implicitHeight ?? 0 - Repeater { - id: repeater + contentX: currentItem?.x ?? 0 + contentWidth: row.implicitWidth + contentHeight: row.implicitHeight - model: ScriptModel { - values: root.dashboardTabs - } + interactive: false - delegate: Loader { - id: paneLoader + onContentXChanged: { + if (!moving || !currentItem) + return; - required property int index - required property var modelData + const x = contentX - currentItem.x; + if (x > currentItem.implicitWidth / 2) + root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1); + else if (x < -currentItem.implicitWidth / 2) + root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); + } - Layout.alignment: Qt.AlignTop + RowLayout { + id: row - sourceComponent: modelData.component + Repeater { + id: repeater - Component.onCompleted: active = Qt.binding(() => { - if (index === view.currentIndex) - return true; - const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); - const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); - return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); - }) + model: ScriptModel { + values: root.dashboardTabs + } + + delegate: Loader { + id: paneLoader + + required property int index + required property var modelData + + Layout.alignment: Qt.AlignTop + + sourceComponent: modelData.component + + Component.onCompleted: active = Qt.binding(() => { + if (index === view.currentIndex) + return true; + const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); + const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); + return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); + }) + } } } - } - Component { - id: dashComponent + Component { + id: dashComponent - Dash { - screenState: root.screenState + Dash { + screenState: root.screenState + } } - } - Component { - id: weatherComponent + Component { + id: weatherComponent - WeatherTab {} - } - - Component { - id: mediaComponent - - Media { - screenState: root.screenState + WeatherTab {} } - } - // Component { - // id: testingComponent - // - // WeatherTest {} - // } + Component { + id: mediaComponent - Behavior on contentX { - Anim { - type: Anim.DefaultEffects + Media { + screenState: root.screenState + } + } + + Component { + id: procsComponent + + ProcessesTab {} + } + + Behavior on contentX { + Anim { + type: Anim.DefaultEffects + } } } } diff --git a/Modules/Dashboard/Processes/ProcessesColumns.qml b/Modules/Dashboard/Processes/ProcessesColumns.qml new file mode 100644 index 0000000..40b9694 --- /dev/null +++ b/Modules/Dashboard/Processes/ProcessesColumns.qml @@ -0,0 +1,209 @@ +pragma Singleton + +import QtQuick +import Quickshell +import ZShell.Config + +Singleton { + id: root + + readonly property var columnDefs: [ + { + id: "command", + title: qsTr("Command"), + width: 200, + field: 3 + }, + { + id: "pid", + title: qsTr("PID"), + width: 60, + field: 1 + }, + { + id: "cpuUsage", + title: qsTr("CPU %"), + width: 70, + field: 7 + }, + { + id: "memoryUsage", + title: qsTr("Memory"), + width: 90, + field: 8 + }, + { + id: "elapsed", + title: qsTr("Elapsed"), + width: 80, + field: 9 + }, + { + id: "ppid", + title: qsTr("PPID"), + width: 60, + field: 2 + }, + { + id: "user", + title: qsTr("User"), + width: 100, + field: 11 + }, + { + id: "uid", + title: qsTr("UID"), + width: 60, + field: 10 + }, + { + id: "state", + title: qsTr("State"), + width: 60, + field: 5 + }, + { + id: "sleeping", + title: qsTr("Sleeping"), + width: 80, + field: 6 + }, + { + id: "commandLine", + title: qsTr("Command line"), + width: 240, + field: 4 + }, + { + id: "ioReadBytes", + title: qsTr("IO read"), + width: 90, + field: 12 + }, + { + id: "ioWriteBytes", + title: qsTr("IO write"), + width: 90, + field: 13 + } + ] + + readonly property string pinnedColumnId: "command" + + readonly property var hiddenColumns: { + const visible = Config.dashboard.processColumns.values.map(e => e.id); + return root.columnDefs.filter(d => !visible.includes(d.id)); + } + + function pinnedIndex(): int { + const values = Config.dashboard.processColumns.values; + for (let i = 0; i < values.length; i++) + if (values[i].id === root.pinnedColumnId) + return i; + return -1; + } + + function ensurePinnedFirst() { + const i = root.pinnedIndex(); + if (i > 0) + Qt.callLater(() => { + const idx = root.pinnedIndex(); + if (idx > 0) + Config.dashboard.processColumns.move(idx, 0); + }); + } + + Component.onCompleted: Qt.callLater(ensurePinnedFirst) + + function defFor(id: string): var { + for (const def of root.columnDefs) + if (def.id === id) + return def; + return null; + } + + function addColumn(id: string) { + const def = root.defFor(id); + Config.dashboard.processColumns.insert({ + id: id, + width: def ? def.width : 100 + }); + } + + function removeColumn(index: int) { + const entry = Config.dashboard.processColumns.at(index); + if (entry && entry.id === root.pinnedColumnId) + return; + Config.dashboard.processColumns.remove(index); + } + + function moveColumns(from: int, to: int) { + const list = Config.dashboard.processColumns; + const entry = list.at(from); + if (!entry) + return; + if (entry.id === root.pinnedColumnId) + return; + if (to === 0 && list.at(0)?.id === root.pinnedColumnId) + return; + + list.move(from, to); + + const i = root.pinnedIndex(); + if (i > 0) + list.move(i, 0); + } + + function resizeColumn(index: int, width: real) { + const entry = Config.dashboard.processColumns.at(index); + if (entry) + entry.width = Math.max(50, width); + } + + function dampOvershoot(overshoot: real, maxOvershoot: real): real { + return overshoot / (1 + overshoot / maxOvershoot); + } + + function formatCell(id: string, value: var): string { + switch (id) { + case "cpuUsage": + return value.toFixed(1) + "%"; + case "memoryUsage": + return root.formatBytes(value * 1024); + case "ioReadBytes": + case "ioWriteBytes": + return root.formatBytes(value); + case "elapsed": + return root.formatElapsed(value); + case "sleeping": + return value ? qsTr("Yes") : qsTr("No"); + default: + return String(value); + } + } + + function formatBytes(bytes: var): string { + if (bytes === undefined || bytes === null || bytes <= 0) + return "0 B"; + const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + let v = Number(bytes); + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + ++i; + } + return (i === 0 ? v.toFixed(0) : v.toFixed(1)) + " " + units[i]; + } + + function formatElapsed(secs: var): string { + const s = Math.max(0, Math.floor(Number(secs))); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const ss = s % 60; + if (h > 0) + return h + "h " + m + "m"; + if (m > 0) + return m + "m " + ss + "s"; + return ss + "s"; + } +} diff --git a/Modules/Dashboard/Processes/ProcessesHeader.qml b/Modules/Dashboard/Processes/ProcessesHeader.qml new file mode 100644 index 0000000..83225dd --- /dev/null +++ b/Modules/Dashboard/Processes/ProcessesHeader.qml @@ -0,0 +1,712 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import ZShell.Config +import ZShell.Internal +import ZShell.Services +import qs.Components +import qs.Services + +Item { + id: root + + required property int headerHeight + required property ProcessesList pList + + readonly property alias columnList: columnList + readonly property alias columnsModel: columnsData + readonly property bool anyHeld: columnList.contentItem.children.some(c => c.held) + + z: 1 + implicitHeight: headerHeight + + function widthForColumn(id: string, configured: real): real { + for (const item of columnList.contentItem.children) { + if (item.modelData !== undefined && item.modelData.id === id) + return item.liveWidth > 0 ? item.liveWidth : (configured > 0 ? configured : (ProcessesColumns.defFor(id)?.width ?? 100)); + } + return configured > 0 ? configured : (ProcessesColumns.defFor(id)?.width ?? 100); + } + + function fillMissingWidths() { + const n = columnsData.values.length; + if (n === 0 || columnList.width <= 0) + return; + + const spacingTotal = columnList.spacing * Math.max(0, n - 1); + + let savedTotal = 0; + const unsavedIndices = []; + for (let i = 0; i < n; i++) { + const w = columnsData.values[i].width; + if (w > 0) + savedTotal += w; + else + unsavedIndices.push(i); + } + + if (unsavedIndices.length === 0) + return; + + const available = columnList.width - spacingTotal - savedTotal; + if (available <= 0) + return; + + const each = Math.max(50, available / unsavedIndices.length); + for (const idx of unsavedIndices) + ProcessesColumns.resizeColumn(idx, each); + } + + Connections { + target: columnList + function onWidthChanged() { + root.fillMissingWidths(); + } + } + + Connections { + target: columnsData + function onValuesChanged() { + root.fillMissingWidths(); + } + } + + Component.onCompleted: Qt.callLater(fillMissingWidths) + Component.onDestruction: Cursor.clearOverride() + + RowLayout { + id: rowLayout + + anchors.fill: parent + + spacing: Tokens.spacing.extraSmall / 2 + + CustomListView { + id: columnList + + Layout.fillWidth: true + orientation: ListView.Horizontal + interactive: false + spacing: parent.spacing + implicitHeight: root.headerHeight + + add: Transition { + Anim { + from: 0 + properties: "opacity" + to: 1 + type: Anim.DefaultEffects + } + } + displaced: Transition { + Anim { + properties: "opacity" + to: 1 + type: Anim.DefaultEffects + } + Anim { + properties: "x" + } + } + move: Transition { + Anim { + properties: "opacity" + to: 1 + type: Anim.DefaultEffects + } + Anim { + properties: "x" + } + } + remove: Transition { + Anim { + to: 0 + properties: "opacity" + type: Anim.DefaultEffects + } + } + + model: DelegateModel { + id: columnsVisual + + delegate: ColumnHeaderCell { + parentModel: columnsVisual + } + + model: ScriptModel { + id: columnsData + values: Config.dashboard.processColumns.values + } + } + } + + IconButton { + id: addColumnBtn + + type: IconButton.Text + icon: "add" + font: Tokens.font.icon.medium + Layout.alignment: Qt.AlignVCenter + visible: ProcessesColumns.hiddenColumns.length > 0 + onClicked: addMenu.expanded = true + } + + Variants { + id: hiddenColumnList + + model: ProcessesColumns.hiddenColumns + + HiddenColumnItem {} + } + + Menu { + id: addMenu + + attachTo: addColumnBtn + items: hiddenColumnList.instances + onItemSelected: item => ProcessesColumns.addColumn(item.modelData.id) + } + } + + Item { + id: resizeOverlay + + property real gapBoost: pressed ? Tokens.spacing.small : Tokens.spacing.extraSmall + property bool pressed: false + property int geometryTick: 0 + property int activeGapIndex: -1 + + anchors.fill: rowLayout + z: 10 + + Connections { + target: columnsData + function onValuesChanged() { + resizeOverlay.geometryTick++; + } + } + + Connections { + target: columnList + function onCountChanged() { + resizeOverlay.geometryTick++; + } + function onContentWidthChanged() { + resizeOverlay.geometryTick++; + } + } + + Component.onCompleted: Qt.callLater(() => geometryTick++) + + Repeater { + model: Math.max(0, columnsData.values.length - 1) + + MouseArea { + id: handleRoot + + required property int index + + readonly property bool hovered: containsMouse + + property ColumnHeaderCell leftCell: { + const left = leftCell; + if (resizing && left) + return left; + const tick = resizeOverlay.geometryTick; + return resolveCell(true); + } + + property ColumnHeaderCell rightCell: { + const right = rightCell; + if (resizing && right) + return right; + const tick = resizeOverlay.geometryTick; + return resolveCell(false); + } + + property bool resizing: false + property real originX: 0 + property real originWidth: 0 + property real originRightWidth: 0 + property real originAbsX: 0 + + function resolveCell(left: bool): ColumnHeaderCell { + if (left) { // qmllint disable incompatible-type + const leftItem = columnList.itemAtIndex(index); + if (leftItem) + return leftItem; + else + return null; + } else { + const rightItem = columnList.itemAtIndex(index + 1); + if (rightItem) + return rightItem; + else + return null; + } + } + + cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor + preventStealing: true + hoverEnabled: true + enabled: !root.anyHeld + + x: { + if (!leftCell) + return 0; + const gapCenter = leftCell.x + leftCell.width + columnList.spacing / 2; + return gapCenter - width / 2; + } + y: 0 + implicitWidth: 16 + implicitHeight: columnList.height + + function pairAvailableWidth(rightCell) { + let othersWidth = 0; + for (const item of columnList.contentItem.children) { + if (item.modelData === undefined || item === handleRoot.leftCell || item === rightCell) + continue; + othersWidth += item.width; + } + const spacingTotal = columnList.spacing * Math.max(0, columnsData.values.length - 1); + return columnList.width - othersWidth - spacingTotal; + } + + CustomRect { + id: handle + + readonly property bool shouldBeVisible: handleRoot.hovered || handleRoot.pressed + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + + implicitWidth: shouldBeVisible ? resizeOverlay.gapBoost : 0 + color: Colors.palette.m3secondary + radius: Tokens.rounding.full + opacity: shouldBeVisible ? 1.0 : 0 + + Behavior on implicitWidth { + Anim { + type: Anim.FastSpatial + } + } + + Behavior on opacity { + Anim {} + } + + transform: Scale { + origin { + x: handle.implicitWidth / 2 + y: handle.height / 2 + } + + yScale: handle.shouldBeVisible ? 1 : 0.5 + xScale: handle.shouldBeVisible ? 1 : 0.5 + + Behavior on xScale { + Anim {} + } + + Behavior on yScale { + Anim {} + } + } + } + + onHoveredChanged: resizeOverlay.activeGapIndex = hovered || pressed ? handleRoot.index : (resizeOverlay.activeGapIndex === handleRoot.index ? -1 : resizeOverlay.activeGapIndex) + + onPressedChanged: { + resizeOverlay.pressed = pressed; + if (!pressed) + resizeOverlay.activeGapIndex = hovered ? handleRoot.index : resizeOverlay.activeGapIndex === handleRoot.index ? -1 : resizeOverlay.activeGapIndex; + } + + onPressed: e => { + if (!handleRoot.leftCell) + return; + + Cursor.setOverride(Qt.ClosedHandCursor); + resizing = true; + originAbsX = handleRoot.mapToItem(resizeOverlay, e.x, 0).x; + originWidth = handleRoot.leftCell.implicitWidth; + originRightWidth = handleRoot.rightCell ? handleRoot.rightCell.implicitWidth : 0; + } + + onPositionChanged: e => { + if (!resizing || !handleRoot.leftCell) + return; + + const nowAbsX = handleRoot.mapToItem(resizeOverlay, e.x, 0).x; + let newLeftWidth = Math.max(50, originWidth + (nowAbsX - originAbsX)); + + const rightCell = handleRoot.rightCell; + let roundedLeft = Math.round(newLeftWidth); + + if (rightCell) { + const available = Math.round(handleRoot.pairAvailableWidth(rightCell)); + const maxLeftWidth = Math.max(50, available - 50); + roundedLeft = Math.min(roundedLeft, maxLeftWidth); + rightCell.liveWidth = Math.max(50, available - roundedLeft); + } + + handleRoot.leftCell.liveWidth = roundedLeft; + } + + onReleased: { + Cursor.clearOverride(); + resizing = false; + const cell = handleRoot.leftCell; + const rightCell = handleRoot.rightCell; + + if (cell.liveWidth !== 0) + ProcessesColumns.resizeColumn(cell.DelegateModel.itemsIndex, cell.liveWidth); + if (rightCell.liveWidth !== 0) + ProcessesColumns.resizeColumn(rightCell.DelegateModel.itemsIndex, rightCell.liveWidth); + if (cell || rightCell) { + cell.liveWidth = 0; + rightCell.liveWidth = 0; + } + resizeOverlay.geometryTick++; + } + } + } + } + + component ColumnHeaderCell: Item { + id: cell + + required property var modelData + required property int index + required property DelegateModel parentModel + + property int leftRadius: pushSide !== 1 && DelegateModel.itemsIndex === 0 ? cell.implicitHeight / 2 : Tokens.rounding.extraSmall + property int rightRadius: pushSide !== -1 && DelegateModel.itemsIndex === parentModel.count - 1 ? cell.implicitHeight / 2 : Tokens.rounding.extraSmall + property real radiusLerpProg + property bool held + property bool moved: false + property int pressIndex: -1 + property point pressPos + property real lastMoveX: 0 + property real liveWidth: 0 + + readonly property bool isActiveSort: Processes.sortField === (ProcessesColumns.defFor(cell.modelData.id)?.field ?? 0) + readonly property int pushSide: { + const gapIdx = resizeOverlay.activeGapIndex; + if (gapIdx < 0) + return 0; + const myIdx = cell.DelegateModel.itemsIndex; + if (myIdx === gapIdx) + return -1; + if (myIdx === gapIdx + 1) + return 1; + return 0; + } + property real gapBoost: resizeOverlay.gapBoost + readonly property real gapShrink: gapBoost / 2 + + property real gapProg: pushSide !== 0 ? 1 : 0 + property real gapX: pushSide === 1 ? 1 : 0 + + function lerpRadius(a: real, b: real): real { + return a + (b - a) * radiusLerpProg; + } + + Behavior on gapBoost { + Anim { + type: Anim.FastSpatial + } + } + + Behavior on gapX { + Anim { + type: Anim.FastSpatial + } + } + + Behavior on gapProg { + Anim { + type: Anim.FastSpatial + } + } + + implicitHeight: root.headerHeight + implicitWidth: liveWidth > 0 ? liveWidth : (modelData.width > 0 ? modelData.width : (ProcessesColumns.defFor(modelData.id)?.width ?? 100)) + z: held || returnAnim.running ? 1 : 0 + + states: State { + name: "held" + + PropertyChanges { + cellBg.color: Colors.palette.m3primaryContainer + elevation.opacity: 1 + placeholder.opacity: 0.1 + cell.radiusLerpProg: 1 + title.color: Colors.palette.m3onPrimaryContainer + removeBtn.inactiveOnColor: Colors.palette.m3onPrimaryContainer + } + } + + Behavior on leftRadius { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on rightRadius { + Anim { + type: Anim.DefaultEffects + } + } + transitions: Transition { + Anim { + properties: "opacity,radiusLerpProg" + type: Anim.SlowEffects + } + + PropertyAction { + properties: "color,inactiveOnColor" + } + } + state: held ? "held" : "" + + CustomRect { + id: placeholder + + anchors.fill: parent + color: Colors.palette.m3primary + opacity: 0 + topLeftRadius: cell.leftRadius + bottomLeftRadius: cell.leftRadius + topRightRadius: cell.rightRadius + bottomRightRadius: cell.rightRadius + } + + DragHandler { + id: dragHandler + + property real centerOffset: 0 + + enabled: cell.modelData.id !== ProcessesColumns.pinnedColumnId + target: null + xAxis.enabled: true + dragThreshold: 10 + grabPermissions: PointerHandler.CanTakeOverFromItems + + onActiveChanged: { + if (active) { + Cursor.setOverride(Qt.ClosedHandCursor); + returnAnim.stop(); + + cell.pressIndex = cell.DelegateModel.itemsIndex; + cell.lastMoveX = cell.x; + cell.moved = true; + cell.held = true; + + const p = centroid.position; + cell.pressPos = Qt.point(p.x, p.y); + dragHandler.centerOffset = p.x - cell.width / 2; + + cellContent.x = Qt.binding(() => { + const maxOvershoot = Tokens.padding.extraLarge; + const xDiff = cell.x - cell.lastMoveX; + const x = dragHandler.centroid.position.x - cell.pressPos.x - xDiff; + const absX = cell.mapToItem(columnList.contentItem, x, 0).x; + const maxX = columnList.contentWidth - cell.width; + + if (absX < 0) + return x - absX - ProcessesColumns.dampOvershoot(-absX, maxOvershoot); + + if (absX > maxX) + return x - absX + maxX + ProcessesColumns.dampOvershoot(absX - maxX, maxOvershoot); + + return x; + }); + + cellContent.y = Qt.binding(() => { + const maxOvershoot = Tokens.padding.extraLargeIncreased; + const y = dragHandler.centroid.position.y - cell.pressPos.y; + + return ProcessesColumns.dampOvershoot(Math.abs(y), maxOvershoot) * Math.sign(y); + }); + } else { + Cursor.clearOverride(); + if (cell.held) { + cellContent.x = cellContent.x; + cellContent.y = cellContent.y; + + cell.held = false; + returnAnim.start(); + + if (cell.DelegateModel.itemsIndex !== cell.pressIndex) { + ProcessesColumns.moveColumns(cell.pressIndex, cell.DelegateModel.itemsIndex); + } + } + } + } + + onCentroidChanged: { + if (!active) + return; + + const p = centroid.position; + cell.lastMoveX = cell.x; + + const cursorAbsX = cell.mapToItem(columnList.contentItem, p.x, 0).x; + + const centerAbsX = cursorAbsX - dragHandler.centerOffset; + + const movingRight = p.x > cell.pressPos.x; + const grabbedRight = dragHandler.centerOffset > 0; + + const useCursor = movingRight === grabbedRight; + const refX = useCursor ? cursorAbsX : centerAbsX; + + const myIndex = cell.DelegateModel.itemsIndex; + + const rightItem = columnList.itemAtIndex(myIndex + 1); + if (rightItem) { + const rightMid = rightItem.x + rightItem.width / 2; + if (refX > rightMid) { + columnsVisual.items.move(myIndex, myIndex + 1); + resizeOverlay.geometryTick++; + Qt.callLater(() => resizeOverlay.geometryTick++); + return; + } + } + + const leftItem = columnList.itemAtIndex(myIndex - 1); + if (leftItem) { + const leftMid = leftItem.x + leftItem.width / 2; + const pinnedAtZero = columnList.itemAtIndex(0)?.modelData?.id === ProcessesColumns.pinnedColumnId; + if (refX < leftMid && !(myIndex === 1 && pinnedAtZero)) { + columnsVisual.items.move(myIndex, myIndex - 1); + resizeOverlay.geometryTick++; + Qt.callLater(() => resizeOverlay.geometryTick++); + } + } + } + } + + Anim { + id: returnAnim + + properties: "x,y" + target: cellContent + to: 0 + } + + Item { + id: cellVisual + + x: cell.gapShrink * cell.gapX + y: 0 + width: cell.width - cell.gapShrink * cell.gapProg + height: cell.height + + Item { + id: cellContent + + width: cellVisual.width + height: cellVisual.height + + readonly property int topLeftRadius: cell.lerpRadius(cell.leftRadius, cell.implicitHeight / 2) + readonly property int topRightRadius: cell.lerpRadius(cell.rightRadius, cell.implicitHeight / 2) + readonly property int bottomLeftRadius: cell.lerpRadius(cell.leftRadius, cell.implicitHeight / 2) + readonly property int bottomRightRadius: cell.lerpRadius(cell.rightRadius, cell.implicitHeight / 2) + + Elevation { + id: elevation + + anchors.fill: parent + level: 3 + opacity: 0 + topLeftRadius: parent.topLeftRadius + topRightRadius: parent.topRightRadius + bottomLeftRadius: parent.bottomLeftRadius + bottomRightRadius: parent.bottomRightRadius + } + + CustomRect { + id: cellBg + + anchors.fill: parent + color: cell.isActiveSort ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer + + topLeftRadius: cell.lerpRadius(cell.leftRadius, cell.implicitHeight / 2) + topRightRadius: cell.lerpRadius(cell.rightRadius, cell.implicitHeight / 2) + bottomLeftRadius: cell.lerpRadius(cell.leftRadius, cell.implicitHeight / 2) + bottomRightRadius: cell.lerpRadius(cell.rightRadius, cell.implicitHeight / 2) + + StateLayer { + id: layer + + color: cell.isActiveSort ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + manualHoverOverride: cell.held + + onClicked: { + Processes.sortBy(ProcessesColumns.defFor(cell.modelData.id)?.field ?? 0); + root.pList.positionViewAtBeginning(); + } + } + } + + Item { + anchors.fill: parent + anchors.leftMargin: Tokens.padding.medium + anchors.rightMargin: 12 + + CustomText { + id: title + anchors.left: parent.left + anchors.right: removeBtn.visible ? removeBtn.left : parent.right + anchors.rightMargin: Tokens.spacing.extraSmall + anchors.verticalCenter: parent.verticalCenter + color: cell.isActiveSort ? Colors.palette.m3onPrimary : Colors.palette.m3onSurfaceVariant + elide: CustomText.ElideRight + text: ProcessesColumns.defFor(cell.modelData.id)?.title ?? cell.modelData.id + } + + IconButton { + id: removeBtn + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + type: IconButton.Text + icon: "close" + font: Tokens.font.icon.small + inactiveOnColor: Colors.palette.m3onPrimary + visible: cell.modelData.id !== ProcessesColumns.pinnedColumnId + onClicked: ProcessesColumns.removeColumn(cell.DelegateModel.itemsIndex) + } + + MaterialIcon { + anchors.right: removeBtn.visible ? removeBtn.left : parent.right + anchors.verticalCenter: parent.verticalCenter + + text: "keyboard_arrow_down" + opacity: cell.isActiveSort ? 1 : 0 + visible: opacity > 0 + rotation: !Processes.sortAscending && opacity > 0 ? 180 : 0 + color: Colors.palette.m3onPrimary + + Behavior on rotation { + Anim {} + } + + Behavior on opacity { + Anim {} + } + } + } + } + } + } + + component HiddenColumnItem: MenuItem { + required property var modelData + + text: modelData.title + } +} diff --git a/Modules/Dashboard/Processes/ProcessesList.qml b/Modules/Dashboard/Processes/ProcessesList.qml new file mode 100644 index 0000000..8f368db --- /dev/null +++ b/Modules/Dashboard/Processes/ProcessesList.qml @@ -0,0 +1,140 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Services + +CustomListView { + id: root + + required property ProcessesHeader columnHeader + required property int rowHeight + property real rowSpacing: Tokens.spacing.small + + model: Processes.list + spacing: root.rowSpacing + clip: true + + CustomScrollBar.vertical: CustomScrollBar { + flickable: root + } + + delegate: CustomRect { + id: processRow + + required property var modelData + required property int index + + implicitWidth: ListView.view.width + implicitHeight: rowLayout.implicitHeight + radius: Tokens.rounding.full + + StateLayer { + id: rowHover + } + + RowLayout { + id: rowLayout + + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + spacing: Tokens.spacing.extraSmall / 2 + + CustomClippingRect { + id: iconClip + + Layout.fillHeight: true + Layout.margins: Tokens.padding.medium + implicitWidth: height + radius: Tokens.rounding.full + + IconImage { + id: appIcon + anchors.fill: parent + implicitSize: height + source: Quickshell.iconPath(processRow.modelData.iconName, "application-x-executable") + } + } + + Repeater { + id: cellRepeater + + model: root.columnHeader.columnsModel + + Item { + id: rowCell + + required property var modelData + required property int index + + implicitWidth: root.columnHeader.widthForColumn(modelData.id, modelData.width) - (index === 0 ? iconClip.implicitWidth + iconClip.Layout.margins * 2 + rowLayout.spacing : 0) + implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 + Layout.preferredWidth: implicitWidth + Layout.fillHeight: true + + CustomRect { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: Tokens.padding.small + anchors.leftMargin: -(rowLayout.spacing) + + implicitWidth: 2 + color: Colors.palette.m3outlineVariant + visible: rowCell.index !== 0 + } + + Item { + id: layout + anchors.fill: parent + anchors.margins: Tokens.padding.small + anchors.leftMargin: rowCell.index !== 0 ? Tokens.padding.medium : 0 + anchors.rightMargin: Tokens.padding.medium + implicitHeight: mainText.implicitHeight + subText.implicitHeight + mainText.anchors.margins * 2 + subText.anchors.topMargin + + CustomText { + id: mainText + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + elide: CustomText.ElideRight + text: { + const value = processRow.modelData[rowCell.modelData.id]; + return value === undefined ? "" : ProcessesColumns.formatCell(rowCell.modelData.id, value); + } + + states: State { + name: "centerAnchored" + when: !subText.visible + + AnchorChanges { + target: mainText + anchors.top: undefined + anchors.verticalCenter: layout.verticalCenter + } + } + } + + CustomText { + id: subText + anchors.top: mainText.bottom + anchors.left: parent.left + anchors.right: parent.right + visible: rowCell.modelData.id === "command" + elide: CustomText.ElideRight + text: rowCell.modelData.id === "command" ? processRow.modelData["commandLine"] : "" + font: Tokens.font.label.small + color: Colors.palette.m3outline + } + } + } + } + } + } +} diff --git a/Modules/Dashboard/Processes/SystemInfo.qml b/Modules/Dashboard/Processes/SystemInfo.qml new file mode 100644 index 0000000..3346b56 --- /dev/null +++ b/Modules/Dashboard/Processes/SystemInfo.qml @@ -0,0 +1,186 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import ZShell.Components +import ZShell.Services +import qs.Components +import qs.Services +import qs.Helpers + +Item { + id: root + + implicitHeight: layout.implicitHeight + + ServiceRef { + service: Cpu + } + + ServiceRef { + service: Memory + } + + RowLayout { + id: layout + anchors.fill: parent + spacing: Tokens.spacing.medium + + UsageCard { + accent: Colors.palette.m3primary + buffer: ResourceUsage.cpuBuffer + icon: "memory" + label: qsTr("CPU") + values: ResourceUsage.cpuInfo + } + + UsageCard { + accent: Colors.palette.m3tertiary + buffer: ResourceUsage.memoryBuffer + icon: "memory_alt" + label: qsTr("Memory") + values: ResourceUsage.memoryInfo + } + } + + component UsageCard: CustomRect { + id: card + + property color accent + required property var buffer + property string icon + property string label + property list values + + color: Colors.tPalette.m3surfaceContainer + radius: Tokens.rounding.largeIncreased + Layout.fillWidth: true + Layout.fillHeight: true + implicitHeight: mainLayout.implicitHeight + mainLayout.anchors.margins * 2 + + ColumnLayout { + id: mainLayout + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: 0 + + RowLayout { + spacing: Tokens.spacing.small + + MaterialIcon { + color: card.accent + text: card.icon + } + + CustomText { + text: card.label + } + } + + Item { + id: graphWrapper + Layout.fillHeight: true + Layout.fillWidth: true + implicitHeight: infoColumn.implicitHeight + + Item { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: infoColumn.left + + SparklineItem { + id: sparkline + + anchors.fill: parent + historyLength: ResourceUsage.historyLength + line1: card.buffer + line1Color: card.accent + line1FillAlpha: 0.15 + maxValue: 1.0 + slideProgress: 1 + + Connections { + function onValuesChanged(): void { + slideAnim.restart(); + } + + target: card.buffer + } + + NumberAnimation { + id: slideAnim + + duration: Config.dashboard.resourceUpdateInterval + easing.type: Easing.Linear + from: 0 + property: "slideProgress" + target: sparkline + to: 1 + } + } + + // "Collecting data" placeholder + CustomText { + anchors.centerIn: parent + color: Colors.palette.m3outline + text: qsTr("Collecting data...") + visible: card.buffer.count < 2 + } + } + + ColumnLayout { + id: infoColumn + anchors.right: parent.right + anchors.bottom: parent.bottom + width: { + const prefWidth = graphWrapper.width / 3; + const minWidth = graphWrapper.width / 4; + if (prefWidth < implicitWidth) + return implicitWidth; + else if (prefWidth >= implicitWidth * 2) + return minWidth; + else + return prefWidth; + } + + Repeater { + model: card.values + + Item { + id: moreInfo + + required property var modelData + + Layout.leftMargin: Tokens.spacing.medium + Layout.fillWidth: true + implicitWidth: label.implicitWidth + value.implicitWidth + label.anchors.margins * 2 + implicitHeight: label.implicitHeight + + CustomText { + id: label + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + + color: Colors.palette.m3onSurfaceVariant + text: moreInfo.modelData.label + } + + CustomText { + id: value + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + color: card.accent + text: moreInfo.modelData.value + } + } + } + } + } + } + } +} diff --git a/Modules/Dashboard/ProcessesTab.qml b/Modules/Dashboard/ProcessesTab.qml new file mode 100644 index 0000000..76bc140 --- /dev/null +++ b/Modules/Dashboard/ProcessesTab.qml @@ -0,0 +1,64 @@ +import QtQuick +import Quickshell +import ZShell.Config +import ZShell.Services +import qs.Modules.Dashboard.Processes +import qs.Components +import qs.Services + +Item { + id: root + + readonly property var desktopEntries: DesktopEntries.applications.values.map(e => ({ + icon: e.icon, + command: e.command + })) + + implicitWidth: Tokens.sizes.dashboard.processesWidth + implicitHeight: Tokens.sizes.dashboard.processesHeight + + ServiceRef { + service: Processes + } + + Connections { + target: DesktopEntries.applications + function onValuesChanged(): void { + Processes.setDesktopEntries(root.desktopEntries); + } + } + Component.onCompleted: Processes.setDesktopEntries(root.desktopEntries) + + ProcessesHeader { + id: header + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + headerHeight: Tokens.sizes.dashboard.processesHeaderHeight + pList: processList + } + + ProcessesList { + id: processList + + anchors.top: header.bottom + anchors.bottom: sysInfo.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Tokens.spacing.small + anchors.bottomMargin: Tokens.spacing.small + + columnHeader: header + rowHeight: Tokens.sizes.dashboard.processesRowHeight + } + + SystemInfo { + id: sysInfo + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + } +} diff --git a/Modules/Resources/Cards/NetworkCard.qml b/Modules/Resources/Cards/NetworkCard.qml index fd4b3dc..2184b53 100644 --- a/Modules/Resources/Cards/NetworkCard.qml +++ b/Modules/Resources/Cards/NetworkCard.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Layouts -import ZShell.Internal +import ZShell.Components import ZShell.Config import qs.Helpers import qs.Components @@ -66,8 +66,7 @@ CustomRect { Behavior on smoothMax { enabled: sparkline.initialized - Anim { - } + Anim {} } Component.onCompleted: { diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index 85f7df0..0a6d627 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -73,6 +73,17 @@ qml_module(ZShell target_compile_definitions(ZShell PRIVATE ZSHELL_VERSION="${VERSION}") +# Node macros expand to Q_OBJECT, automoc can't see it otherwise +list(APPEND CMAKE_AUTOMOC_MACRO_NAMES + CONFIG_NODE + CONFIG_NODE_NO_CTOR + CONFIG_LIST_TYPE + SETTINGS_NODE + SETTINGS_NODE_NO_CTOR + SETTINGS_LIST_TYPE +) + +add_subdirectory(Settings) add_subdirectory(Models) add_subdirectory(Internal) add_subdirectory(Services) diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index 3d3e4a7..abc5960 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -5,6 +5,12 @@ qml_module(ZShell-components wavyline.hpp wavyline.cpp buttonrow.hpp buttonrow.cpp carouselview.hpp carouselview.cpp - LIBRARIES - Qt::Quick + wheelinverter.hpp + sparklinecontent.hpp sparklinecontent.cpp + sparklineitem.hpp sparklineitem.cpp + sparklinesceneitem.hpp sparklinesceneitem.cpp + LIBRARIES + Qt::Quick + Qt::QuickPrivate + ZShell-internal ) diff --git a/Plugins/ZShell/Components/sparklinecontent.cpp b/Plugins/ZShell/Components/sparklinecontent.cpp new file mode 100644 index 0000000..8d51704 --- /dev/null +++ b/Plugins/ZShell/Components/sparklinecontent.cpp @@ -0,0 +1,250 @@ +#include "sparklinecontent.hpp" + +#include +#include +#include + +namespace ZShell::components { + +SparklineContent::SparklineContent(QQuickItem* parent) + : QQuickPaintedItem(parent) { + setAntialiasing(true); +} + +qreal SparklineContent::stepX() const { + if (m_historyLength < 2 || m_viewportW <= 0.0) return 0.0; + if (m_sliding) return m_viewportW / static_cast(m_historyLength - 1); + + const int len = qMax( + 2, + (m_line1 && m_line1->count() >= 2) ? m_line1->count() + : (m_line2 ? m_line2->count() : 2)); + return m_viewportW / static_cast(len - 1); +} + +void SparklineContent::setViewportSize( + qreal viewportWidth, qreal viewportHeight) { + if (qFuzzyCompare(m_viewportW, viewportWidth) && + qFuzzyCompare(m_viewportH, viewportHeight)) + return; + m_viewportW = viewportWidth; + m_viewportH = viewportHeight; + invalidate(); +} + +void SparklineContent::setLine1(internal::CircularBuffer* buffer) { + if (m_line1 == buffer) return; + if (m_line1) disconnect(m_line1, nullptr, this, nullptr); + m_line1 = buffer; + connectBuffer(buffer); + invalidate(); +} + +void SparklineContent::setLine2(internal::CircularBuffer* buffer) { + if (m_line2 == buffer) return; + if (m_line2) disconnect(m_line2, nullptr, this, nullptr); + m_line2 = buffer; + connectBuffer(buffer); + invalidate(); +} + +void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) { + if (!buffer) return; + // New data, or the buffer's own smoothingRadius changed (it re-emits + // valuesChanged in that case too) -> geometry is stale. + connect( + buffer, + &internal::CircularBuffer::valuesChanged, + this, + &SparklineContent::invalidate); + connect(buffer, &QObject::destroyed, this, [this, buffer]() { + if (m_line1 == buffer) m_line1 = nullptr; + if (m_line2 == buffer) m_line2 = nullptr; + invalidate(); + }); +} + +void SparklineContent::setLine1Color(const QColor& color) { + if (m_line1Color == color) return; + m_line1Color = color; + update(); // visual-only, no geometry rebuild needed +} + +void SparklineContent::setLine2Color(const QColor& color) { + if (m_line2Color == color) return; + m_line2Color = color; + update(); +} + +void SparklineContent::setLine1FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line1FillAlpha, alpha)) return; + m_line1FillAlpha = alpha; + update(); +} + +void SparklineContent::setLine2FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line2FillAlpha, alpha)) return; + m_line2FillAlpha = alpha; + update(); +} + +void SparklineContent::setMaxValue(qreal value) { + if (qFuzzyCompare(m_maxValue, value)) return; + m_maxValue = value; + invalidate(); +} + +void SparklineContent::setLineWidth(qreal width) { + if (qFuzzyCompare(m_lineWidth, width)) return; + m_lineWidth = width; + invalidate(); +} + +void SparklineContent::setCurveSmoothing(bool enabled) { + if (m_curveSmoothing == enabled) return; + m_curveSmoothing = enabled; + invalidate(); +} + +void SparklineContent::setHistoryLength(int length) { + if (m_historyLength == length) return; + m_historyLength = length; + invalidate(); +} + +void SparklineContent::setSliding(bool sliding) { + if (m_sliding == sliding) return; + m_sliding = sliding; + invalidate(); +} + +void SparklineContent::invalidate() { + m_dirty = true; + applyGeometry(); + update(); +} + +void SparklineContent::applyGeometry() { + const qreal pad = m_sliding ? stepX() : 0.0; + setSize(QSizeF(m_viewportW + pad, m_viewportH)); +} + +SparklineContent::LineGeometry SparklineContent::buildGeometry( + internal::CircularBuffer* buffer) const { + LineGeometry geom; + if (!buffer || buffer->count() < 2 || m_maxValue <= 0.0 || + m_viewportW <= 0.0) + return geom; + if (m_historyLength < 2) return geom; + + const int len = buffer->count(); + const qreal step = stepX(); + if (step <= 0.0) return geom; + + qreal startX = 0.0; + if (m_sliding) + startX = m_viewportW - (len - 1) * step + step; + else + startX = 0.0; + + const qreal h = m_viewportH; + const qreal strokePad = qCeil(m_lineWidth / 2); + const qreal curvePad = 3.0; + const qreal plotTop = strokePad + curvePad; + const qreal plotBottom = h - strokePad; + const qreal fillBottom = h; + const qreal plotH = qMax(1.0, plotBottom - plotTop); + + QVector points; + points.reserve(len); + + for (int i = 0; i < len; ++i) { + const qreal value = + qBound(0.0, buffer->smoothedAt(i), m_maxValue); + + const qreal x = startX + i * step; + const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH; + points.append(QPointF(x, y)); + } + + QPainterPath linePath; + linePath.moveTo(points[0]); + + if (m_curveSmoothing && points.size() > 2) { + const qreal tension = 1.0; + const qreal k = tension / 6.0; + const int n = points.size(); + + for (int i = 0; i < n - 1; ++i) { + const QPointF& p0 = points[qMax(i - 1, 0)]; + const QPointF& p1 = points[i]; + const QPointF& p2 = points[i + 1]; + const QPointF& p3 = points[qMin(i + 2, n - 1)]; + + QPointF c1 = p1 + (p2 - p0) * k; + QPointF c2 = p2 - (p3 - p1) * k; + + const qreal segMinY = qMin(p1.y(), p2.y()); + const qreal segMaxY = qMax(p1.y(), p2.y()); + c1.setY(qBound(segMinY, c1.y(), segMaxY)); + c2.setY(qBound(segMinY, c2.y(), segMaxY)); + + linePath.cubicTo(c1, c2, p2); + } + } else { + for (int i = 0; i < points.size() - 1; ++i) { + const QPointF& p0 = points[i]; + const QPointF& p1 = points[i + 1]; + const qreal ctrlX = (p0.x() + p1.x()) * 0.5; + linePath.cubicTo(QPointF(ctrlX, p0.y()), QPointF(ctrlX, p1.y()), p1); + } + } + + geom.stroke = linePath; + + QPainterPath fillPath = linePath; + fillPath.lineTo(startX + (len - 1) * step, fillBottom); + fillPath.lineTo(startX, fillBottom); + fillPath.closeSubpath(); + geom.fill = fillPath; + geom.hasData = true; + + return geom; +} + +void SparklineContent::rebuildIfNeeded() { + if (!m_dirty) return; + m_geom1 = buildGeometry(m_line1); + m_geom2 = buildGeometry(m_line2); + m_dirty = false; +} + +void SparklineContent::paint(QPainter* painter) { + rebuildIfNeeded(); + if (!m_geom1.hasData && !m_geom2.hasData) return; + + painter->setRenderHint(QPainter::Antialiasing, true); + + auto drawOne = + [&](const LineGeometry& geom, const QColor& color, qreal fillAlpha) { + if (!geom.hasData) return; + + QColor fillColor = color; + fillColor.setAlphaF(static_cast(fillAlpha)); + painter->setPen(Qt::NoPen); + painter->setBrush(fillColor); + painter->drawPath(geom.fill); + + QPen pen(color, m_lineWidth); + pen.setCapStyle(Qt::RoundCap); + pen.setJoinStyle(Qt::RoundJoin); + painter->setPen(pen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(geom.stroke); + }; + + drawOne(m_geom1, m_line1Color, m_line1FillAlpha); + drawOne(m_geom2, m_line2Color, m_line2FillAlpha); +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/sparklinecontent.hpp b/Plugins/ZShell/Components/sparklinecontent.hpp new file mode 100644 index 0000000..9417b94 --- /dev/null +++ b/Plugins/ZShell/Components/sparklinecontent.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include + +#include "internal/circularbuffer.hpp" + +namespace ZShell::components { + +class SparklineContent : public QQuickPaintedItem { + Q_OBJECT + + public: + explicit SparklineContent(QQuickItem* parent = nullptr); + + void paint(QPainter* painter) override; + + void setLine1(internal::CircularBuffer* buffer); + void setLine2(internal::CircularBuffer* buffer); + void setLine1Color(const QColor& color); + void setLine2Color(const QColor& color); + void setLine1FillAlpha(qreal alpha); + void setLine2FillAlpha(qreal alpha); + void setMaxValue(qreal value); + void setLineWidth(qreal width); + void setCurveSmoothing(bool enabled); + void setHistoryLength(int length); + void setSliding(bool sliding); + + void setViewportSize(qreal viewportWidth, qreal viewportHeight); + + [[nodiscard]] qreal stepX() const; + + private: + struct LineGeometry { + QPainterPath stroke; + QPainterPath fill; + bool hasData = false; + }; + + void invalidate(); + void rebuildIfNeeded(); + [[nodiscard]] LineGeometry buildGeometry( + internal::CircularBuffer* buffer) const; + void connectBuffer(internal::CircularBuffer* buffer); + void applyGeometry(); + + internal::CircularBuffer* m_line1 = nullptr; + internal::CircularBuffer* m_line2 = nullptr; + QColor m_line1Color; + QColor m_line2Color; + qreal m_line1FillAlpha = 0.15; + qreal m_line2FillAlpha = 0.2; + qreal m_maxValue = 1024.0; + qreal m_lineWidth = 2.0; + bool m_curveSmoothing = false; + int m_historyLength = 30; + bool m_sliding = true; + + qreal m_viewportW = 0.0; + qreal m_viewportH = 0.0; + + LineGeometry m_geom1; + LineGeometry m_geom2; + bool m_dirty = true; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/sparklineitem.cpp b/Plugins/ZShell/Components/sparklineitem.cpp new file mode 100644 index 0000000..c6d1078 --- /dev/null +++ b/Plugins/ZShell/Components/sparklineitem.cpp @@ -0,0 +1,179 @@ +#include "sparklineitem.hpp" + +#include "sparklinecontent.hpp" + +namespace ZShell::components { + +SparklineItem::SparklineItem(QQuickItem* parent) : QQuickItem(parent) { + setClip(true); + m_content = new SparklineContent(this); + m_content->setHistoryLength(m_historyLength); + m_content->setSliding(m_sliding); +} + +void SparklineItem::geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + m_content->setViewportSize(newGeometry.width(), newGeometry.height()); + updateContentOffset(); +} + +void SparklineItem::updateContentOffset() { + m_content->setX(-m_content->stepX() * m_slideProgress); +} + +void SparklineItem::applySmoothingRadius( + internal::CircularBuffer* buffer) const { + if (buffer) buffer->setSmoothingRadius(qRound(m_smoothing)); +} + +internal::CircularBuffer* SparklineItem::line1() const { + return m_line1; +} + +void SparklineItem::setLine1(internal::CircularBuffer* buffer) { + if (m_line1 == buffer) return; + m_line1 = buffer; + m_content->setLine1(buffer); + applySmoothingRadius(buffer); + emit line1Changed(); +} + +internal::CircularBuffer* SparklineItem::line2() const { + return m_line2; +} + +void SparklineItem::setLine2(internal::CircularBuffer* buffer) { + if (m_line2 == buffer) return; + m_line2 = buffer; + m_content->setLine2(buffer); + applySmoothingRadius(buffer); + emit line2Changed(); +} + +QColor SparklineItem::line1Color() const { + return m_line1Color; +} + +void SparklineItem::setLine1Color(const QColor& color) { + if (m_line1Color == color) return; + m_line1Color = color; + m_content->setLine1Color(color); + emit line1ColorChanged(); +} + +QColor SparklineItem::line2Color() const { + return m_line2Color; +} + +void SparklineItem::setLine2Color(const QColor& color) { + if (m_line2Color == color) return; + m_line2Color = color; + m_content->setLine2Color(color); + emit line2ColorChanged(); +} + +qreal SparklineItem::line1FillAlpha() const { + return m_line1FillAlpha; +} + +void SparklineItem::setLine1FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line1FillAlpha, alpha)) return; + m_line1FillAlpha = alpha; + m_content->setLine1FillAlpha(alpha); + emit line1FillAlphaChanged(); +} + +qreal SparklineItem::line2FillAlpha() const { + return m_line2FillAlpha; +} + +void SparklineItem::setLine2FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line2FillAlpha, alpha)) return; + m_line2FillAlpha = alpha; + m_content->setLine2FillAlpha(alpha); + emit line2FillAlphaChanged(); +} + +qreal SparklineItem::maxValue() const { + return m_maxValue; +} + +void SparklineItem::setMaxValue(qreal value) { + if (qFuzzyCompare(m_maxValue, value)) return; + m_maxValue = value; + m_content->setMaxValue(value); + emit maxValueChanged(); +} + +qreal SparklineItem::slideProgress() const { + return m_slideProgress; +} + +void SparklineItem::setSlideProgress(qreal progress) { + if (qFuzzyCompare(m_slideProgress, progress)) return; + m_slideProgress = progress; + updateContentOffset(); + emit slideProgressChanged(); +} + +int SparklineItem::historyLength() const { + return m_historyLength; +} + +void SparklineItem::setHistoryLength(int length) { + if (m_historyLength == length) return; + m_historyLength = length; + m_content->setHistoryLength(length); + updateContentOffset(); + emit historyLengthChanged(); +} + +qreal SparklineItem::lineWidth() const { + return m_lineWidth; +} + +void SparklineItem::setLineWidth(qreal width) { + if (qFuzzyCompare(m_lineWidth, width)) return; + m_lineWidth = width; + m_content->setLineWidth(width); + emit lineWidthChanged(); +} + +void SparklineItem::setSliding(bool sliding) { + if (m_sliding == sliding) return; + m_sliding = sliding; + m_content->setSliding(sliding); + updateContentOffset(); + emit slidingChanged(); +} + +bool SparklineItem::sliding() const { + return m_sliding; +} + +void SparklineItem::setSmoothing(qreal smoothing) { + smoothing = qMax(0.0, smoothing); + if (qFuzzyCompare(m_smoothing, smoothing)) return; + m_smoothing = smoothing; + applySmoothingRadius(m_line1); + applySmoothingRadius(m_line2); + emit smoothingChanged(); +} + +qreal SparklineItem::smoothing() const { + return m_smoothing; +} + +void SparklineItem::setCurveSmoothing(bool enabled) { + if (m_curveSmoothing == enabled) return; + m_curveSmoothing = enabled; + m_content->setCurveSmoothing(enabled); + emit curveSmoothingChanged(); +} + +bool SparklineItem::curveSmoothing() const { + return m_curveSmoothing; +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Internal/sparklineitem.hpp b/Plugins/ZShell/Components/sparklineitem.hpp similarity index 76% rename from Plugins/ZShell/Internal/sparklineitem.hpp rename to Plugins/ZShell/Components/sparklineitem.hpp index a79be15..81d640b 100644 --- a/Plugins/ZShell/Internal/sparklineitem.hpp +++ b/Plugins/ZShell/Components/sparklineitem.hpp @@ -3,21 +3,25 @@ #include #include #include -#include +#include #include -#include "circularbuffer.hpp" +#include "internal/circularbuffer.hpp" -namespace ZShell::internal { +namespace ZShell::components { -class SparklineItem : public QQuickPaintedItem { +class SparklineContent; + +class SparklineItem : public QQuickItem { Q_OBJECT QML_ELEMENT Q_PROPERTY( - CircularBuffer* line1 READ line1 WRITE setLine1 NOTIFY line1Changed) + internal::CircularBuffer* line1 READ line1 WRITE setLine1 NOTIFY + line1Changed) Q_PROPERTY( - CircularBuffer* line2 READ line2 WRITE setLine2 NOTIFY line2Changed) + internal::CircularBuffer* line2 READ line2 WRITE setLine2 NOTIFY + line2Changed) Q_PROPERTY( QColor line1Color READ line1Color WRITE setLine1Color NOTIFY line1ColorChanged) @@ -52,13 +56,11 @@ class SparklineItem : public QQuickPaintedItem { public: explicit SparklineItem(QQuickItem* parent = nullptr); - void paint(QPainter* painter) override; + [[nodiscard]] internal::CircularBuffer* line1() const; + void setLine1(internal::CircularBuffer* buffer); - [[nodiscard]] CircularBuffer* line1() const; - void setLine1(CircularBuffer* buffer); - - [[nodiscard]] CircularBuffer* line2() const; - void setLine2(CircularBuffer* buffer); + [[nodiscard]] internal::CircularBuffer* line2() const; + void setLine2(internal::CircularBuffer* buffer); [[nodiscard]] QColor line1Color() const; void setLine1Color(const QColor& color); @@ -108,16 +110,17 @@ class SparklineItem : public QQuickPaintedItem { void smoothingChanged(); void curveSmoothingChanged(); - private: - void drawLine( - QPainter* painter, - CircularBuffer* buffer, - const QColor& color, - qreal fillAlpha); - void connectBuffer(CircularBuffer* buffer); + protected: + void geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) override; - CircularBuffer* m_line1 = nullptr; - CircularBuffer* m_line2 = nullptr; + private: + void updateContentOffset(); + void applySmoothingRadius(internal::CircularBuffer* buffer) const; + + SparklineContent* m_content = nullptr; + internal::CircularBuffer* m_line1 = nullptr; + internal::CircularBuffer* m_line2 = nullptr; QColor m_line1Color; QColor m_line2Color; qreal m_line1FillAlpha = 0.15; @@ -131,4 +134,4 @@ class SparklineItem : public QQuickPaintedItem { bool m_curveSmoothing = false; }; -} // namespace ZShell::internal +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/sparklinesceneitem.cpp b/Plugins/ZShell/Components/sparklinesceneitem.cpp new file mode 100644 index 0000000..42170e3 --- /dev/null +++ b/Plugins/ZShell/Components/sparklinesceneitem.cpp @@ -0,0 +1,447 @@ +#include "sparklinesceneitem.hpp" + +#include +#include +#include +#include + +namespace ZShell::components { + +SparklineSceneItem::SparklineSceneItem(QQuickItem* parent) + : QQuickItem(parent) { + setClip(true); + setFlag(QQuickItem::ItemHasContents, true); +} + +void SparklineSceneItem::geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + if (newGeometry.size() != oldGeometry.size()) invalidateGeometry(); +} + +void SparklineSceneItem::invalidateGeometry() { + m_geometryDirty = true; + update(); +} + +void SparklineSceneItem::applySmoothingRadius( + internal::CircularBuffer* buffer) const { + if (buffer) buffer->setSmoothingRadius(qRound(m_smoothing)); +} + +void SparklineSceneItem::connectBuffer(internal::CircularBuffer* buffer) { + if (!buffer) return; + connect( + buffer, + &internal::CircularBuffer::valuesChanged, + this, + &SparklineSceneItem::invalidateGeometry); + connect(buffer, &QObject::destroyed, this, [this, buffer]() { + if (m_line1 == buffer) { + m_line1 = nullptr; + emit line1Changed(); + } + if (m_line2 == buffer) { + m_line2 = nullptr; + emit line2Changed(); + } + invalidateGeometry(); + }); +} + +qreal SparklineSceneItem::stepX() const { + if (m_historyLength < 2 || width() <= 0.0) return 0.0; + if (m_sliding) return width() / static_cast(m_historyLength - 1); + + const int len = qMax( + 2, + (m_line1 && m_line1->count() >= 2) ? m_line1->count() + : (m_line2 ? m_line2->count() : 2)); + return width() / static_cast(len - 1); +} + +SparklineSceneItem::LineSample SparklineSceneItem::buildSample( + internal::CircularBuffer* buffer) const { + LineSample sample; + if (!buffer || buffer->count() < 2 || m_maxValue <= 0.0 || width() <= 0.0) + return sample; + if (m_historyLength < 2) return sample; + + const int len = buffer->count(); + const qreal step = stepX(); + if (step <= 0.0) return sample; + + qreal startX = m_sliding ? (width() - (len - 1) * step + step) : 0.0; + + const qreal h = height(); + const qreal strokePad = qCeil(m_lineWidth / 2); + const qreal curvePad = 3.0; + const qreal plotTop = strokePad + curvePad; + const qreal plotBottom = h - strokePad; + const qreal plotH = qMax(1.0, plotBottom - plotTop); + + QVector points; + points.reserve(len); + + for (int i = 0; i < len; ++i) { + // Already smoothed once, incrementally, by the buffer itself. + const qreal value = + qBound(0.0, buffer->smoothedAt(i), m_maxValue); + + const qreal x = startX + i * step; + const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH; + points.append(QPointF(x, y)); + } + + QPainterPath path; + path.moveTo(points[0]); + + if (m_curveSmoothing && points.size() > 2) { + const qreal tension = 1.0; + const qreal k = tension / 6.0; + const int n = points.size(); + for (int i = 0; i < n - 1; ++i) { + const QPointF& p0 = points[qMax(i - 1, 0)]; + const QPointF& p1 = points[i]; + const QPointF& p2 = points[i + 1]; + const QPointF& p3 = points[qMin(i + 2, n - 1)]; + + QPointF c1 = p1 + (p2 - p0) * k; + QPointF c2 = p2 - (p3 - p1) * k; + + const qreal segMinY = qMin(p1.y(), p2.y()); + const qreal segMaxY = qMax(p1.y(), p2.y()); + c1.setY(qBound(segMinY, c1.y(), segMaxY)); + c2.setY(qBound(segMinY, c2.y(), segMaxY)); + + path.cubicTo(c1, c2, p2); + } + } else { + for (int i = 0; i < points.size() - 1; ++i) { + const QPointF& p0 = points[i]; + const QPointF& p1 = points[i + 1]; + const qreal ctrlX = (p0.x() + p1.x()) * 0.5; + path.cubicTo(QPointF(ctrlX, p0.y()), QPointF(ctrlX, p1.y()), p1); + } + } + + // Flatten the curve to a polyline once, here, on data change only. + // This is CPU math only -- no QPainter draw calls, no image, no + // texture upload happen as a result of this. + const auto subpaths = path.toSubpathPolygons(); + if (subpaths.isEmpty()) return sample; + + sample.polyline = subpaths.first(); + sample.fillBottom = h; + sample.hasData = sample.polyline.size() >= 2; + return sample; +} + +void SparklineSceneItem::writeFillGeometry( + QSGGeometryNode* node, const LineSample& sample) { + const int n = sample.polyline.size(); + auto* geometry = node->geometry(); + geometry->allocate(n * 2); + geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); + + auto* vertices = geometry->vertexDataAsPoint2D(); + for (int i = 0; i < n; ++i) { + const QPointF& p = sample.polyline[i]; + vertices[i * 2].set(p.x(), p.y()); + vertices[i * 2 + 1].set(p.x(), sample.fillBottom); + } + + node->markDirty(QSGNode::DirtyGeometry); +} + +void SparklineSceneItem::writeStrokeGeometry( + QSGGeometryNode* node, const LineSample& sample, qreal lineWidth) { + const int n = sample.polyline.size(); + auto* geometry = node->geometry(); + geometry->allocate(n * 2); + geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); + + auto* vertices = geometry->vertexDataAsPoint2D(); + const qreal half = lineWidth * 0.5; + + for (int i = 0; i < n; ++i) { + QPointF dir; + if (i == 0) + dir = sample.polyline[1] - sample.polyline[0]; + else if (i == n - 1) + dir = sample.polyline[n - 1] - sample.polyline[n - 2]; + else + dir = sample.polyline[i + 1] - sample.polyline[i - 1]; + + const qreal len = std::hypot(dir.x(), dir.y()); + QPointF normal = len > 1e-6 ? QPointF(-dir.y() / len, dir.x() / len) + : QPointF(0, 1); + + const QPointF& p = sample.polyline[i]; + const QPointF offset = normal * half; + vertices[i * 2].set(p.x() + offset.x(), p.y() + offset.y()); + vertices[i * 2 + 1].set(p.x() - offset.x(), p.y() - offset.y()); + } + + node->markDirty(QSGNode::DirtyGeometry); +} + +void SparklineSceneItem::setMaterialColor( + QSGGeometryNode* node, const QColor& color, qreal alpha) { + auto* material = static_cast(node->material()); + QColor c = color; + c.setAlphaF(static_cast(alpha)); + material->setColor(c); + node->markDirty(QSGNode::DirtyMaterial); +} + +QSGNode* SparklineSceneItem::updatePaintNode( + QSGNode* node, UpdatePaintNodeData*) { + bool samplesRebuilt = false; + if (m_geometryDirty) { + m_sample1 = buildSample(m_line1); + m_sample2 = buildSample(m_line2); + m_geometryDirty = false; + samplesRebuilt = true; + } + + const bool hasAny = m_sample1.hasData || m_sample2.hasData; + if (!hasAny) { + delete node; + m_transformNode = nullptr; + m_line1FillNode = m_line1StrokeNode = nullptr; + m_line2FillNode = m_line2StrokeNode = nullptr; + return nullptr; + } + + auto* transformNode = static_cast(node); + bool justCreated = false; + if (!transformNode) { + transformNode = new QSGTransformNode(); + m_transformNode = transformNode; + + auto makeNode = [] { + auto* n = new QSGGeometryNode(); + n->setGeometry( + new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(), 0)); + n->setFlag(QSGNode::OwnsGeometry); + n->setMaterial(new QSGFlatColorMaterial()); + n->setFlag(QSGNode::OwnsMaterial); + return n; + }; + + m_line1FillNode = makeNode(); + m_line1StrokeNode = makeNode(); + m_line2FillNode = makeNode(); + m_line2StrokeNode = makeNode(); + transformNode->appendChildNode(m_line1FillNode); + transformNode->appendChildNode(m_line1StrokeNode); + transformNode->appendChildNode(m_line2FillNode); + transformNode->appendChildNode(m_line2StrokeNode); + + justCreated = true; + } + + QMatrix4x4 matrix; + matrix.translate(-stepX() * m_slideProgress, 0); + transformNode->setMatrix(matrix); + + const bool needsGeometryWrite = samplesRebuilt || justCreated; + const bool needsColorWrite = m_colorsDirty || justCreated; + + auto writeLine = [&](QSGGeometryNode* fillNode, + QSGGeometryNode* strokeNode, + const LineSample& sample, + const QColor& color, + qreal fillAlpha) { + if (needsGeometryWrite) { + if (sample.hasData) { + writeFillGeometry(fillNode, sample); + writeStrokeGeometry(strokeNode, sample, m_lineWidth); + } else { + fillNode->geometry()->allocate(0); + strokeNode->geometry()->allocate(0); + fillNode->markDirty(QSGNode::DirtyGeometry); + strokeNode->markDirty(QSGNode::DirtyGeometry); + } + } + if (needsColorWrite) { + setMaterialColor(fillNode, color, fillAlpha); + setMaterialColor(strokeNode, color, 1.0); + } + }; + + writeLine( + m_line1FillNode, + m_line1StrokeNode, + m_sample1, + m_line1Color, + m_line1FillAlpha); + writeLine( + m_line2FillNode, + m_line2StrokeNode, + m_sample2, + m_line2Color, + m_line2FillAlpha); + + m_colorsDirty = false; + + return transformNode; +} + +internal::CircularBuffer* SparklineSceneItem::line1() const { + return m_line1; +} + +void SparklineSceneItem::setLine1(internal::CircularBuffer* buffer) { + if (m_line1 == buffer) return; + if (m_line1) disconnect(m_line1, nullptr, this, nullptr); + m_line1 = buffer; + connectBuffer(buffer); + applySmoothingRadius(buffer); + invalidateGeometry(); + emit line1Changed(); +} + +internal::CircularBuffer* SparklineSceneItem::line2() const { + return m_line2; +} + +void SparklineSceneItem::setLine2(internal::CircularBuffer* buffer) { + if (m_line2 == buffer) return; + if (m_line2) disconnect(m_line2, nullptr, this, nullptr); + m_line2 = buffer; + connectBuffer(buffer); + applySmoothingRadius(buffer); + invalidateGeometry(); + emit line2Changed(); +} + +QColor SparklineSceneItem::line1Color() const { + return m_line1Color; +} + +void SparklineSceneItem::setLine1Color(const QColor& color) { + if (m_line1Color == color) return; + m_line1Color = color; + m_colorsDirty = true; + update(); + emit line1ColorChanged(); +} + +QColor SparklineSceneItem::line2Color() const { + return m_line2Color; +} + +void SparklineSceneItem::setLine2Color(const QColor& color) { + if (m_line2Color == color) return; + m_line2Color = color; + m_colorsDirty = true; + update(); + emit line2ColorChanged(); +} + +qreal SparklineSceneItem::line1FillAlpha() const { + return m_line1FillAlpha; +} + +void SparklineSceneItem::setLine1FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line1FillAlpha, alpha)) return; + m_line1FillAlpha = alpha; + m_colorsDirty = true; + update(); + emit line1FillAlphaChanged(); +} + +qreal SparklineSceneItem::line2FillAlpha() const { + return m_line2FillAlpha; +} + +void SparklineSceneItem::setLine2FillAlpha(qreal alpha) { + if (qFuzzyCompare(m_line2FillAlpha, alpha)) return; + m_line2FillAlpha = alpha; + m_colorsDirty = true; + update(); + emit line2FillAlphaChanged(); +} + +qreal SparklineSceneItem::maxValue() const { + return m_maxValue; +} + +void SparklineSceneItem::setMaxValue(qreal value) { + if (qFuzzyCompare(m_maxValue, value)) return; + m_maxValue = value; + invalidateGeometry(); + emit maxValueChanged(); +} + +qreal SparklineSceneItem::slideProgress() const { + return m_slideProgress; +} + +void SparklineSceneItem::setSlideProgress(qreal progress) { + if (qFuzzyCompare(m_slideProgress, progress)) return; + m_slideProgress = progress; + update(); + emit slideProgressChanged(); +} + +int SparklineSceneItem::historyLength() const { + return m_historyLength; +} + +void SparklineSceneItem::setHistoryLength(int length) { + if (m_historyLength == length) return; + m_historyLength = length; + invalidateGeometry(); + emit historyLengthChanged(); +} + +qreal SparklineSceneItem::lineWidth() const { + return m_lineWidth; +} + +void SparklineSceneItem::setLineWidth(qreal width) { + if (qFuzzyCompare(m_lineWidth, width)) return; + m_lineWidth = width; + invalidateGeometry(); + emit lineWidthChanged(); +} + +bool SparklineSceneItem::sliding() const { + return m_sliding; +} + +void SparklineSceneItem::setSliding(bool sliding) { + if (m_sliding == sliding) return; + m_sliding = sliding; + invalidateGeometry(); + emit slidingChanged(); +} + +qreal SparklineSceneItem::smoothing() const { + return m_smoothing; +} + +void SparklineSceneItem::setSmoothing(qreal smoothing) { + smoothing = qMax(0.0, smoothing); + if (qFuzzyCompare(m_smoothing, smoothing)) return; + m_smoothing = smoothing; + applySmoothingRadius(m_line1); + applySmoothingRadius(m_line2); + emit smoothingChanged(); +} + +bool SparklineSceneItem::curveSmoothing() const { + return m_curveSmoothing; +} + +void SparklineSceneItem::setCurveSmoothing(bool enabled) { + if (m_curveSmoothing == enabled) return; + m_curveSmoothing = enabled; + invalidateGeometry(); + emit curveSmoothingChanged(); +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/sparklinesceneitem.hpp b/Plugins/ZShell/Components/sparklinesceneitem.hpp new file mode 100644 index 0000000..5309793 --- /dev/null +++ b/Plugins/ZShell/Components/sparklinesceneitem.hpp @@ -0,0 +1,167 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "internal/circularbuffer.hpp" + +class QSGGeometryNode; +class QSGTransformNode; + +namespace ZShell::components { + +class SparklineSceneItem : public QQuickItem { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + internal::CircularBuffer* line1 READ line1 WRITE setLine1 NOTIFY + line1Changed) + Q_PROPERTY( + internal::CircularBuffer* line2 READ line2 WRITE setLine2 NOTIFY + line2Changed) + Q_PROPERTY( + QColor line1Color READ line1Color WRITE setLine1Color NOTIFY + line1ColorChanged) + Q_PROPERTY( + QColor line2Color READ line2Color WRITE setLine2Color NOTIFY + line2ColorChanged) + Q_PROPERTY( + qreal line1FillAlpha READ line1FillAlpha WRITE setLine1FillAlpha NOTIFY + line1FillAlphaChanged) + Q_PROPERTY( + qreal line2FillAlpha READ line2FillAlpha WRITE setLine2FillAlpha NOTIFY + line2FillAlphaChanged) + Q_PROPERTY( + qreal maxValue READ maxValue WRITE setMaxValue NOTIFY maxValueChanged) + Q_PROPERTY( + qreal slideProgress READ slideProgress WRITE setSlideProgress NOTIFY + slideProgressChanged) + Q_PROPERTY( + int historyLength READ historyLength WRITE setHistoryLength NOTIFY + historyLengthChanged) + Q_PROPERTY( + qreal lineWidth READ lineWidth WRITE setLineWidth NOTIFY + lineWidthChanged) + Q_PROPERTY(bool sliding READ sliding WRITE setSliding NOTIFY slidingChanged) + Q_PROPERTY( + qreal smoothing READ smoothing WRITE setSmoothing NOTIFY + smoothingChanged) + Q_PROPERTY( + bool curveSmoothing READ curveSmoothing WRITE setCurveSmoothing NOTIFY + curveSmoothingChanged) + + public: + explicit SparklineSceneItem(QQuickItem* parent = nullptr); + + [[nodiscard]] internal::CircularBuffer* line1() const; + void setLine1(internal::CircularBuffer* buffer); + + [[nodiscard]] internal::CircularBuffer* line2() const; + void setLine2(internal::CircularBuffer* buffer); + + [[nodiscard]] QColor line1Color() const; + void setLine1Color(const QColor& color); + + [[nodiscard]] QColor line2Color() const; + void setLine2Color(const QColor& color); + + [[nodiscard]] qreal line1FillAlpha() const; + void setLine1FillAlpha(qreal alpha); + + [[nodiscard]] qreal line2FillAlpha() const; + void setLine2FillAlpha(qreal alpha); + + [[nodiscard]] qreal maxValue() const; + void setMaxValue(qreal value); + + [[nodiscard]] qreal slideProgress() const; + void setSlideProgress(qreal progress); + + [[nodiscard]] int historyLength() const; + void setHistoryLength(int length); + + [[nodiscard]] qreal lineWidth() const; + void setLineWidth(qreal width); + + [[nodiscard]] bool sliding() const; + void setSliding(bool sliding); + + [[nodiscard]] qreal smoothing() const; + void setSmoothing(qreal smoothing); + + [[nodiscard]] bool curveSmoothing() const; + void setCurveSmoothing(bool curveSmoothing); + + signals: + void line1Changed(); + void line2Changed(); + void line1ColorChanged(); + void line2ColorChanged(); + void line1FillAlphaChanged(); + void line2FillAlphaChanged(); + void maxValueChanged(); + void slideProgressChanged(); + void historyLengthChanged(); + void lineWidthChanged(); + void slidingChanged(); + void smoothingChanged(); + void curveSmoothingChanged(); + + protected: + QSGNode* updatePaintNode(QSGNode* node, UpdatePaintNodeData* data) override; + void geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) override; + + private: + struct LineSample { + QVector polyline; // local coords, slideProgress baked to 0 + qreal fillBottom = 0.0; + bool hasData = false; + }; + + void connectBuffer(internal::CircularBuffer* buffer); + void invalidateGeometry(); + void applySmoothingRadius(internal::CircularBuffer* buffer) const; + [[nodiscard]] qreal stepX() const; + [[nodiscard]] LineSample buildSample(internal::CircularBuffer* buffer) const; + static void writeFillGeometry( + QSGGeometryNode* node, const LineSample& sample); + static void writeStrokeGeometry( + QSGGeometryNode* node, const LineSample& sample, qreal lineWidth); + static void setMaterialColor( + QSGGeometryNode* node, const QColor& color, qreal alpha); + + internal::CircularBuffer* m_line1 = nullptr; + internal::CircularBuffer* m_line2 = nullptr; + QColor m_line1Color; + QColor m_line2Color; + qreal m_line1FillAlpha = 0.15; + qreal m_line2FillAlpha = 0.2; + qreal m_maxValue = 1024.0; + qreal m_slideProgress = 0.0; + int m_historyLength = 30; + qreal m_lineWidth = 2.0; + bool m_sliding = true; + qreal m_smoothing = 0.0; + bool m_curveSmoothing = false; + + LineSample m_sample1; + LineSample m_sample2; + + bool m_geometryDirty = true; + bool m_colorsDirty = true; + + QSGTransformNode* m_transformNode = nullptr; + QSGGeometryNode* m_line1FillNode = nullptr; + QSGGeometryNode* m_line1StrokeNode = nullptr; + QSGGeometryNode* m_line2FillNode = nullptr; + QSGGeometryNode* m_line2StrokeNode = nullptr; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Config/CMakeLists.txt b/Plugins/ZShell/Config/CMakeLists.txt index fdb1ea2..cb55a84 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -1,16 +1,17 @@ qml_module(ZShell-config URI ZShell.Config INCLUDE_PREFIX config + DEPENDENCIES + ZShell.Settings SOURCES - confignode.hpp confignode.cpp - configobject.hpp configobject.cpp - configlist.hpp configlist.cpp - config.hpp config.cpp + common.hpp common.cpp + rootnodes.hpp rootnodes.cpp + migration.hpp migration.cpp anim.hpp anim.cpp fontbuilder.hpp fontbuilder.cpp font.hpp font.cpp tokens.hpp - appearance.hpp + appearance.hpp appearance.cpp background.hpp bar.hpp clipboard.hpp @@ -27,6 +28,11 @@ qml_module(ZShell-config services.hpp sidebar.hpp utilities.hpp - LIBRARIES - Qt::Gui +) + +target_link_libraries(ZShell-config PUBLIC + Qt::Gui + zshell-util + ZShell + ZShell-settings ) diff --git a/Plugins/ZShell/Config/anim.cpp b/Plugins/ZShell/Config/anim.cpp index 50926c2..f5b97ff 100644 --- a/Plugins/ZShell/Config/anim.cpp +++ b/Plugins/ZShell/Config/anim.cpp @@ -1,32 +1,85 @@ #include "anim.hpp" -#include "tokens.hpp" + #include +#include "appearance.hpp" +#include "tokens.hpp" + namespace ZShell::config { -QEasingCurve AnimEasing::buildCurve(const QList& points) { +AnimTokens::AnimTokens(QObject* parent) : QObject(parent) {} + +QEasingCurve AnimTokens::emphasized() const { + return m_emphasized; +} + +QEasingCurve AnimTokens::emphasizedAccel() const { + return m_emphasizedAccel; +} + +QEasingCurve AnimTokens::emphasizedDecel() const { + return m_emphasizedDecel; +} + +QEasingCurve AnimTokens::standard() const { + return m_standard; +} + +QEasingCurve AnimTokens::standardAccel() const { + return m_standardAccel; +} + +QEasingCurve AnimTokens::standardDecel() const { + return m_standardDecel; +} + +QEasingCurve AnimTokens::expressiveFastSpatial() const { + return m_expressiveFastSpatial; +} + +QEasingCurve AnimTokens::expressiveDefaultSpatial() const { + return m_expressiveDefaultSpatial; +} + +QEasingCurve AnimTokens::expressiveSlowSpatial() const { + return m_expressiveSlowSpatial; +} + +QEasingCurve AnimTokens::expressiveFastEffects() const { + return m_expressiveFastEffects; +} + +QEasingCurve AnimTokens::expressiveDefaultEffects() const { + return m_expressiveDefaultEffects; +} + +QEasingCurve AnimTokens::expressiveSlowEffects() const { + return m_expressiveSlowEffects; +} + +AnimDurations* AnimTokens::durations() const { + return m_durations; +} + +QEasingCurve AnimTokens::buildCurve(const QList& points) { QEasingCurve curve(QEasingCurve::BezierSpline); + + // Points come in pairs of (x, y) forming cubic bezier segments. + // Each segment needs 3 control points: c1, c2, endPoint. + // So 6 values per segment: c1x, c1y, c2x, c2y, endX, endY. for (int i = 0; i + 5 < points.size(); i += 6) { - QPointF c1(points[i], points[i + 1]); - QPointF c2(points[i + 2], points[i + 3]); - QPointF end(points[i + 4], points[i + 5]); + const QPointF c1(points[i], points[i + 1]); + const QPointF c2(points[i + 2], points[i + 3]); + const QPointF end(points[i + 4], points[i + 5]); curve.addCubicBezierSegment(c1, c2, end); } + return curve; } -void AnimEasing::bindCurves(AnimCurves* curves) { - m_curves = curves; - connect( - curves, - &AnimCurves::propertiesChanged, - this, - &AnimEasing::rebuildCurves); - rebuildCurves(); -} - -void AnimEasing::rebuildCurves() { +void AnimTokens::rebuildCurves() { if (!m_curves) return; + m_emphasized = buildCurve(m_curves->emphasized()); m_emphasizedAccel = buildCurve(m_curves->emphasizedAccel()); m_emphasizedDecel = buildCurve(m_curves->emphasizedDecel()); @@ -41,7 +94,25 @@ void AnimEasing::rebuildCurves() { m_expressiveDefaultEffects = buildCurve(m_curves->expressiveDefaultEffects()); m_expressiveSlowEffects = buildCurve(m_curves->expressiveSlowEffects()); + emit curvesChanged(); } +void AnimTokens::bindCurves(AnimCurves* curves) { + m_curves = curves; + + // Rebuild when any curve control points change + connect( + curves, &AnimCurves::optionChanged, this, &AnimTokens::rebuildCurves); + + rebuildCurves(); +} + +void AnimTokens::bindDurations(AnimDurations* durations) { + if (m_durations == durations) return; + + m_durations = durations; + emit durationsChanged(); +} + } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/anim.hpp b/Plugins/ZShell/Config/anim.hpp index c5f95bc..2a76c9d 100644 --- a/Plugins/ZShell/Config/anim.hpp +++ b/Plugins/ZShell/Config/anim.hpp @@ -9,8 +9,10 @@ namespace ZShell::config { class AnimCurves; class AnimDurations; -class AnimEasing : public QObject { +class AnimTokens : public QObject { Q_OBJECT + Q_MOC_INCLUDE("tokens.hpp") // AnimCurves + Q_MOC_INCLUDE("appearance.hpp") QML_ANONYMOUS Q_PROPERTY(QEasingCurve emphasized READ emphasized NOTIFY curvesChanged) @@ -42,54 +44,53 @@ class AnimEasing : public QObject { QEasingCurve expressiveSlowEffects READ expressiveSlowEffects NOTIFY curvesChanged) + Q_PROPERTY( + ZShell::config::AnimDurations* durations READ durations NOTIFY + durationsChanged) + public: - explicit AnimEasing(QObject* parent = nullptr) : QObject(parent) {} + explicit AnimTokens(QObject* parent = nullptr); void bindCurves(AnimCurves* curves); + void bindDurations(AnimDurations* durations); - [[nodiscard]] QEasingCurve emphasized() const { return m_emphasized; } - [[nodiscard]] QEasingCurve emphasizedAccel() const { - return m_emphasizedAccel; - } - [[nodiscard]] QEasingCurve emphasizedDecel() const { - return m_emphasizedDecel; - } - [[nodiscard]] QEasingCurve standard() const { return m_standard; } - [[nodiscard]] QEasingCurve standardAccel() const { return m_standardAccel; } - [[nodiscard]] QEasingCurve standardDecel() const { return m_standardDecel; } - [[nodiscard]] QEasingCurve expressiveFastSpatial() const { - return m_expressiveFastSpatial; - } - [[nodiscard]] QEasingCurve expressiveDefaultSpatial() const { - return m_expressiveDefaultSpatial; - } - [[nodiscard]] QEasingCurve expressiveSlowSpatial() const { - return m_expressiveSlowSpatial; - } - [[nodiscard]] QEasingCurve expressiveFastEffects() const { - return m_expressiveFastEffects; - } - [[nodiscard]] QEasingCurve expressiveDefaultEffects() const { - return m_expressiveDefaultEffects; - } - [[nodiscard]] QEasingCurve expressiveSlowEffects() const { - return m_expressiveSlowEffects; - } + [[nodiscard]] QEasingCurve emphasized() const; + [[nodiscard]] QEasingCurve emphasizedAccel() const; + [[nodiscard]] QEasingCurve emphasizedDecel() const; + [[nodiscard]] QEasingCurve standard() const; + [[nodiscard]] QEasingCurve standardAccel() const; + [[nodiscard]] QEasingCurve standardDecel() const; + [[nodiscard]] QEasingCurve expressiveFastSpatial() const; + [[nodiscard]] QEasingCurve expressiveDefaultSpatial() const; + [[nodiscard]] QEasingCurve expressiveSlowSpatial() const; + [[nodiscard]] QEasingCurve expressiveFastEffects() const; + [[nodiscard]] QEasingCurve expressiveDefaultEffects() const; + [[nodiscard]] QEasingCurve expressiveSlowEffects() const; + [[nodiscard]] AnimDurations* durations() const; signals: void curvesChanged(); + void durationsChanged(); private: - static QEasingCurve buildCurve(const QList& points); void rebuildCurves(); + static QEasingCurve buildCurve(const QList& points); AnimCurves* m_curves = nullptr; - QEasingCurve m_emphasized, m_emphasizedAccel, m_emphasizedDecel; - QEasingCurve m_standard, m_standardAccel, m_standardDecel; - QEasingCurve m_expressiveFastSpatial, m_expressiveDefaultSpatial, - m_expressiveSlowSpatial; - QEasingCurve m_expressiveFastEffects, m_expressiveDefaultEffects, - m_expressiveSlowEffects; + AnimDurations* m_durations = nullptr; + + QEasingCurve m_emphasized; + QEasingCurve m_emphasizedAccel; + QEasingCurve m_emphasizedDecel; + QEasingCurve m_standard; + QEasingCurve m_standardAccel; + QEasingCurve m_standardDecel; + QEasingCurve m_expressiveFastSpatial; + QEasingCurve m_expressiveDefaultSpatial; + QEasingCurve m_expressiveSlowSpatial; + QEasingCurve m_expressiveFastEffects; + QEasingCurve m_expressiveDefaultEffects; + QEasingCurve m_expressiveSlowEffects; }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/appearance.cpp b/Plugins/ZShell/Config/appearance.cpp new file mode 100644 index 0000000..1d125ec --- /dev/null +++ b/Plugins/ZShell/Config/appearance.cpp @@ -0,0 +1,229 @@ +#include "appearance.hpp" + +#include "tokens.hpp" + +namespace ZShell::config { + +namespace { + +// Helper: connect all changed signals from a token object to a single valuesChanged signal, +// plus connect the local scaleChanged signal. +template +void connectTokenSignals(Source* source, Target* target) { + const auto* meta = source->metaObject(); + + for (int i = meta->propertyOffset(); i < meta->propertyCount(); ++i) { + auto prop = meta->property(i); + + if (prop.hasNotifySignal()) + QObject::connect( + source, + prop.notifySignal(), + target, + target->metaObject()->method( + target->metaObject()->indexOfSignal("valuesChanged()"))); + } + + QObject::connect( + target, &Target::scaleChanged, target, &Target::valuesChanged); +} + +} // namespace + +// AppearanceRounding + +void AppearanceRounding::bindTokens(RoundingTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AppearanceRounding::extraSmall() const { + return m_tokens ? static_cast(m_tokens->extraSmall() * m_scale) : 0; +} + +int AppearanceRounding::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AppearanceRounding::medium() const { + return m_tokens ? static_cast(m_tokens->medium() * m_scale) : 0; +} + +int AppearanceRounding::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int AppearanceRounding::largeIncreased() const { + return m_tokens ? static_cast(m_tokens->largeIncreased() * m_scale) + : 0; +} + +int AppearanceRounding::extraLarge() const { + return m_tokens ? static_cast(m_tokens->extraLarge() * m_scale) : 0; +} + +int AppearanceRounding::extraLargeIncreased() const { + return m_tokens + ? static_cast(m_tokens->extraLargeIncreased() * m_scale) + : 0; +} + +int AppearanceRounding::extraExtraLarge() const { + return m_tokens ? static_cast(m_tokens->extraExtraLarge() * m_scale) + : 0; +} + +int AppearanceRounding::full() const { + return m_tokens ? m_tokens->full() : 0; +} + +// AppearanceSpacing + +void AppearanceSpacing::bindTokens(SpacingTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AppearanceSpacing::extraSmall() const { + return m_tokens ? static_cast(m_tokens->extraSmall() * m_scale) : 0; +} + +int AppearanceSpacing::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AppearanceSpacing::medium() const { + return m_tokens ? static_cast(m_tokens->medium() * m_scale) : 0; +} + +int AppearanceSpacing::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int AppearanceSpacing::largeIncreased() const { + return m_tokens ? static_cast(m_tokens->largeIncreased() * m_scale) + : 0; +} + +int AppearanceSpacing::extraLarge() const { + return m_tokens ? static_cast(m_tokens->extraLarge() * m_scale) : 0; +} + +int AppearanceSpacing::extraLargeIncreased() const { + return m_tokens + ? static_cast(m_tokens->extraLargeIncreased() * m_scale) + : 0; +} + +int AppearanceSpacing::extraExtraLarge() const { + return m_tokens ? static_cast(m_tokens->extraExtraLarge() * m_scale) + : 0; +} + +// AppearancePadding + +void AppearancePadding::bindTokens(PaddingTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AppearancePadding::extraSmall() const { + return m_tokens ? static_cast(m_tokens->extraSmall() * m_scale) : 0; +} + +int AppearancePadding::smaller() const { + return m_tokens ? static_cast(m_tokens->smaller() * m_scale) : 0; +} + +int AppearancePadding::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AppearancePadding::medium() const { + return m_tokens ? static_cast(m_tokens->medium() * m_scale) : 0; +} + +int AppearancePadding::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int AppearancePadding::largeIncreased() const { + return m_tokens ? static_cast(m_tokens->largeIncreased() * m_scale) + : 0; +} + +int AppearancePadding::extraLarge() const { + return m_tokens ? static_cast(m_tokens->extraLarge() * m_scale) : 0; +} + +int AppearancePadding::extraLargeIncreased() const { + return m_tokens + ? static_cast(m_tokens->extraLargeIncreased() * m_scale) + : 0; +} + +int AppearancePadding::extraExtraLarge() const { + return m_tokens ? static_cast(m_tokens->extraExtraLarge() * m_scale) + : 0; +} + +// AnimDurations + +void AnimDurations::bindTokens(AnimDurationTokens* tokens) { + m_tokens = tokens; + connectTokenSignals(tokens, this); +} + +int AnimDurations::small() const { + return m_tokens ? static_cast(m_tokens->small() * m_scale) : 0; +} + +int AnimDurations::normal() const { + return m_tokens ? static_cast(m_tokens->normal() * m_scale) : 0; +} + +int AnimDurations::large() const { + return m_tokens ? static_cast(m_tokens->large() * m_scale) : 0; +} + +int AnimDurations::extraLarge() const { + return m_tokens ? static_cast(m_tokens->extraLarge() * m_scale) : 0; +} + +int AnimDurations::expressiveFastSpatial() const { + return m_tokens + ? static_cast(m_tokens->expressiveFastSpatial() * m_scale) + : 0; +} + +int AnimDurations::expressiveDefaultSpatial() const { + return m_tokens ? static_cast( + m_tokens->expressiveDefaultSpatial() * m_scale) + : 0; +} + +int AnimDurations::expressiveSlowSpatial() const { + return m_tokens + ? static_cast(m_tokens->expressiveSlowSpatial() * m_scale) + : 0; +} + +int AnimDurations::expressiveFastEffects() const { + return m_tokens + ? static_cast(m_tokens->expressiveFastEffects() * m_scale) + : 0; +} + +int AnimDurations::expressiveDefaultEffects() const { + return m_tokens ? static_cast( + m_tokens->expressiveDefaultEffects() * m_scale) + : 0; +} + +int AnimDurations::expressiveSlowEffects() const { + return m_tokens + ? static_cast(m_tokens->expressiveSlowEffects() * m_scale) + : 0; +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/appearance.hpp b/Plugins/ZShell/Config/appearance.hpp index 5181423..4166253 100644 --- a/Plugins/ZShell/Config/appearance.hpp +++ b/Plugins/ZShell/Config/appearance.hpp @@ -1,101 +1,126 @@ #pragma once -#include "configobject.hpp" -#include -#include -#include -#include +#include +#include +#include + +#include "settings/objectnode.hpp" +#include "common.hpp" namespace ZShell::config { -class AnimDurations : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +using Qt::StringLiterals::operator""_s; - CFG_PROPERTY(qreal, scale, 1) +// Forward declare token types from tokens.hpp +class RoundingTokens; +class SpacingTokens; +class PaddingTokens; +class AnimDurationTokens; - Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) +class AppearanceRounding : public settings::ObjectNode { + CONFIG_NODE(AppearanceRounding, settings::ObjectNode) + + CONFIG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY valuesChanged) + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int medium READ medium NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY valuesChanged) Q_PROPERTY( - int expressiveDefaultEffects READ expressiveDefaultEffects NOTIFY - scaleChanged) - Q_PROPERTY( - int expressiveDefaultSpatial READ expressiveDefaultSpatial NOTIFY - scaleChanged) - Q_PROPERTY( - int expressiveFastEffects READ expressiveFastEffects NOTIFY scaleChanged) - Q_PROPERTY( - int expressiveFastSpatial READ expressiveFastSpatial NOTIFY scaleChanged) - Q_PROPERTY( - int expressiveSlowEffects READ expressiveSlowEffects NOTIFY scaleChanged) - Q_PROPERTY( - int expressiveSlowSpatial READ expressiveSlowSpatial NOTIFY scaleChanged) - Q_PROPERTY(int large READ large NOTIFY scaleChanged) - Q_PROPERTY(int normal READ normal NOTIFY scaleChanged) - Q_PROPERTY(int small READ small NOTIFY scaleChanged) + int extraLargeIncreased READ extraLargeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY valuesChanged) + Q_PROPERTY(int full READ full NOTIFY valuesChanged) public: - explicit AnimDurations(QObject* parent = nullptr) : ConfigObject(parent) {} + void bindTokens(RoundingTokens* tokens); - [[nodiscard]] int small() const { return static_cast(200 * scale()); } - [[nodiscard]] int normal() const { return static_cast(400 * scale()); } - [[nodiscard]] int large() const { return static_cast(600 * scale()); } - [[nodiscard]] int extraLarge() const { - return static_cast(1000 * scale()); - } - [[nodiscard]] int expressiveFastSpatial() const { - return static_cast(350 * scale()); - } - [[nodiscard]] int expressiveDefaultSpatial() const { - return static_cast(500 * scale()); - } - [[nodiscard]] int expressiveSlowSpatial() const { - return static_cast(650 * scale()); - } - [[nodiscard]] int expressiveFastEffects() const { - return static_cast(150 * scale()); - } - [[nodiscard]] int expressiveDefaultEffects() const { - return static_cast(200 * scale()); - } - [[nodiscard]] int expressiveSlowEffects() const { - return static_cast(300 * scale()); - } + [[nodiscard]] int extraSmall() const; + [[nodiscard]] int small() const; + [[nodiscard]] int medium() const; + [[nodiscard]] int large() const; + [[nodiscard]] int largeIncreased() const; + [[nodiscard]] int extraLarge() const; + [[nodiscard]] int extraLargeIncreased() const; + [[nodiscard]] int extraExtraLarge() const; + [[nodiscard]] int full() const; + + signals: + void valuesChanged(); + + private: + RoundingTokens* m_tokens = nullptr; }; -class Anim : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class AppearanceSpacing : public settings::ObjectNode { + CONFIG_NODE(AppearanceSpacing, settings::ObjectNode) - CFG_PROPERTY(int, mediaGifSpeedAdjustment, 300) - CFG_PROPERTY(qreal, sessionGifSpeed, 0.7) - CONFIG_SUBOBJECT(AnimDurations, durations) + CONFIG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY valuesChanged) + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int medium READ medium NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY valuesChanged) + Q_PROPERTY( + int extraLargeIncreased READ extraLargeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY valuesChanged) public: - explicit Anim(QObject* parent = nullptr) - : ConfigObject(parent), m_durations(new AnimDurations(this)) {} + void bindTokens(SpacingTokens* tokens); + + [[nodiscard]] int extraSmall() const; + [[nodiscard]] int small() const; + [[nodiscard]] int medium() const; + [[nodiscard]] int large() const; + [[nodiscard]] int largeIncreased() const; + [[nodiscard]] int extraLarge() const; + [[nodiscard]] int extraLargeIncreased() const; + [[nodiscard]] int extraExtraLarge() const; + + signals: + void valuesChanged(); + + private: + SpacingTokens* m_tokens = nullptr; }; -class Deform : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class AppearancePadding : public settings::ObjectNode { + CONFIG_NODE(AppearancePadding, settings::ObjectNode) - CFG_PROPERTY(qreal, scale, 1) + CONFIG_PROPERTY(qreal, scale, 1) + + Q_PROPERTY(int extraSmall READ extraSmall NOTIFY valuesChanged) + Q_PROPERTY(int smaller READ smaller NOTIFY valuesChanged) + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int medium READ medium NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY valuesChanged) + Q_PROPERTY( + int extraLargeIncreased READ extraLargeIncreased NOTIFY valuesChanged) + Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY valuesChanged) public: - explicit Deform(QObject* parent = nullptr) : ConfigObject(parent) {} -}; + void bindTokens(PaddingTokens* tokens); -class FontFamily : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS + [[nodiscard]] int extraSmall() const; + [[nodiscard]] int smaller() const; + [[nodiscard]] int small() const; + [[nodiscard]] int medium() const; + [[nodiscard]] int large() const; + [[nodiscard]] int largeIncreased() const; + [[nodiscard]] int extraLarge() const; + [[nodiscard]] int extraLargeIncreased() const; + [[nodiscard]] int extraExtraLarge() const; - CFG_PROPERTY(QString, clock, QStringLiteral("Rubik")) - CFG_PROPERTY(QString, material, QStringLiteral("Material Symbols Rounded")) - CFG_PROPERTY(QString, mono, QStringLiteral("CaskaydiaCove NF")) - CFG_PROPERTY(QString, sans, QStringLiteral("Segoe UI Variable Text")) + signals: + void valuesChanged(); - public: - explicit FontFamily(QObject* parent = nullptr) : ConfigObject(parent) {} + private: + PaddingTokens* m_tokens = nullptr; }; namespace detail { @@ -105,20 +130,19 @@ struct FontConfig { int size; int weight = QFont::Normal; bool italic = false; - QVariantMap vaxes = {{QStringLiteral("ROND"), 25}}; + QVariantMap vaxes = {{u"ROND"_s, 25}}; }; } // namespace detail -class FontSizeNode : public ConfigObject { - public: - explicit FontSizeNode(QObject* parent = nullptr) : ConfigObject(parent) {} +class FontSizeNode : public settings::ObjectNode { + CONFIG_NODE(FontSizeNode, settings::ObjectNode) }; -class FontStyleNode : public ConfigObject { - public: - explicit FontStyleNode(QObject* parent = nullptr) : ConfigObject(parent) {} +class FontStyleNode : public settings::ObjectNode { + CONFIG_NODE(FontStyleNode, settings::ObjectNode) + public: virtual FontSizeNode* largeNode() const = 0; virtual FontSizeNode* mediumNode() const = 0; virtual FontSizeNode* smallNode() const = 0; @@ -129,17 +153,16 @@ class FontStyleNode : public ConfigObject { #define FONT(...) ZShell::config::detail::FontConfig __VA_ARGS__ #define FONT_CONFIG(Style, Size, props) \ class FontConfig##Style##Size : public FontSizeNode { \ - Q_OBJECT QML_ANONYMOUS \ + CONFIG_NODE(FontConfig##Style##Size, FontSizeNode) \ \ - CFG_PROPERTY(QString, family, ARG(props).family) \ - CFG_PROPERTY(int, size, ARG(props).size) \ - CFG_PROPERTY(int, weight, ARG(props).weight) \ - CFG_PROPERTY(bool, italic, ARG(props).italic) \ - CFG_PROPERTY(QVariantMap, vaxes, ARG(props).vaxes) \ - \ - public \ - : explicit FontConfig##Style##Size(QObject* parent = nullptr) \ - : FontSizeNode(parent) {} \ + CONFIG_PROPERTY( \ + QString, \ + family, \ + ARG(props).family) /* Empty inherits the style family */ \ + CONFIG_PROPERTY(int, size, ARG(props).size) \ + CONFIG_PROPERTY(int, weight, ARG(props).weight) \ + CONFIG_PROPERTY(bool, italic, ARG(props).italic) \ + CONFIG_PROPERTY(QVariantMap, vaxes, ARG(props).vaxes) \ }; #define FONT_STYLE(Style, family_, large_, medium_, small_) \ FONT_CONFIG(Style, Large, ARG(large_)) \ @@ -147,47 +170,41 @@ class FontStyleNode : public ConfigObject { FONT_CONFIG(Style, Small, ARG(small_)) \ \ class FontStyle##Style : public FontStyleNode { \ - Q_OBJECT QML_ANONYMOUS \ + CONFIG_NODE(FontStyle##Style, FontStyleNode) \ \ - CFG_PROPERTY(QString, family, family_) \ - CONFIG_SUBOBJECT(FontConfig##Style##Large, large) \ - CONFIG_SUBOBJECT(FontConfig##Style##Medium, medium) \ - CONFIG_SUBOBJECT(FontConfig##Style##Small, small) \ - \ - public \ - : explicit FontStyle##Style(QObject* parent = nullptr) \ - : FontStyleNode(parent) \ - , m_large(new FontConfig##Style##Large(this)) \ - , m_medium(new FontConfig##Style##Medium(this)) \ - , m_small(new FontConfig##Style##Small(this)) {} \ + CONFIG_PROPERTY(QString, family, family_) \ + CONFIG_SUBOBJECT(FontConfig##Style##Large, large) \ + CONFIG_SUBOBJECT(FontConfig##Style##Medium, medium) \ + CONFIG_SUBOBJECT(FontConfig##Style##Small, small) \ \ + public: \ FontSizeNode* largeNode() const override { return m_large; } \ FontSizeNode* mediumNode() const override { return m_medium; } \ FontSizeNode* smallNode() const override { return m_small; } \ }; // clang-format off -FONT_STYLE(Headline, QStringLiteral("GoogleSansFlex"), +FONT_STYLE(Headline, u"GoogleSansFlex"_s, FONT({ .size = 32, .weight = QFont::Medium }), FONT({ .size = 28, .weight = QFont::Medium }), FONT({ .size = 24, .weight = QFont::Medium }) ) -FONT_STYLE(Title, QStringLiteral("GoogleSansFlex"), +FONT_STYLE(Title, u"GoogleSansFlex"_s, FONT({ .size = 22, .weight = QFont::Medium }), FONT({ .size = 16, .weight = QFont::Medium }), FONT({ .size = 14, .weight = QFont::Medium }) ) -FONT_STYLE(Body, QStringLiteral("GoogleSansFlex"), +FONT_STYLE(Body, u"GoogleSansFlex"_s, FONT({ .size = 16 }), FONT({ .size = 14 }), FONT({ .size = 12 }) ) -FONT_STYLE(Label, QStringLiteral("GoogleSansFlex"), +FONT_STYLE(Label, u"GoogleSansFlex"_s, FONT({ .size = 14, .weight = QFont::Medium }), FONT({ .size = 12, .weight = QFont::Medium }), FONT({ .size = 11 }) ) -FONT_STYLE(Mono, QStringLiteral("CaskaydiaCove NF"), +FONT_STYLE(Mono, u"CaskaydiaCove NF"_s, FONT({ .size = 16, .vaxes = {} }), FONT({ .size = 14, .vaxes = {} }), FONT({ .size = 12, .vaxes = {} }) @@ -200,23 +217,15 @@ FONT_CONFIG(Icon, Medium, FONT({.size = static_cast(24 / 1.33)})) FONT_CONFIG(Icon, Small, FONT({.size = static_cast(20 / 1.33)})) class FontStyleIcon : public FontStyleNode { - Q_OBJECT - QML_ANONYMOUS + CONFIG_NODE(FontStyleIcon, FontStyleNode) - CFG_PROPERTY(QString, family, QStringLiteral("Material Symbols Rounded")) + CONFIG_PROPERTY(QString, family, u"Material Symbols Rounded"_s) CONFIG_SUBOBJECT(FontConfigIconExtraLarge, extraLarge) CONFIG_SUBOBJECT(FontConfigIconLarge, large) CONFIG_SUBOBJECT(FontConfigIconMedium, medium) CONFIG_SUBOBJECT(FontConfigIconSmall, small) public: - explicit FontStyleIcon(QObject* parent = nullptr) - : FontStyleNode(parent) - , m_extraLarge(new FontConfigIconExtraLarge(this)) - , m_large(new FontConfigIconLarge(this)) - , m_medium(new FontConfigIconMedium(this)) - , m_small(new FontConfigIconSmall(this)) {} - FontSizeNode* extraLargeNode() const override { return m_extraLarge; } FontSizeNode* largeNode() const override { return m_large; } FontSizeNode* mediumNode() const override { return m_medium; } @@ -228,11 +237,20 @@ class FontStyleIcon : public FontStyleNode { #undef FONT_CONFIG #undef FONT_STYLE -class AppearanceFont : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class FontFamily : public settings::ObjectNode { + CONFIG_NODE(FontFamily, settings::ObjectNode) - CFG_PROPERTY(qreal, scale, 1) + CONFIG_PROPERTY(QString, clock, QStringLiteral("Rubik")) + CONFIG_PROPERTY( + QString, material, QStringLiteral("Material Symbols Rounded")) + CONFIG_PROPERTY(QString, mono, QStringLiteral("CaskaydiaCove NF")) + CONFIG_PROPERTY(QString, sans, QStringLiteral("Segoe UI Variable Text")) +}; + +class AppearanceFont : public settings::ObjectNode { + CONFIG_NODE(AppearanceFont, settings::ObjectNode) + + CONFIG_PROPERTY(qreal, scale, 1) CONFIG_SUBOBJECT(FontFamily, family) CONFIG_SUBOBJECT(FontStyleHeadline, headline) CONFIG_SUBOBJECT(FontStyleTitle, title) @@ -240,160 +258,83 @@ class AppearanceFont : public ConfigObject { CONFIG_SUBOBJECT(FontStyleLabel, label) CONFIG_SUBOBJECT(FontStyleMono, mono) CONFIG_SUBOBJECT(FontStyleIcon, icon) - CFG_PROPERTY(QString, clock, QStringLiteral("Rubik")) - CFG_PROPERTY(QString, workspaces, QStringLiteral("Rubik")) - - public: - explicit AppearanceFont(QObject* parent = nullptr) - : ConfigObject(parent) - , m_family(new FontFamily(this)) - , m_headline(new FontStyleHeadline(this)) - , m_title(new FontStyleTitle(this)) - , m_body(new FontStyleBody(this)) - , m_label(new FontStyleLabel(this)) - , m_mono(new FontStyleMono(this)) - , m_icon(new FontStyleIcon(this)) {} + CONFIG_PROPERTY(QString, clock, QStringLiteral("Rubik")) + CONFIG_PROPERTY(QString, workspaces, QStringLiteral("Rubik")) }; -class Transparency : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class AnimDurations : public settings::ObjectNode { + CONFIG_NODE(AnimDurations, settings::ObjectNode) - CFG_PROPERTY(qreal, base, 0.85) - CFG_PROPERTY(bool, enabled, false) - CFG_PROPERTY(qreal, layers, 0.4) + CONFIG_GLOBAL_PROPERTY(qreal, scale, 1) - public: - explicit Transparency(QObject* parent = nullptr) : ConfigObject(parent) {} -}; - -class AppearanceRounding : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(qreal, scale, 1) - - Q_PROPERTY(int extraSmall READ extraSmall NOTIFY scaleChanged) - Q_PROPERTY(int small READ small NOTIFY scaleChanged) - Q_PROPERTY(int medium READ medium NOTIFY scaleChanged) - Q_PROPERTY(int large READ large NOTIFY scaleChanged) - Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY scaleChanged) - Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) + Q_PROPERTY(int small READ small NOTIFY valuesChanged) + Q_PROPERTY(int normal READ normal NOTIFY valuesChanged) + Q_PROPERTY(int large READ large NOTIFY valuesChanged) + Q_PROPERTY(int extraLarge READ extraLarge NOTIFY valuesChanged) Q_PROPERTY( - int extraLargeIncreased READ extraLargeIncreased NOTIFY scaleChanged) - Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY scaleChanged) - Q_PROPERTY(int full READ full CONSTANT) - - public: - explicit AppearanceRounding(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] int extraSmall() const { - return static_cast(4 * scale()); - } - [[nodiscard]] int small() const { return static_cast(8 * scale()); } - [[nodiscard]] int medium() const { return static_cast(12 * scale()); } - [[nodiscard]] int large() const { return static_cast(16 * scale()); } - [[nodiscard]] int largeIncreased() const { - return static_cast(20 * scale()); - } - [[nodiscard]] int extraLarge() const { - return static_cast(28 * scale()); - } - [[nodiscard]] int extraLargeIncreased() const { - return static_cast(32 * scale()); - } - [[nodiscard]] int extraExtraLarge() const { - return static_cast(48 * scale()); - } - [[nodiscard]] int full() const { return std::numeric_limits::max(); } -}; - -class AppearanceSpacing : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(qreal, scale, 1) - - Q_PROPERTY(int extraSmall READ extraSmall NOTIFY scaleChanged) - Q_PROPERTY(int small READ small NOTIFY scaleChanged) - Q_PROPERTY(int medium READ medium NOTIFY scaleChanged) - Q_PROPERTY(int large READ large NOTIFY scaleChanged) - Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY scaleChanged) - Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) + int expressiveFastSpatial READ expressiveFastSpatial NOTIFY + valuesChanged) Q_PROPERTY( - int extraLargeIncreased READ extraLargeIncreased NOTIFY scaleChanged) - Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY scaleChanged) - - public: - explicit AppearanceSpacing(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] int extraSmall() const { - return static_cast(4 * scale()); - } - [[nodiscard]] int small() const { return static_cast(8 * scale()); } - [[nodiscard]] int medium() const { return static_cast(12 * scale()); } - [[nodiscard]] int large() const { return static_cast(16 * scale()); } - [[nodiscard]] int largeIncreased() const { - return static_cast(20 * scale()); - } - [[nodiscard]] int extraLarge() const { - return static_cast(28 * scale()); - } - [[nodiscard]] int extraLargeIncreased() const { - return static_cast(32 * scale()); - } - [[nodiscard]] int extraExtraLarge() const { - return static_cast(48 * scale()); - } -}; - -class AppearancePadding : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(qreal, scale, 1) - - Q_PROPERTY(int extraSmall READ extraSmall NOTIFY scaleChanged) - Q_PROPERTY(int smaller READ smaller NOTIFY scaleChanged) - Q_PROPERTY(int small READ small NOTIFY scaleChanged) - Q_PROPERTY(int medium READ medium NOTIFY scaleChanged) - Q_PROPERTY(int large READ large NOTIFY scaleChanged) - Q_PROPERTY(int largeIncreased READ largeIncreased NOTIFY scaleChanged) - Q_PROPERTY(int extraLarge READ extraLarge NOTIFY scaleChanged) + int expressiveDefaultSpatial READ expressiveDefaultSpatial NOTIFY + valuesChanged) Q_PROPERTY( - int extraLargeIncreased READ extraLargeIncreased NOTIFY scaleChanged) - Q_PROPERTY(int extraExtraLarge READ extraExtraLarge NOTIFY scaleChanged) + int expressiveSlowSpatial READ expressiveSlowSpatial NOTIFY + valuesChanged) + Q_PROPERTY( + int expressiveFastEffects READ expressiveFastEffects NOTIFY + valuesChanged) + Q_PROPERTY( + int expressiveDefaultEffects READ expressiveDefaultEffects NOTIFY + valuesChanged) + Q_PROPERTY( + int expressiveSlowEffects READ expressiveSlowEffects NOTIFY + valuesChanged) public: - explicit AppearancePadding(QObject* parent = nullptr) - : ConfigObject(parent) {} + void bindTokens(AnimDurationTokens* tokens); - [[nodiscard]] int extraSmall() const { - return static_cast(4 * scale()); - } - [[nodiscard]] int smaller() const { return static_cast(5 * scale()); } - [[nodiscard]] int small() const { return static_cast(8 * scale()); } - [[nodiscard]] int medium() const { return static_cast(12 * scale()); } - [[nodiscard]] int large() const { return static_cast(16 * scale()); } - [[nodiscard]] int largeIncreased() const { - return static_cast(20 * scale()); - } - [[nodiscard]] int extraLarge() const { - return static_cast(28 * scale()); - } - [[nodiscard]] int extraLargeIncreased() const { - return static_cast(32 * scale()); - } - [[nodiscard]] int extraExtraLarge() const { - return static_cast(48 * scale()); - } + [[nodiscard]] int small() const; + [[nodiscard]] int normal() const; + [[nodiscard]] int large() const; + [[nodiscard]] int extraLarge() const; + [[nodiscard]] int expressiveFastSpatial() const; + [[nodiscard]] int expressiveDefaultSpatial() const; + [[nodiscard]] int expressiveSlowSpatial() const; + [[nodiscard]] int expressiveFastEffects() const; + [[nodiscard]] int expressiveDefaultEffects() const; + [[nodiscard]] int expressiveSlowEffects() const; + + signals: + void valuesChanged(); + + private: + AnimDurationTokens* m_tokens = nullptr; }; -class Appearance : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Anim : public settings::ObjectNode { + CONFIG_NODE(Anim, settings::ObjectNode) + + CONFIG_PROPERTY(int, mediaGifSpeedAdjustment, 300) + CONFIG_PROPERTY(qreal, sessionGifSpeed, 0.7) + CONFIG_SUBOBJECT(AnimDurations, durations) +}; + +class Deform : public settings::ObjectNode { + CONFIG_NODE(Deform, settings::ObjectNode) + + CONFIG_PROPERTY(qreal, scale, 1) +}; + +class AppearanceTransparency : public settings::ObjectNode { + CONFIG_NODE(AppearanceTransparency, settings::ObjectNode) + + CONFIG_GLOBAL_PROPERTY(bool, enabled, false) + CONFIG_GLOBAL_PROPERTY(qreal, base, 0.85) + CONFIG_GLOBAL_PROPERTY(qreal, layers, 0.4) +}; + +class Appearance : public settings::ObjectNode { + CONFIG_NODE(Appearance, settings::ObjectNode) CONFIG_SUBOBJECT(Anim, anim) CONFIG_SUBOBJECT(Deform, deform) @@ -401,18 +342,7 @@ class Appearance : public ConfigObject { CONFIG_SUBOBJECT(AppearanceRounding, rounding) CONFIG_SUBOBJECT(AppearanceSpacing, spacing) CONFIG_SUBOBJECT(AppearancePadding, padding) - CONFIG_SUBOBJECT(Transparency, transparency) - - public: - explicit Appearance(QObject* parent = nullptr) - : ConfigObject(parent) - , m_anim(new Anim(this)) - , m_deform(new Deform(this)) - , m_font(new AppearanceFont(this)) - , m_rounding(new AppearanceRounding(this)) - , m_spacing(new AppearanceSpacing(this)) - , m_padding(new AppearancePadding(this)) - , m_transparency(new Transparency(this)) {}; + CONFIG_SUBOBJECT(AppearanceTransparency, transparency) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/background.hpp b/Plugins/ZShell/Config/background.hpp index 98331f8..d079a2a 100644 --- a/Plugins/ZShell/Config/background.hpp +++ b/Plugins/ZShell/Config/background.hpp @@ -1,18 +1,14 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class Background : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Background : public settings::ObjectNode { + CONFIG_NODE(Background, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(int, wallFadeDuration, 300) - - public: - explicit Background(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, wallFadeDuration, 300) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/bar.hpp b/Plugins/ZShell/Config/bar.hpp index 4a0c8ae..25f4c66 100644 --- a/Plugins/ZShell/Config/bar.hpp +++ b/Plugins/ZShell/Config/bar.hpp @@ -1,7 +1,6 @@ #pragma once -#include "configlist.hpp" -#include "configobject.hpp" +#include "common.hpp" #include #include #include @@ -9,66 +8,53 @@ namespace ZShell::config { class BarEntry : public ListEntry { - Q_OBJECT - QML_ANONYMOUS - - public: - explicit BarEntry(QObject* parent = nullptr) : ListEntry(parent) {} + CONFIG_NODE(BarEntry, ListEntry) }; -class Popouts : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Popouts : public settings::ObjectNode { + CONFIG_NODE(Popouts, settings::ObjectNode) - CFG_PROPERTY(bool, statusIcons, true) - CFG_PROPERTY(bool, tray, true) - - public: - explicit Popouts(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, statusIcons, true) + CONFIG_PROPERTY(bool, tray, true) }; -class Tray : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Tray : public settings::ObjectNode { + CONFIG_NODE(Tray, settings::ObjectNode) - CFG_PROPERTY(bool, recolorIcons, false) - CFG_PROPERTY(bool, showOnHover, true) - CFG_PROPERTY(int, trayIconSize, 24) + CONFIG_PROPERTY(bool, recolorIcons, false) + CONFIG_PROPERTY(bool, showOnHover, true) + CONFIG_PROPERTY(int, trayIconSize, 24) CONFIG_LIST( EntryList, statusIcons, - { + DEFAULT_ARG({ LIST_ENTRY(audio, true), LIST_ENTRY(microphone, true), LIST_ENTRY(bluetooth, false), LIST_ENTRY(network, false), LIST_ENTRY(wifi, false), LIST_ENTRY(power, true), - }) - - public: - explicit Tray(QObject* parent = nullptr) : ConfigObject(parent) {} + })) }; -class Bar : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Bar : public settings::ObjectNode { + CONFIG_NODE(Bar, settings::ObjectNode) - CFG_PROPERTY(bool, autoHide, false) - CFG_PROPERTY(int, border, 4) - CFG_PROPERTY(bool, fullscreenReveal, true) - CFG_PROPERTY(int, height, 24) - CFG_PROPERTY(bool, hideWhenNotif, true) + CONFIG_PROPERTY(bool, autoHide, false) + CONFIG_PROPERTY(int, border, 4) + CONFIG_PROPERTY(bool, fullscreenReveal, true) + CONFIG_PROPERTY(int, height, 24) + CONFIG_PROPERTY(bool, hideWhenNotif, true) CONFIG_SUBOBJECT(Popouts, popouts) - CFG_PROPERTY(int, revealDelay, 100) - CFG_PROPERTY(int, rounding, 14) - CFG_PROPERTY(int, smoothing, 32) - CFG_PROPERTY(QString, position, QStringLiteral("top")) + CONFIG_PROPERTY(int, revealDelay, 100) + CONFIG_PROPERTY(int, rounding, 14) + CONFIG_PROPERTY(int, smoothing, 32) + CONFIG_PROPERTY(QString, position, QStringLiteral("top")) CONFIG_SUBOBJECT(Tray, tray) CONFIG_LIST( EntryList, entries, - { + DEFAULT_ARG({ LIST_ENTRY(workspaces, true), LIST_ENTRY(audio, true), LIST_ENTRY(media, true), @@ -85,13 +71,7 @@ class Bar : public ConfigObject { LIST_ENTRY(upower, true), LIST_ENTRY(clock, true), LIST_ENTRY(notifBell, true), - }) - - public: - explicit Bar(QObject* parent = nullptr) - : ConfigObject(parent) - , m_popouts(new Popouts(this)) - , m_tray(new Tray(this)) {} + })) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/clipboard.hpp b/Plugins/ZShell/Config/clipboard.hpp index b2c0708..321c861 100644 --- a/Plugins/ZShell/Config/clipboard.hpp +++ b/Plugins/ZShell/Config/clipboard.hpp @@ -1,33 +1,24 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class ClipboardSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class ClipboardSizes : public settings::ObjectNode { + CONFIG_NODE(ClipboardSizes, settings::ObjectNode) - CFG_PROPERTY(int, itemHeight, 60) - CFG_PROPERTY(int, minPreviewWidth, 200) - CFG_PROPERTY(int, previewWidth, 800) - CFG_PROPERTY(int, width, 500) - - public: - explicit ClipboardSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, itemHeight, 60) + CONFIG_PROPERTY(int, minPreviewWidth, 200) + CONFIG_PROPERTY(int, previewWidth, 800) + CONFIG_PROPERTY(int, width, 500) }; -class Clipboard : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Clipboard : public settings::ObjectNode { + CONFIG_NODE(Clipboard, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(int, maxEntriesShown, 10) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, maxEntriesShown, 10) CONFIG_SUBOBJECT(ClipboardSizes, sizes) - - public: - explicit Clipboard(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new ClipboardSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/colors.hpp b/Plugins/ZShell/Config/colors.hpp index 0554df6..fd904e6 100644 --- a/Plugins/ZShell/Config/colors.hpp +++ b/Plugins/ZShell/Config/colors.hpp @@ -1,31 +1,29 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class Presets : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Presets : public settings::ObjectNode { + CONFIG_NODE(Presets, settings::ObjectNode) - CFG_PROPERTY(QString, accent, QString()) - CFG_PROPERTY(QString, name, QStringLiteral("Catppuccin")) - CFG_PROPERTY(QString, variant, QStringLiteral("latte")) - - public: - explicit Presets(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(QString, accent, QString()) + CONFIG_PROPERTY(QString, name, QStringLiteral("Catppuccin")) + CONFIG_PROPERTY(QString, variant, QStringLiteral("latte")) }; -class Colors : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Colors : public settings::ObjectNode { + CONFIG_NODE(Colors, settings::ObjectNode) CONFIG_SUBOBJECT(Presets, presets) - CFG_PROPERTY(QString, schemeType, QStringLiteral("fidelity")) - - public: - explicit Colors(QObject* parent = nullptr) - : ConfigObject(parent), m_presets(new Presets(this)) {} + CONFIG_PROPERTY(QString, schemeType, QStringLiteral("fidelity")) + CONFIG_PROPERTY(bool, scheduleDark, false) + CONFIG_PROPERTY(int, scheduleDarkEnd, 600) + CONFIG_PROPERTY(int, scheduleDarkStart, 1140) + CONFIG_PROPERTY(bool, schemeGen, true) + CONFIG_PROPERTY(bool, neovimColors, false) + CONFIG_PROPERTY(QString, mode, QStringLiteral("dark")) + CONFIG_PROPERTY(bool, smart, false) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/common.cpp b/Plugins/ZShell/Config/common.cpp new file mode 100644 index 0000000..b9ba202 --- /dev/null +++ b/Plugins/ZShell/Config/common.cpp @@ -0,0 +1,22 @@ +#include "common.hpp" + +#include +#include + +namespace ZShell::config { + +Q_LOGGING_CATEGORY(lcConfig, "zshell.config", QtInfoMsg) + +QString configDir() { + const QString dir = + QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + + QStringLiteral("/zshell"); + QDir().mkpath(dir); + return dir; +} + +QString monitorConfigDir() { + return configDir() + QStringLiteral("/monitors"); +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/common.hpp b/Plugins/ZShell/Config/common.hpp new file mode 100644 index 0000000..c96cf57 --- /dev/null +++ b/Plugins/ZShell/Config/common.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +#include "settings/macros.hpp" +#include "settings/objectnode.hpp" + +#define CONFIG_NODE_NO_CTOR SETTINGS_NODE_NO_CTOR +#define CONFIG_NODE SETTINGS_NODE +#define CONFIG_PROPERTY SETTINGS_PROPERTY +#define CONFIG_GLOBAL_PROPERTY SETTINGS_GLOBAL_PROPERTY + +#define CONFIG_ENUM_PROPERTY(Type, name, defaultVal) \ + CONFIG_PROPERTY(ZShell::config::Type::Enum, name, defaultVal) +#define CONFIG_GLOBAL_ENUM_PROPERTY(Type, name, defaultVal) \ + CONFIG_GLOBAL_PROPERTY(ZShell::config::Type::Enum, name, defaultVal) + +#define CONFIG_SUBOBJECT(Type, name) \ + SETTINGS_SUBOBJECT(ZShell::config::Type, name) +#define CONFIG_GLOBAL_SUBOBJECT(Type, name) \ + SETTINGS_GLOBAL_SUBOBJECT(ZShell::config::Type, name) + +#define CONFIG_LIST_TYPE SETTINGS_LIST_TYPE +#define CONFIG_LIST(Type, name, defaultVal, ...) \ + SETTINGS_LIST( \ + ZShell::config::Type, name, DEFAULT_ARG(defaultVal), __VA_ARGS__) +#define CONFIG_GLOBAL_LIST(Type, name, defaultVal, ...) \ + SETTINGS_GLOBAL_LIST( \ + ZShell::config::Type, name, DEFAULT_ARG(defaultVal), __VA_ARGS__) + +namespace ZShell::config { + +Q_DECLARE_LOGGING_CATEGORY(lcConfig) + +QString configDir(); +QString monitorConfigDir(); + +class ListEntry : public settings::ObjectNode { + CONFIG_NODE(ListEntry, settings::ObjectNode) + + CONFIG_PROPERTY(QString, id, {}) + CONFIG_PROPERTY(bool, enabled, true) +}; +CONFIG_LIST_TYPE(ListEntry, EntryList) + +} // namespace ZShell::config + +// Shorthand for declaring an ID'd entry (bar entries/status icons, quick toggles, etc) +#define LIST_ENTRY(id, enabled) \ + settings::vmap({ \ + {QStringLiteral("id"), QStringLiteral(#id)}, \ + {QStringLiteral("enabled"), enabled}, \ + }) diff --git a/Plugins/ZShell/Config/config.cpp b/Plugins/ZShell/Config/config.cpp deleted file mode 100644 index 268b4c1..0000000 --- a/Plugins/ZShell/Config/config.cpp +++ /dev/null @@ -1,278 +0,0 @@ -#include "config.hpp" - -#include "appearance.hpp" -#include "background.hpp" -#include "bar.hpp" -#include "clipboard.hpp" -#include "colors.hpp" -#include "dashboard.hpp" -#include "dock.hpp" -#include "general.hpp" -#include "launcher.hpp" -#include "lock.hpp" -#include "notifs.hpp" -#include "osd.hpp" -#include "screenshot.hpp" -#include "services.hpp" -#include "sidebar.hpp" -#include "utilities.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace ZShell::config { - -Config::Config(QObject* parent) - : ConfigObject(parent) - , m_appearance(new Appearance(this)) - , m_background(new Background(this)) - , m_bar(new Bar(this)) - , m_clipboard(new Clipboard(this)) - , m_colors(new Colors(this)) - , m_dashboard(new Dashboard(this)) - , m_dock(new Dock(this)) - , m_general(new General(this)) - , m_launcher(new Launcher(this)) - , m_lock(new Lock(this)) - , m_notifs(new Notifs(this)) - , m_osd(new Osd(this)) - , m_screenshot(new Screenshot(this)) - , m_services(new Services(this)) - , m_sidebar(new Sidebar(this)) - , m_utilities(new Utilities(this)) { - connect(this, &ConfigObject::propertiesChanged, this, &Config::scheduleSave); - - m_saveTimer.setSingleShot(true); - m_saveTimer.setInterval(300); - connect(&m_saveTimer, &QTimer::timeout, this, &Config::flushAsync); - - m_reloadTimer.setSingleShot(true); - m_reloadTimer.setInterval(50); - connect(&m_reloadTimer, &QTimer::timeout, this, &Config::reloadAsync); - - connectAutoSave(this); - - connect( - &m_watcher, - &QFileSystemWatcher::directoryChanged, - this, - &Config::onWatcherEvent); - - connect( - &m_watcher, - &QFileSystemWatcher::fileChanged, - this, - &Config::onWatcherEvent); - - loadSync(); - updateWatch(); - - m_firstLoadDone = true; -} - -Config* Config::create(QQmlEngine*, QJSEngine*) { - return new Config(); -} - -QString Config::filePath() const { - const QString dir = - QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + - QStringLiteral("/zshell"); - QDir().mkpath(dir); - return dir + QStringLiteral("/config.json"); -} - -void Config::loadSync() { - m_loading = true; - - QFile f(filePath()); - QJsonObject before; - - bool existed = false; - - if (f.open(QIODevice::ReadOnly)) { - const auto doc = QJsonDocument::fromJson(f.readAll()); - if (doc.isObject()) { - before = doc.object(); - existed = true; - } else { - qInfo() << "Config: existing config at" << filePath() - << "is empty or not a valid JSON object - using defaults"; - } - } else { - qInfo() << "Config: no existing config at" << filePath() - << "- using defaults"; - } - - loadFromJson(QJsonValue(before)); - m_loading = false; - - const auto after = toJson().toObject(); - if (!existed || after != before) saveNow(); -} - -void Config::updateWatch() { - const QFileInfo info(filePath()); - - const QString dir = info.absolutePath(); - const QString file = info.absoluteFilePath(); - - if (!m_watcher.directories().contains(dir)) m_watcher.addPath(dir); - if (info.exists() && !m_watcher.files().contains(file)) - m_watcher.addPath(file); -} - -void Config::onWatcherEvent(const QString&) { - updateWatch(); - - const QFileInfo info(filePath()); - if (!info.exists()) return; - - QFile file(info.absoluteFilePath()); - if (!file.open(QIODevice::ReadOnly)) return; - - const QByteArray hash = - QCryptographicHash::hash(file.readAll(), QCryptographicHash::Sha256); - - { - QMutexLocker lock(&m_writeMutex); - - if (hash == m_lastWriteHash) return; - } - - m_reloadPending = true; - - if (!m_loading) m_reloadTimer.start(); -} - -void Config::loadAsync() { - m_loading = true; - - auto future = QtConcurrent::run([this]() { - QFile f(filePath()); - - if (f.open(QIODevice::ReadOnly)) { - const auto doc = QJsonDocument::fromJson(f.readAll()); - const bool valid = doc.isObject(); - const QJsonObject before = valid ? doc.object() : QJsonObject(); - - QMetaObject::invokeMethod( - this, - [this, valid, before]() { - loadFromJson(QJsonValue(before)); - m_loading = false; - - const auto after = toJson().toObject(); - if (!valid || after != before) saveNow(); - - if (m_reloadPending) m_reloadTimer.start(); - }, - Qt::QueuedConnection); - } else { - qInfo() << "Config: failed to reload from" << filePath() - << "- using in-memory values"; - - QMetaObject::invokeMethod( - this, - [this]() { - m_loading = false; - - if (m_reloadPending) m_reloadTimer.start(); - }, - Qt::QueuedConnection); - } - }); - - m_loadFuture = future; -} - -void Config::load() { - if (!m_firstLoadDone) { - loadSync(); - } else { - loadAsync(); - } -} - -void Config::reloadAsync() { - if (m_loading) return; - - m_reloadPending = false; - loadAsync(); -} - -void Config::scheduleSave() { - if (m_loading) return; - m_saveTimer.start(); -} - -void Config::flushAsync() { - const QByteArray data = - QJsonDocument(toJson().toObject()).toJson(QJsonDocument::Indented); - - const QByteArray hash = - QCryptographicHash::hash(data, QCryptographicHash::Sha256); - - QThreadPool::globalInstance()->start([this, data, hash]() { - const bool success = writeAtomically(data); - - QMetaObject::invokeMethod( - this, - [this, success, hash]() { - if (!success) return; - - QMutexLocker lock(&m_writeMutex); - m_lastWriteHash = hash; - }, - Qt::QueuedConnection); - }); -} - -void Config::saveNow() { - m_saveTimer.stop(); - flushAsync(); -} - -void Config::connectAutoSave(ConfigNode* node) { - connect(node, &ConfigNode::propertiesChanged, this, [this] { - scheduleSave(); - }); - - for (auto* child : node->childNodes()) - connectAutoSave(child); -} - -bool Config::writeAtomically(const QByteArray& data) { - QSaveFile f(filePath()); - - if (!f.open(QIODevice::WriteOnly)) { - qWarning() << "Config: failed to open for atomic write:" - << f.errorString(); - return false; - } - - if (f.write(data) != data.size()) { - qWarning() << "Config: failed to write:" << f.errorString(); - return false; - } - - if (!f.commit()) { - qWarning() << "Config: commit failed:" << f.errorString(); - return false; - } - - return true; -} - -}; // namespace ZShell::config diff --git a/Plugins/ZShell/Config/config.hpp b/Plugins/ZShell/Config/config.hpp deleted file mode 100644 index e1881c5..0000000 --- a/Plugins/ZShell/Config/config.hpp +++ /dev/null @@ -1,108 +0,0 @@ -#pragma once - -#include "configobject.hpp" - -#include -#include -#include -#include -#include -#include -#include - -class QQmlEngine; -class QJSEngine; - -namespace ZShell::config { - -class Appearance; -class Background; -class Bar; -class Clipboard; -class Colors; -class Dashboard; -class Dock; -class General; -class Launcher; -class Lock; -class Notifs; -class Osd; -class Screenshot; -class Services; -class Sidebar; -class Utilities; - -class Config : public ConfigObject { - Q_OBJECT - QML_ELEMENT - QML_SINGLETON - - Q_MOC_INCLUDE("appearance.hpp") - Q_MOC_INCLUDE("background.hpp") - Q_MOC_INCLUDE("bar.hpp") - Q_MOC_INCLUDE("clipboard.hpp") - Q_MOC_INCLUDE("colors.hpp") - Q_MOC_INCLUDE("dashboard.hpp") - Q_MOC_INCLUDE("dock.hpp") - Q_MOC_INCLUDE("general.hpp") - Q_MOC_INCLUDE("launcher.hpp") - Q_MOC_INCLUDE("lock.hpp") - Q_MOC_INCLUDE("notifs.hpp") - Q_MOC_INCLUDE("osd.hpp") - Q_MOC_INCLUDE("screenshot.hpp") - Q_MOC_INCLUDE("services.hpp") - Q_MOC_INCLUDE("sidebar.hpp") - Q_MOC_INCLUDE("utilities.hpp") - - CONFIG_SUBOBJECT(Appearance, appearance) - CONFIG_SUBOBJECT(Background, background) - CONFIG_SUBOBJECT(Bar, bar) - CONFIG_SUBOBJECT(Clipboard, clipboard) - CONFIG_SUBOBJECT(Colors, colors) - CONFIG_SUBOBJECT(Dashboard, dashboard) - CONFIG_SUBOBJECT(Dock, dock) - CONFIG_SUBOBJECT(General, general) - CONFIG_SUBOBJECT(Launcher, launcher) - CONFIG_SUBOBJECT(Lock, lock) - CONFIG_SUBOBJECT(Notifs, notifs) - CONFIG_SUBOBJECT(Osd, osd) - CONFIG_SUBOBJECT(Screenshot, screenshot) - CONFIG_SUBOBJECT(Services, services) - CONFIG_SUBOBJECT(Sidebar, sidebar) - CONFIG_SUBOBJECT(Utilities, utilities) - - public: - explicit Config(QObject* parent = nullptr); - static Config* create(QQmlEngine*, QJSEngine*); - - Q_INVOKABLE void load(); - Q_INVOKABLE void saveNow(); - Q_INVOKABLE void reloadAsync(); - - private Q_SLOTS: - void scheduleSave(); - void flushAsync(); - void onWatcherEvent(const QString& path); - - private: - QString filePath() const; - bool writeAtomically(const QByteArray& data); - void updateWatch(); - void loadSync(); - void loadAsync(); - void connectAutoSave(ConfigNode* node); - - QTimer m_saveTimer; - QTimer m_reloadTimer; - QFileSystemWatcher m_watcher; - - QMutex m_writeMutex; - QByteArray m_lastWriteHash; - - bool m_reloadPending = false; - bool m_loading = false; - bool m_firstLoadDone = false; - QFuture m_loadFuture; -}; - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/configlist.cpp b/Plugins/ZShell/Config/configlist.cpp deleted file mode 100644 index 4f75cda..0000000 --- a/Plugins/ZShell/Config/configlist.cpp +++ /dev/null @@ -1,314 +0,0 @@ -#include "configlist.hpp" - -#include -#include -#include -#include - -namespace ZShell::config { - -ConfigList::ConfigList(QObject* parent, const QVariantList& defaults) - : ConfigNode(parent), m_defaults(QJsonArray::fromVariantList(defaults)) {} - -int ConfigList::count() const { - return static_cast(m_items.size()); -} - -QVariantList ConfigList::values() const { - QVariantList vals; - vals.reserve(m_items.size()); - for (auto* const item : m_items) - vals.append(QVariant::fromValue(item)); - return vals; -} - -ConfigObject* ConfigList::itemAt(int index) const { - if (index < 0 || index >= m_items.size()) return nullptr; - - return m_items.at(index); -} - -const QList& ConfigList::items() const { - return m_items; -} - -void ConfigList::remove(int index) { - if (index < 0 || index >= m_items.size()) { - qCWarning(lcConfig) - << metaObject()->className() << "cannot remove index" << index - << "of" << count(); - return; - } - - destroyItem(m_items.takeAt(index)); - - m_loaded = true; - emit countChanged(); - emit valuesChanged(); - notifyChanged(); -} - -void ConfigList::move(int from, int to) { - if (from < 0 || from >= m_items.size() || to < 0 || to >= m_items.size()) { - qCWarning(lcConfig) << metaObject()->className() << "cannot move" - << from << "to" << to << "of" << count(); - return; - } - - if (from == to) return; - - m_items.move(from, to); - - m_loaded = true; - emit valuesChanged(); - notifyChanged(); -} - -void ConfigList::clear() { - if (m_items.isEmpty()) return; - - destroyItems(); - - m_loaded = true; - emit countChanged(); - emit valuesChanged(); - notifyChanged(); -} - -void ConfigList::loadFromJson(const QJsonValue& json) { - if (!json.isArray()) { - qCWarning( - lcConfig, - "Option '%s' must be a list, resetting to default", - qUtf8Printable(propertyPath())); - - resetToDefaults(); - materializeDefaults(); - return; - } - - populate(json.toArray()); - m_loaded = true; -} - -QJsonValue ConfigList::toJson() const { - if (m_loaded) return elementsToJson(); - - return QJsonValue::Undefined; -} - -void ConfigList::clearLoadedKeys() { - m_loaded = false; -} - -QStringList ConfigList::unknownKeys() const { - QStringList keys; - - for (int i = 0; i < m_items.size(); ++i) { - const auto childKeys = m_items.at(i)->unknownKeys(); - for (const auto& childKey : childKeys) - keys.append(joinPath(QString("[%1]").arg(i), childKey)); - } - - return keys; -} - -void ConfigList::materializeDefaults() { - for (auto* const item : m_items) - item->materializeDefaults(); - - if (m_global) return; - - m_loaded = true; -} - -void ConfigList::resyncFromGlobal() { - syncValuesFromGlobal(); -} - -ConfigObject* ConfigList::insertItem(const QVariantMap& props, int index) { - const auto pos = index < 0 || index > m_items.size() ? m_items.size() - : index; - - appendItem(QJsonObject::fromVariantMap(props)); - m_items.move(m_items.size() - 1, pos); - - auto* const item = m_items.at(pos); - const auto unknown = item->unknownKeys(); - for (const auto& key : unknown) - qCWarning(lcConfig) << "Unknown option" << key << "for" - << item->metaObject()->className(); - - m_loaded = true; - emit countChanged(); - emit valuesChanged(); - notifyChanged(); - - return item; -} - -QJsonArray ConfigList::elementsToJson() const { - QJsonArray arr; - - for (auto* const item : m_items) - arr.append(item->toJson().toObject()); - - return arr; -} - -void ConfigList::resetToDefaults() { - loadFromJsonQuietly(m_defaults); - m_loaded = false; -} - -void ConfigList::syncValuesFromGlobal() { - if (m_loaded) return; - - if (auto* const global = qobject_cast(m_global)) - populate(global->elementsToJson()); -} - -void ConfigList::onGlobalPropertiesChanged(const QMap&) { - syncValuesFromGlobal(); -} - -QString ConfigList::childPath(const ConfigNode* child) const { - for (int i = 0; i < m_items.size(); ++i) - if (m_items.at(i) == child) return QString("[%1]").arg(i); - - return {}; -} - -void ConfigList::populate(const QJsonArray& arr) { - const auto current = elementsToJson(); - if (current == arr) return; - - const auto old = m_items; - QList reused(old.size(), false); - - m_items.clear(); - m_items.reserve(arr.size()); - - for (int i = 0; i < arr.size(); ++i) { - auto match = - i < old.size() && !reused.at(i) && current.at(i) == arr.at(i) ? i - : -1; - - for (int j = 0; match < 0 && j < old.size(); ++j) - if (!reused.at(j) && current.at(j) == arr.at(i)) match = j; - - auto update = false; - for (int j = 0; match < 0 && j < old.size(); ++j) { - if (!reused.at(j) && - sameElement(old.at(j), current.at(j), arr.at(i))) { - match = j; - update = true; - } - } - - if (match < 0) { - appendItem(arr.at(i)); - continue; - } - - reused[match] = true; - - if (update) old.at(match)->loadFromJsonQuietly(arr.at(i)); - - m_items.append(old.at(match)); - } - - for (int i = 0; i < old.size(); ++i) - if (!reused.at(i)) destroyItem(old.at(i)); - - if (m_items.size() != old.size()) emit countChanged(); - - emit valuesChanged(); - - notifyChanged(); -} - -bool ConfigList::sameElement( - const ConfigObject* item, - const QJsonValue& current, - const QJsonValue& json) { - const auto keys = item->identityKeys(); - if (keys.isEmpty()) return false; - - const auto currentObj = current.toObject(); - const auto newObj = json.toObject(); - - if (currentObj.keys() != newObj.keys()) return false; - - for (const auto& key : keys) - if (!newObj.contains(key) || currentObj.value(key) != newObj.value(key)) - return false; - - return true; -} - -void ConfigList::appendItem(const QJsonValue& json) { - auto* const item = createItem(); - QQmlEngine::setObjectOwnership(item, QQmlEngine::CppOwnership); - - if (m_items.isEmpty()) rejectGlobalOnlyElement(item); - - item->loadFromJsonQuietly(json); - - connectItem(item); - - m_items.append(item); -} - -void ConfigList::rejectGlobalOnlyElement(const ConfigObject* item) const { - const auto keys = item->globalOnlyKeys(); - if (keys.isEmpty()) return; - - qCCritical( - lcConfig, - "%s declares global-only options (%s) which do not work inside a list, " - "use CONFIG_GLOBAL_LIST on %s instead", - item->metaObject()->className(), - qUtf8Printable(keys.join(", ")), - metaObject()->className()); -} - -void ConfigList::connectItem(ConfigNode* node) { - connect( - node, &ConfigNode::propertiesChanged, this, &ConfigList::onItemChanged); - - const auto children = node->childNodes(); - for (auto* const child : children) - connectItem(child); -} - -void ConfigList::disconnectItem(ConfigNode* node) { - node->disconnect(this); - - const auto children = node->childNodes(); - for (auto* const child : children) - disconnectItem(child); -} - -void ConfigList::destroyItems() { - for (auto* const item : std::as_const(m_items)) - destroyItem(item); - - m_items.clear(); -} - -void ConfigList::destroyItem(ConfigObject* item) { - disconnectItem(item); - item->deleteLater(); -} - -void ConfigList::onItemChanged() { - m_loaded = true; - notifyChanged(); -} - -void ConfigList::notifyChanged() { - notifyPropertyChanged("values", count()); -} - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/configlist.hpp b/Plugins/ZShell/Config/configlist.hpp deleted file mode 100644 index 5787526..0000000 --- a/Plugins/ZShell/Config/configlist.hpp +++ /dev/null @@ -1,139 +0,0 @@ -#pragma once - -#include "configobject.hpp" - -#include -#include -#include -#include - -namespace ZShell::config { - -class ConfigList : public ConfigNode { - Q_OBJECT - QML_ANONYMOUS - - Q_PROPERTY(int count READ count NOTIFY countChanged) - Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged) - - public: - explicit ConfigList( - QObject* parent = nullptr, const QVariantList& defaults = {}); - - [[nodiscard]] int count() const; - [[nodiscard]] QVariantList values() const; - [[nodiscard]] ConfigObject* itemAt(int index) const; - [[nodiscard]] const QList& items() const; - - Q_INVOKABLE void remove(int index); - Q_INVOKABLE void move(int from, int to); - Q_INVOKABLE void clear(); - - void loadFromJson(const QJsonValue& json) override; - [[nodiscard]] QJsonValue toJson() const override; - void clearLoadedKeys() override; - [[nodiscard]] QStringList unknownKeys() const override; - void materializeDefaults() override; - void resyncFromGlobal() override; - - signals: - void countChanged(); - void valuesChanged(); - - protected: - virtual ConfigObject* createItem() = 0; - - void resetToDefaults(); - - ConfigObject* insertItem(const QVariantMap& props, int index = -1); - - [[nodiscard]] QJsonArray elementsToJson() const; - - void syncValuesFromGlobal() override; - void onGlobalPropertiesChanged( - const QMap& changed) override; - [[nodiscard]] QString childPath(const ConfigNode* child) const override; - - private: - void populate(const QJsonArray& arr); - static bool sameElement( - const ConfigObject* item, - const QJsonValue& current, - const QJsonValue& json); - void appendItem(const QJsonValue& json); - void destroyItems(); - void destroyItem(ConfigObject* item); - void rejectGlobalOnlyElement(const ConfigObject* item) const; - void connectItem(ConfigNode* node); - void disconnectItem(ConfigNode* node); - void onItemChanged(); - void notifyChanged(); - - QJsonArray m_defaults; - QList m_items; - bool m_loaded = false; -}; - -} // namespace ZShell::config - -#define CONFIG_LIST_TYPE(Element, Name) \ - class Name : public ZShell::config::ConfigList { \ - Q_OBJECT \ - QML_ANONYMOUS \ - \ - public: \ - explicit Name( \ - QObject* parent = nullptr, const QVariantList& defaults = {}) \ - : ZShell::config::ConfigList(parent, defaults) { \ - resetToDefaults(); \ - } \ - \ - Q_INVOKABLE ZShell::config::Element* at(int index) const { \ - return static_cast(itemAt(index)); \ - } \ - Q_INVOKABLE ZShell::config::Element* insert( \ - const QVariantMap& props, int index = -1) { \ - return static_cast( \ - insertItem(props, index)); \ - } \ - \ - protected: \ - [[nodiscard]] ZShell::config::ConfigObject* createItem() override { \ - return new ZShell::config::Element(this); \ - } \ - }; - -#define CONFIG_LIST(Type, name, ...) \ - Q_PROPERTY(ZShell::config::Type* name READ name CONSTANT) \ - \ - public: \ - [[nodiscard]] Type* name() const { \ - return m_##name; \ - } \ - \ - private: \ - Type* m_##name = new Type(this __VA_OPT__(, __VA_ARGS__)); - -#define LIST_ENTRY(id, enabled) \ - vmap({{"id", QString::fromUtf8(#id)}, {"enabled", enabled}}) - -namespace ZShell::config { - -class ListEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(QString, id) - CFG_PROPERTY(bool, enabled, true) - - public: - explicit ListEntry(QObject* parent = nullptr) : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("id")}; - } -}; - -CONFIG_LIST_TYPE(ListEntry, EntryList) - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/confignode.cpp b/Plugins/ZShell/Config/confignode.cpp deleted file mode 100644 index b120c6e..0000000 --- a/Plugins/ZShell/Config/confignode.cpp +++ /dev/null @@ -1,89 +0,0 @@ -#include "confignode.hpp" - -#include -#include -#include -#include - -namespace ZShell::config { - -Q_LOGGING_CATEGORY(lcConfig, "zshell.config") - -ConfigNode::ConfigNode(QObject* parent) : QObject(parent) { - m_batchTimer = new QTimer(this); - m_batchTimer->setSingleShot(true); - m_batchTimer->setInterval(0); - connect( - m_batchTimer, &QTimer::timeout, this, &ConfigNode::emitBatchedChanges); -} - -void ConfigNode::loadFromJsonQuietly(const QJsonValue& json) { - setNotifySuppressed(true); - loadFromJson(json); - setNotifySuppressed(false); -} - -void ConfigNode::setNotifySuppressed(bool suppressed) { - m_suppressNotify = suppressed; - - const auto children = childNodes(); - for (auto* const child : children) - child->setNotifySuppressed(suppressed); -} - -QList ConfigNode::childNodes() const { - return {}; -} - -void ConfigNode::syncFromGlobal(ConfigNode* global) { - m_global = global; - resyncFromGlobal(); -} - -bool ConfigNode::isOverlay() const { - return m_global != nullptr && this != m_global; -} - -QString ConfigNode::propertyPath(const QString& name) const { - if (name.isEmpty()) return objectName(); - - QObject* const parentObj = parent(); - if (parentObj) { - if (auto* parentNode = qobject_cast(parentObj)) { - const auto childName = parentNode->childPath(this); - if (!childName.isEmpty()) { - return joinPath(parentNode->propertyPath(childName), name); - } - } - } - return name; -} - -int ConfigNode::basePropertyOffset() { - return QObject::staticMetaObject.propertyCount(); -} - -void ConfigNode::notifyPropertyChanged( - const QString& name, const QVariant& value) { - if (m_suppressNotify) return; - - m_pendingChanges[name] = value; - if (m_batchTimer && !m_batchTimer->isActive()) m_batchTimer->start(); -} - -QString ConfigNode::joinPath(const QString& parent, const QString& child) { - // List elements are addressed as parent[i], everything else as parent.child - if (child.startsWith(QLatin1Char('['))) return parent + child; - - return parent + QLatin1Char('.') + child; -} - -void ConfigNode::emitBatchedChanges() { - if (m_pendingChanges.isEmpty()) return; - - QMap changes = m_pendingChanges; - m_pendingChanges.clear(); - emit propertiesChanged(changes); -} - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/confignode.hpp b/Plugins/ZShell/Config/confignode.hpp deleted file mode 100644 index d4e4389..0000000 --- a/Plugins/ZShell/Config/confignode.hpp +++ /dev/null @@ -1,65 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -namespace ZShell::config { - -Q_DECLARE_LOGGING_CATEGORY(lcConfig) - -class ConfigNode : public QObject { - Q_OBJECT - - public: - explicit ConfigNode(QObject* parent = nullptr); - - virtual void loadFromJson(const QJsonValue& json) = 0; - void loadFromJsonQuietly(const QJsonValue& json); - [[nodiscard]] virtual QJsonValue toJson() const = 0; - - virtual void clearLoadedKeys() = 0; - [[nodiscard]] virtual QStringList unknownKeys() const = 0; - [[nodiscard]] virtual QList childNodes() const; - - virtual void materializeDefaults() = 0; - - void syncFromGlobal(ConfigNode* global); - virtual void resyncFromGlobal() = 0; - - [[nodiscard]] bool isOverlay() const; - [[nodiscard]] QString propertyPath(const QString& name = {}) const; - - [[nodiscard]] static int basePropertyOffset(); - - signals: - void propertiesChanged(const QMap& changed); - - protected: - virtual void syncValuesFromGlobal() = 0; - virtual void onGlobalPropertiesChanged( - const QMap& changed) = 0; - - [[nodiscard]] virtual QString childPath(const ConfigNode* child) const = 0; - - void notifyPropertyChanged(const QString& name, const QVariant& value); - - [[nodiscard]] static QString joinPath( - const QString& parent, const QString& child); - - ConfigNode* m_global = nullptr; - - private: - void setNotifySuppressed(bool suppressed); - void emitBatchedChanges(); - - QMap m_pendingChanges; - QTimer* m_batchTimer = nullptr; - bool m_suppressNotify = false; -}; - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/configobject.cpp b/Plugins/ZShell/Config/configobject.cpp deleted file mode 100644 index 1b0d01d..0000000 --- a/Plugins/ZShell/Config/configobject.cpp +++ /dev/null @@ -1,336 +0,0 @@ -#include "configobject.hpp" - -#include -#include -#include -#include - -namespace ZShell::config { - -namespace { - -bool isStringArray(const QJsonArray& arr) { - for (const auto& v : arr) { - if (!v.isString()) return false; - } - return true; -} - -bool jsonValueMatchesType(const QJsonValue& val, QMetaType type) { - switch (val.type()) { - case QJsonValue::Bool: - return type.id() == QMetaType::Bool; - - case QJsonValue::Double: - switch (type.id()) { - case QMetaType::Int: - case QMetaType::UInt: - case QMetaType::LongLong: - case QMetaType::ULongLong: - case QMetaType::Double: - case QMetaType::Float: - return true; - default: - return false; - } - - case QJsonValue::String: - return type.id() == QMetaType::QString; - - case QJsonValue::Array: - if (type.id() == QMetaType::QStringList) - return isStringArray(val.toArray()); - return type.id() == QMetaType::QVariantList; - - case QJsonValue::Object: - case QJsonValue::Null: - case QJsonValue::Undefined: - default: - return false; - } -} - -} // namespace - -ConfigObject::ConfigObject(QObject* parent) : ConfigNode(parent) {} - -void ConfigObject::loadFromJson(const QJsonValue& json) { - const auto obj = json.toObject(); - const auto* meta = metaObject(); - - QSet known; - QSet invalid; - - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - auto prop = meta->property(i); - const auto key = QString::fromUtf8(prop.name()); - - if (!obj.contains(key)) continue; - - const auto jsonVal = obj.value(key); - - if (prop.metaType().flags().testFlag(QMetaType::PointerToQObject)) { - known.insert(key); - if (auto* const node = prop.read(this).value()) { - node->loadFromJson(jsonVal); - continue; - } - } - - if (!prop.isWritable()) continue; - - known.insert(key); - - if (!jsonValueMatchesType(jsonVal, prop.metaType())) { - qWarning() << "Config: type mismatch for" << key << "in" - << meta->className() << "- expected" - << prop.metaType().name() << "got value" << jsonVal - << "- resetting to default"; - invalid.insert(key); - continue; - } - - if (prop.metaType().id() == QMetaType::QStringList) { - QStringList list; - const auto jsonArr = jsonVal.toArray(); - for (const auto& v : jsonArr) - list.append(v.toString()); - prop.write(this, QVariant::fromValue(list)); - m_loadedKeys.insert(key); - continue; - } - - if (prop.metaType().id() == QMetaType::QVariantList) { - prop.write(this, jsonVal.toVariant()); - m_loadedKeys.insert(key); - continue; - } - - prop.write(this, jsonVal.toVariant()); - m_loadedKeys.insert(key); - } - - m_extras = {}; - for (auto it = obj.begin(); it != obj.end(); ++it) { - if (!known.contains(it.key()) || invalid.contains(it.key())) { - m_extras.insert(it.key(), it.value()); - } - } - - materializeDefaults(); -} - -QJsonValue ConfigObject::toJson() const { - QJsonObject obj; - const auto* meta = metaObject(); - - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - - if (!prop.isReadable()) continue; - - const auto key = QString::fromUtf8(prop.name()); - - const auto value = prop.read(this); - - if (prop.metaType().flags().testFlag(QMetaType::PointerToQObject)) { - if (auto* const node = value.value()) { - const auto childJson = node->toJson(); - if (!childJson.isUndefined()) obj.insert(key, childJson); - continue; - } - } - - if (!m_loadedKeys.contains(key)) continue; - - if (!prop.isWritable()) continue; - - if (prop.metaType().id() == QMetaType::QStringList) { - QJsonArray arr; - const auto strList = value.toStringList(); - for (const auto& s : strList) - arr.append(s); - obj.insert(key, arr); - continue; - } - - if (prop.metaType().id() == QMetaType::QVariantList) { - obj.insert(key, QJsonArray::fromVariantList(value.toList())); - continue; - } - - obj.insert(key, QJsonValue::fromVariant(value)); - } - - if (obj.isEmpty()) return QJsonValue::Undefined; - - return obj; -} - -void ConfigObject::clearLoadedKeys() { - m_loadedKeys.clear(); - m_extras = {}; - - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - auto prop = meta->property(i); - if (auto* const node = prop.read(this).value()) - node->clearLoadedKeys(); - } -} - -QStringList ConfigObject::identityKeys() const { - return {}; -} - -QStringList ConfigObject::unknownKeys() const { - auto keys = m_extras.keys(); - - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - auto* const node = prop.read(this).value(); - if (!node) continue; - - const auto childKeys = node->unknownKeys(); - for (const auto& childKey : childKeys) - keys.append(joinPath(QString::fromUtf8(prop.name()), childKey)); - } - - return keys; -} - -QList ConfigObject::childNodes() const { - QList nodes; - - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - - if (auto* const node = prop.read(this).value()) - nodes.append(node); - } - - return nodes; -} - -void ConfigObject::materializeDefaults() { - const auto* meta = metaObject(); - - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - - if (auto* const node = prop.read(this).value()) - node->materializeDefaults(); - } -} - -void ConfigObject::syncValuesFromGlobal() { - const auto* meta = metaObject(); - - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - auto prop = meta->property(i); - const auto key = QString::fromUtf8(prop.name()); - - if (auto* const node = prop.read(this).value()) { - if (auto* const globalNode = - prop.read(m_global).value()) - node->syncFromGlobal(globalNode); - continue; - } - - if (!prop.isWritable()) continue; - - if (!m_loadedKeys.contains(key)) { - auto val = prop.read(m_global); - prop.write(this, val); - m_loadedKeys.remove(key); - } - } -} - -void ConfigObject::resyncFromGlobal() { - if (!m_global) return; - - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - auto prop = meta->property(i); - const auto key = QString::fromUtf8(prop.name()); - - if (auto* const node = prop.read(this).value()) { - node->resyncFromGlobal(); - continue; - } - - if (!prop.isWritable()) continue; - - if (!m_loadedKeys.contains(key)) { - prop.write(this, prop.read(m_global)); - m_loadedKeys.remove(key); - } - } -} - -bool ConfigObject::isPropertyLoaded(const QString& name) const { - return m_loadedKeys.contains(name); -} - -bool ConfigObject::isGlobalOnly(const QString& name) const { - return m_globalOnlyKeys.contains(name); -} - -QStringList ConfigObject::globalOnlyKeys() const { - return {m_globalOnlyKeys.begin(), m_globalOnlyKeys.end()}; -} - -void ConfigObject::resetOption(const QString& name) { - m_loadedKeys.remove(name); - - const int idx = metaObject()->indexOfProperty(name.toUtf8().constData()); - if (idx < 0) return; - - const auto prop = metaObject()->property(idx); - - if (auto* const node = prop.read(this).value()) { - node->clearLoadedKeys(); - node->resyncFromGlobal(); - return; - } - - if (m_global && prop.isWritable()) prop.write(this, prop.read(m_global)); -} - -void ConfigObject::onGlobalPropertiesChanged( - const QMap& changed) { - for (auto it = changed.begin(); it != changed.end(); ++it) { - if (m_loadedKeys.contains(it.key()) || isGlobalOnly(it.key())) continue; - - int idx = metaObject()->indexOfProperty(it.key().toUtf8().constData()); - if (idx >= 0) { - metaObject()->property(idx).write(this, it.value()); - m_loadedKeys.remove(it.key()); - } - } -} - -QString ConfigObject::childPath(const ConfigNode* child) const { - const auto* meta = metaObject(); - for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - - if (prop.read(this).value() == child) - return QString::fromUtf8(prop.name()); - } - - return {}; -} - -void ConfigObject::markPropertyLoaded(const QString& name) { - m_loadedKeys.insert(name); -} - -void ConfigObject::markGlobalOnly(const QString& name) { - m_globalOnlyKeys.insert(name); -} - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/configobject.hpp b/Plugins/ZShell/Config/configobject.hpp deleted file mode 100644 index 7de39ed..0000000 --- a/Plugins/ZShell/Config/configobject.hpp +++ /dev/null @@ -1,101 +0,0 @@ -#pragma once - -#include "confignode.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace ZShell::config { - -inline QVariantMap vmap( - std::initializer_list> entries) { - QVariantMap map; - for (const auto& [key, value] : entries) - map.insert(key, value); - return map; -} - -#define CFG_PROPERTY(Type, name, ...) \ - Q_PROPERTY(Type name READ name WRITE set_##name NOTIFY name##Changed) \ - public: \ - [[nodiscard]] Type name() const { \ - return m_##name; \ - } \ - void set_##name(const Type& value) { \ - if (ConfigObject::updateMember(m_##name, value)) { \ - markPropertyLoaded(QStringLiteral(#name)); \ - Q_EMIT name##Changed(); \ - notifyPropertyChanged( \ - QStringLiteral(#name), QVariant::fromValue(m_##name)); \ - } \ - } \ - Q_SIGNAL void name##Changed(); \ - \ - private: \ - Type m_##name __VA_OPT__(= __VA_ARGS__); - -#define CONFIG_SUBOBJECT(Type, name) \ - Q_PROPERTY(ZShell::config::Type* name READ name CONSTANT) \ - \ - public: \ - [[nodiscard]] Type* name() const { \ - return m_##name; \ - } \ - \ - private: \ - Type* m_##name = nullptr; - -class ConfigObject : public ConfigNode { - Q_OBJECT - - public: - explicit ConfigObject(QObject* parent = nullptr); - - void loadFromJson(const QJsonValue& json) override; - [[nodiscard]] QJsonValue toJson() const override; - void clearLoadedKeys() override; - [[nodiscard]] QStringList unknownKeys() const override; - [[nodiscard]] QList childNodes() const override; - void materializeDefaults() override; - void resyncFromGlobal() override; - - [[nodiscard]] virtual QStringList identityKeys() const; - - [[nodiscard]] bool isPropertyLoaded(const QString& name) const; - [[nodiscard]] bool isGlobalOnly(const QString& name) const; - [[nodiscard]] QStringList globalOnlyKeys() const; - - Q_INVOKABLE void resetOption(const QString& name); - - template static bool updateMember(T& member, const T& value) { - if constexpr (std::is_floating_point_v) { - if (qFuzzyCompare(member + 1.0, value + 1.0)) return false; - } else { - if (member == value) return false; - } - member = value; - return true; - } - - protected: - void syncValuesFromGlobal() override; - void onGlobalPropertiesChanged( - const QMap& changed) override; - [[nodiscard]] QString childPath(const ConfigNode* child) const override; - - void markPropertyLoaded(const QString& name); - void markGlobalOnly(const QString& name); - - private: - QSet m_loadedKeys; - QSet m_globalOnlyKeys; - QJsonObject m_extras; -}; - -} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/dashboard.hpp b/Plugins/ZShell/Config/dashboard.hpp index 271c6f5..3160a48 100644 --- a/Plugins/ZShell/Config/dashboard.hpp +++ b/Plugins/ZShell/Config/dashboard.hpp @@ -1,65 +1,68 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class Performance : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Performance : public settings::ObjectNode { + CONFIG_NODE(Performance, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(bool, showBattery, false) - CFG_PROPERTY(bool, showCpu, true) - CFG_PROPERTY(bool, showGpu, true) - CFG_PROPERTY(bool, showMemory, true) - CFG_PROPERTY(bool, showNetwork, true) - CFG_PROPERTY(bool, showStorage, true) - CFG_PROPERTY(bool, showVram, true) - - public: - explicit Performance(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(bool, showBattery, false) + CONFIG_PROPERTY(bool, showCpu, true) + CONFIG_PROPERTY(bool, showGpu, true) + CONFIG_PROPERTY(bool, showMemory, true) + CONFIG_PROPERTY(bool, showNetwork, true) + CONFIG_PROPERTY(bool, showStorage, true) + CONFIG_PROPERTY(bool, showVram, true) }; -class DashboardSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class DashboardSizes : public settings::ObjectNode { + CONFIG_NODE(DashboardSizes, settings::ObjectNode) - CFG_PROPERTY(int, dateTimeWidth, 110) - CFG_PROPERTY(int, infoIconSize, 25) - CFG_PROPERTY(int, infoWidth, 200) - CFG_PROPERTY(int, mediaCoverArtSize, 150) - CFG_PROPERTY(int, mediaProgressSweep, 180) - CFG_PROPERTY(int, mediaProgressThickness, 8) - CFG_PROPERTY(int, mediaVisualizerSize, 200) - CFG_PROPERTY(int, mediaWidth, 200) - CFG_PROPERTY(int, resourceProgessThickness, 10) - CFG_PROPERTY(int, resourceSize, 200) - CFG_PROPERTY(int, tabIndicatorHeight, 3) - CFG_PROPERTY(int, tabIndicatorSpacing, 5) - CFG_PROPERTY(int, weatherWidth, 250) - - public: - explicit DashboardSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, dateTimeWidth, 110) + CONFIG_PROPERTY(int, infoIconSize, 25) + CONFIG_PROPERTY(int, infoWidth, 200) + CONFIG_PROPERTY(int, mediaCoverArtSize, 150) + CONFIG_PROPERTY(int, mediaProgressSweep, 180) + CONFIG_PROPERTY(int, mediaProgressThickness, 8) + CONFIG_PROPERTY(int, mediaVisualizerSize, 200) + CONFIG_PROPERTY(int, mediaWidth, 200) + CONFIG_PROPERTY(int, resourceProgessThickness, 10) + CONFIG_PROPERTY(int, resourceSize, 200) + CONFIG_PROPERTY(int, tabIndicatorHeight, 3) + CONFIG_PROPERTY(int, tabIndicatorSpacing, 5) + CONFIG_PROPERTY(int, weatherWidth, 250) }; -class Dashboard : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class ProcessColumnEntry : public settings::ObjectNode { + CONFIG_NODE(ProcessColumnEntry, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(bool, showOnHover, true) - CFG_PROPERTY(int, dragThreshold, 50) - CFG_PROPERTY(int, mediaUpdateInterval, 2000) + CONFIG_PROPERTY(QString, id, {}) + CONFIG_PROPERTY(double, width, 0) +}; + +CONFIG_LIST_TYPE(ProcessColumnEntry, ProcessColumnList) + +class Dashboard : public settings::ObjectNode { + CONFIG_NODE(Dashboard, settings::ObjectNode) + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(bool, showOnHover, true) + CONFIG_PROPERTY(int, dragThreshold, 50) + CONFIG_PROPERTY(int, mediaUpdateInterval, 2000) CONFIG_SUBOBJECT(Performance, performance) - CFG_PROPERTY(int, resourceUpdateInterval, 1000) + CONFIG_PROPERTY(int, resourceUpdateInterval, 1000) CONFIG_SUBOBJECT(DashboardSizes, sizes) - - public: - explicit Dashboard(QObject* parent = nullptr) - : ConfigObject(parent) - , m_performance(new Performance(this)) - , m_sizes(new DashboardSizes(this)) {} + CONFIG_LIST( + ProcessColumnList, + processColumns, + DEFAULT_ARG({ + settings::vmap({{"id", "command"}}), + settings::vmap({{"id", "pid"}}), + settings::vmap({{"id", "cpuUsage"}}), + settings::vmap({{"id", "memoryUsage"}}), + })) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/display.hpp b/Plugins/ZShell/Config/display.hpp new file mode 100644 index 0000000..c36da44 --- /dev/null +++ b/Plugins/ZShell/Config/display.hpp @@ -0,0 +1,24 @@ +#pragma once +#include "common.hpp" +#include + +namespace ZShell::config { + +class Nightlight : public settings::ObjectNode { + CONFIG_NODE(Nightlight, settings::ObjectNode) + + CONFIG_PROPERTY(bool, schedule, true) + CONFIG_PROPERTY(int, scheduleStart, 1200) + CONFIG_PROPERTY(int, scheduleEnd, 570) + CONFIG_PROPERTY(bool, useNative, true) + CONFIG_PROPERTY(int, fadeDuration, 2000) + CONFIG_PROPERTY(int, temp, 2600) +}; + +class Display : public settings::ObjectNode { + CONFIG_NODE(Display, settings::ObjectNode) + + CONFIG_SUBOBJECT(Nightlight, nightlight) +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/dock.hpp b/Plugins/ZShell/Config/dock.hpp index 817ce9a..1ea4e10 100644 --- a/Plugins/ZShell/Config/dock.hpp +++ b/Plugins/ZShell/Config/dock.hpp @@ -1,29 +1,25 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include #include namespace ZShell::config { -class Dock : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Dock : public settings::ObjectNode { + CONFIG_NODE(Dock, settings::ObjectNode) - CFG_PROPERTY(bool, enable, false) - CFG_PROPERTY(int, height, 80) - CFG_PROPERTY(bool, hoverToReveal, false) - CFG_PROPERTY(QStringList, ignoredAppRegexes, {}) - CFG_PROPERTY( + CONFIG_PROPERTY(bool, enable, false) + CONFIG_PROPERTY(int, height, 80) + CONFIG_PROPERTY(bool, hoverToReveal, false) + CONFIG_PROPERTY(QStringList, ignoredAppRegexes, {}) + CONFIG_PROPERTY( QStringList, pinnedApps, (QStringList{ QStringLiteral("com.ayugram.desktop"), QStringLiteral("com.obsproject.studio"), QStringLiteral("librewolf")})) - CFG_PROPERTY(bool, pinnedOnStartup, false) - - public: - explicit Dock(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, pinnedOnStartup, false) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/font.cpp b/Plugins/ZShell/Config/font.cpp index 57038f5..8e89cab 100644 --- a/Plugins/ZShell/Config/font.cpp +++ b/Plugins/ZShell/Config/font.cpp @@ -1,7 +1,7 @@ #include "font.hpp" #include "appearance.hpp" -#include "config.hpp" +#include "rootnodes.hpp" namespace ZShell::config { @@ -59,20 +59,20 @@ void FontStyleBase::bind(FontStyleNode* cfg) { if (cfg) { connect( - cfg, &ConfigNode::propertiesChanged, this, &FontStyleBase::rebuild); + cfg, &settings::Node::optionChanged, this, &FontStyleBase::rebuild); connect( cfg->largeNode(), - &ConfigNode::propertiesChanged, + &settings::Node::optionChanged, this, &FontStyleBase::rebuild); connect( cfg->mediumNode(), - &ConfigNode::propertiesChanged, + &settings::Node::optionChanged, this, &FontStyleBase::rebuild); connect( cfg->smallNode(), - &ConfigNode::propertiesChanged, + &settings::Node::optionChanged, this, &FontStyleBase::rebuild); } @@ -128,7 +128,7 @@ void IconFontStyle::bind(FontStyleNode* cfg) { if (cfg) connect( cfg->extraLargeNode(), - &ConfigNode::propertiesChanged, + &settings::Node::optionChanged, this, &IconFontStyle::rebuild); } @@ -181,16 +181,14 @@ FontBuilder IconFontBuilders::extraLarge() const { } FontTokens::FontTokens(QObject* parent) - : ConfigObject(parent) - , m_size(new FontSizeTokens(this)) + : QObject(parent) , m_headline(new FontStyle(this)) , m_title(new FontStyle(this)) , m_body(new FontStyle(this)) , m_label(new FontStyle(this)) , m_mono(new FontStyle(this)) , m_icon(new IconFontStyle(this)) { - if (!Config::instance()) new Config(); - bindFont(Config::instance()->appearance()->font()); + bindFont(ConfigSingleton::instance()->appearance()->font()); } FontStyle* FontTokens::headline() const { diff --git a/Plugins/ZShell/Config/font.hpp b/Plugins/ZShell/Config/font.hpp index 9625457..ac5c4a7 100644 --- a/Plugins/ZShell/Config/font.hpp +++ b/Plugins/ZShell/Config/font.hpp @@ -1,6 +1,5 @@ #pragma once -#include "configobject.hpp" #include "fontbuilder.hpp" #include @@ -17,21 +16,6 @@ class FontSizeNode; class FontStyleNode; class IconFontStyle; -class FontSizeTokens : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(int, small, 11) - CFG_PROPERTY(int, smaller, 12) - CFG_PROPERTY(int, normal, 13) - CFG_PROPERTY(int, larger, 15) - CFG_PROPERTY(int, large, 18) - CFG_PROPERTY(int, extraLarge, 28) - - public: - explicit FontSizeTokens(QObject* parent = nullptr) : ConfigObject(parent) {} -}; - class FontBuilders : public QObject { Q_OBJECT QML_ANONYMOUS @@ -149,12 +133,10 @@ class IconFontStyle : public FontStyleBase { IconFontBuilders* m_builders; }; -class FontTokens : public ConfigObject { +class FontTokens : public QObject { Q_OBJECT QML_ANONYMOUS - CONFIG_SUBOBJECT(FontSizeTokens, size) - Q_PROPERTY(FontStyle* headline READ headline CONSTANT FINAL) Q_PROPERTY(FontStyle* title READ title CONSTANT FINAL) Q_PROPERTY(FontStyle* body READ body CONSTANT FINAL) diff --git a/Plugins/ZShell/Config/general.hpp b/Plugins/ZShell/Config/general.hpp index 1195e6f..c90b4cf 100644 --- a/Plugins/ZShell/Config/general.hpp +++ b/Plugins/ZShell/Config/general.hpp @@ -1,165 +1,103 @@ #pragma once -#include "configlist.hpp" -#include "configobject.hpp" +#include "common.hpp" #include #include namespace ZShell::config { -class BatteryThresholdEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class BatteryThresholdEntry : public settings::ObjectNode { + CONFIG_NODE(BatteryThresholdEntry, settings::ObjectNode) - CFG_PROPERTY(QString, icon) - CFG_PROPERTY(QString, message) - CFG_PROPERTY(QString, name) - CFG_PROPERTY(int, perc) - - public: - explicit BatteryThresholdEntry(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("perc")}; - } + CONFIG_PROPERTY(QString, icon, {}) + CONFIG_PROPERTY(QString, message, {}) + CONFIG_PROPERTY(QString, name, {}) + CONFIG_PROPERTY(int, perc, {}) }; CONFIG_LIST_TYPE(BatteryThresholdEntry, BatteryThresholdList) -class IdleTimeoutEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class IdleTimeoutEntry : public settings::ObjectNode { + CONFIG_NODE(IdleTimeoutEntry, settings::ObjectNode) - CFG_PROPERTY(QString, idleAction) - CFG_PROPERTY(QString, name) - CFG_PROPERTY(int, timeout) - - public: - explicit IdleTimeoutEntry(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("name")}; - } + CONFIG_PROPERTY(QString, idleAction, {}) + CONFIG_PROPERTY(QString, name, {}) + CONFIG_PROPERTY(int, timeout, {}) }; CONFIG_LIST_TYPE(IdleTimeoutEntry, IdleTimeoutList) -class Apps : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Apps : public settings::ObjectNode { + CONFIG_NODE(Apps, settings::ObjectNode) - CFG_PROPERTY(QStringList, archiver, {QStringLiteral("ark")}) - CFG_PROPERTY(QStringList, audio, {QStringLiteral("pavucontrol")}) - CFG_PROPERTY(QStringList, document, {QStringLiteral("libreoffice")}) - CFG_PROPERTY(QStringList, editor, {QStringLiteral("kate")}) - CFG_PROPERTY(QStringList, explorer, {QStringLiteral("dolphin")}) - CFG_PROPERTY(QStringList, image, {QStringLiteral("imv")}) - CFG_PROPERTY(QStringList, playback, {QStringLiteral("vlc")}) - CFG_PROPERTY(QStringList, terminal, {QStringLiteral("wezterm")}) - - public: - explicit Apps(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(QStringList, archiver, {QStringLiteral("ark")}) + CONFIG_PROPERTY(QStringList, audio, {QStringLiteral("pavucontrol")}) + CONFIG_PROPERTY(QStringList, document, {QStringLiteral("libreoffice")}) + CONFIG_PROPERTY(QStringList, editor, {QStringLiteral("kate")}) + CONFIG_PROPERTY(QStringList, explorer, {QStringLiteral("dolphin")}) + CONFIG_PROPERTY(QStringList, image, {QStringLiteral("imv")}) + CONFIG_PROPERTY(QStringList, playback, {QStringLiteral("vlc")}) + CONFIG_PROPERTY(QStringList, terminal, {QStringLiteral("wezterm")}) }; -class Battery : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Battery : public settings::ObjectNode { + CONFIG_NODE(Battery, settings::ObjectNode) - CFG_PROPERTY(int, critPerc, 5) + CONFIG_PROPERTY(int, critPerc, 5) CONFIG_LIST( BatteryThresholdList, popupThresholds, - { - vmap( + DEFAULT_ARG({ + settings::vmap( {{"icon", "battery_android_frame_2"}, {"message", "Battery low"}, {"name", "Low battery"}, {"perc", 20}}), - vmap( + settings::vmap( {{"icon", "battery_android_frame_2"}, {"message", "Battery lower"}, {"name", "Low battery"}, {"perc", 15}}), - vmap( + settings::vmap( {{"icon", "battery_android_frame_2"}, {"message", "Battery lowest"}, {"name", "Low battery"}, {"perc", 10}}), - }) - - public: - explicit Battery(QObject* parent = nullptr) : ConfigObject(parent) {} + })) }; -class ColorSettings : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS - - CFG_PROPERTY(int, hyprsunsetTemp, 2600) - CFG_PROPERTY(QString, mode, QStringLiteral("dark")) - CFG_PROPERTY(bool, neovimColors, false) - CFG_PROPERTY(bool, useNativeNightlight, true) - CFG_PROPERTY(int, nativeFadeDuration, 2000) - CFG_PROPERTY(bool, scheduleDark, false) - CFG_PROPERTY(int, scheduleDarkEnd, 600) - CFG_PROPERTY(int, scheduleDarkStart, 1140) - CFG_PROPERTY(bool, scheduleHyprsunset, true) - CFG_PROPERTY(int, scheduleHyprsunsetEnd, 570) - CFG_PROPERTY(int, scheduleHyprsunsetStart, 1200) - CFG_PROPERTY(bool, schemeGeneration, true) - CFG_PROPERTY(bool, smart, false) - - public: - explicit ColorSettings(QObject* parent = nullptr) : ConfigObject(parent) {} -}; - -class Idle : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Idle : public settings::ObjectNode { + CONFIG_NODE(Idle, settings::ObjectNode) CONFIG_LIST( IdleTimeoutList, timeouts, - { - vmap( + DEFAULT_ARG({ + settings::vmap( {{"idleAction", QStringLiteral("lock")}, {"name", QStringLiteral("Lock")}, {"timeout", 180}}), - }) - - public: - explicit Idle(QObject* parent = nullptr) : ConfigObject(parent) {} + })) }; -class General : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class General : public settings::ObjectNode { + CONFIG_NODE(General, settings::ObjectNode) CONFIG_SUBOBJECT(Apps, apps) CONFIG_SUBOBJECT(Battery, battery) - CONFIG_SUBOBJECT(ColorSettings, color) - CFG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss")) - CFG_PROPERTY(bool, desktopIcons, true) + CONFIG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss")) + CONFIG_PROPERTY(bool, desktopIcons, true) CONFIG_SUBOBJECT(Idle, idle) - CFG_PROPERTY( + CONFIG_PROPERTY( QString, lyricsDir, - QStandardPaths::writableLocation(QStandardPaths::MusicLocation) + QStringLiteral("/Lyrics/")) - CFG_PROPERTY(QString, logo, QString()) - CFG_PROPERTY(bool, showOverFullscreen, true) - CFG_PROPERTY( + QStandardPaths::writableLocation(QStandardPaths::MusicLocation) + + QStringLiteral("/Lyrics/")) + CONFIG_PROPERTY(QString, logo, QString()) + CONFIG_PROPERTY(bool, showOverFullscreen, true) + CONFIG_PROPERTY( QString, wallpaperPath, QStringLiteral("/mnt/IronWolf/SDImages/SWWW_Wals/")) - - public: - explicit General(QObject* parent = nullptr) - : ConfigObject(parent) - , m_apps(new Apps(this)) - , m_battery(new Battery(this)) - , m_color(new ColorSettings(this)) - , m_idle(new Idle(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/launcher.hpp b/Plugins/ZShell/Config/launcher.hpp index 24976ca..27dafca 100644 --- a/Plugins/ZShell/Config/launcher.hpp +++ b/Plugins/ZShell/Config/launcher.hpp @@ -1,68 +1,53 @@ #pragma once -#include "configlist.hpp" -#include "configobject.hpp" + +#include "common.hpp" #include namespace ZShell::config { -class LauncherActionEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +using Qt::StringLiterals::operator""_s; +using settings::vmap; - CFG_PROPERTY(QVariantList, command) - CFG_PROPERTY(bool, dangerous, false) - CFG_PROPERTY(QString, description) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(QString, icon) - CFG_PROPERTY(QString, name) +class LauncherActionEntry : public settings::ObjectNode { + CONFIG_NODE(LauncherActionEntry, settings::ObjectNode) - public: - explicit LauncherActionEntry(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("name")}; - } + CONFIG_PROPERTY(QVariantList, command, {}) + CONFIG_PROPERTY(bool, dangerous, false) + CONFIG_PROPERTY(QString, description, {}) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(QString, icon, {}) + CONFIG_PROPERTY(QString, name, {}) }; CONFIG_LIST_TYPE(LauncherActionEntry, LauncherActionList) -class LauncherSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class LauncherSizes : public settings::ObjectNode { + CONFIG_NODE(LauncherSizes, settings::ObjectNode) - CFG_PROPERTY(int, itemHeight, 50) - CFG_PROPERTY(int, itemWidth, 600) - CFG_PROPERTY(int, wallpaperHeight, 200) - CFG_PROPERTY(int, wallpaperWidth, 280) - - public: - explicit LauncherSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, itemHeight, 50) + CONFIG_PROPERTY(int, itemWidth, 600) + CONFIG_PROPERTY(int, wallpaperHeight, 200) + CONFIG_PROPERTY(int, wallpaperWidth, 280) }; -class UseFuzzy : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class UseFuzzy : public settings::ObjectNode { + CONFIG_NODE(UseFuzzy, settings::ObjectNode) - CFG_PROPERTY(bool, actions, true) - CFG_PROPERTY(bool, apps, true) - CFG_PROPERTY(bool, schemes, true) - CFG_PROPERTY(bool, variants, true) - CFG_PROPERTY(bool, wallpapers, true) - - public: - explicit UseFuzzy(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, actions, true) + CONFIG_PROPERTY(bool, apps, true) + CONFIG_PROPERTY(bool, schemes, true) + CONFIG_PROPERTY(bool, variants, true) + CONFIG_PROPERTY(bool, wallpapers, true) }; -class Launcher : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Launcher : public settings::ObjectNode { + CONFIG_NODE(Launcher, settings::ObjectNode) - CFG_PROPERTY(QString, actionPrefix, QStringLiteral("<")) + CONFIG_PROPERTY(QString, actionPrefix, QStringLiteral("<")) CONFIG_LIST( LauncherActionList, actions, - { + DEFAULT_ARG({ vmap( {{"command", QVariantList{"autocomplete", "calc"}}, {"dangerous", false}, @@ -134,44 +119,18 @@ class Launcher : public ConfigObject { {"enabled", true}, {"icon", "bedtime"}, {"name", "Sleep"}}), - }) - CFG_PROPERTY(int, dragThreshold, 150) - CFG_PROPERTY(bool, enableDangerousActions, true) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY( - QStringList, - favoriteApps, - {QStringLiteral("Balatro"), - QStringLiteral("Slay the Spire 2"), - QStringLiteral("com.obsproject.Studio")}) - CFG_PROPERTY( - QStringList, - hiddenApps, - {QStringLiteral("zellij"), - QStringLiteral("vim"), - QStringLiteral("jshell-java-openjdk"), - QStringLiteral("jconsole-java-openjdk"), - QStringLiteral("jshell-java21-openjdk"), - QStringLiteral("jconsole-java21-openjdk"), - QStringLiteral("jshell-java17-openjdk"), - QStringLiteral("jconsole-java17-openjdk"), - QStringLiteral("org.neovim.nvim"), - QStringLiteral("avahi-discover"), - QStringLiteral("bvnc"), - QStringLiteral("bssh"), - QStringLiteral("nm-connection-editor")}) - CFG_PROPERTY(int, maxAppsShown, 18) - CFG_PROPERTY(int, maxWallpapers, 7) + })) + CONFIG_PROPERTY(int, dragThreshold, 150) + CONFIG_PROPERTY(bool, enableDangerousActions, true) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(QStringList, favoriteApps, {}) + CONFIG_PROPERTY(QStringList, hiddenApps, {}) + CONFIG_PROPERTY(int, maxAppsShown, 18) + CONFIG_PROPERTY(int, maxWallpapers, 7) CONFIG_SUBOBJECT(LauncherSizes, sizes) - CFG_PROPERTY(QString, specialPrefix, QStringLiteral("@")) + CONFIG_PROPERTY(QString, specialPrefix, QStringLiteral("@")) CONFIG_SUBOBJECT(UseFuzzy, useFuzzy) - CFG_PROPERTY(bool, uwsm, true) - - public: - explicit Launcher(QObject* parent = nullptr) - : ConfigObject(parent) - , m_sizes(new LauncherSizes(this)) - , m_useFuzzy(new UseFuzzy(this)) {} + CONFIG_PROPERTY(bool, uwsm, true) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/lock.hpp b/Plugins/ZShell/Config/lock.hpp index 479f3ef..c911a18 100644 --- a/Plugins/ZShell/Config/lock.hpp +++ b/Plugins/ZShell/Config/lock.hpp @@ -1,36 +1,27 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class LockSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class LockSizes : public settings::ObjectNode { + CONFIG_NODE(LockSizes, settings::ObjectNode) - CFG_PROPERTY(int, centerWidth, 600) - CFG_PROPERTY(qreal, heightMult, 0.7) - CFG_PROPERTY(qreal, ratio, 1.78) - - public: - explicit LockSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, centerWidth, 600) + CONFIG_PROPERTY(qreal, heightMult, 0.7) + CONFIG_PROPERTY(qreal, ratio, 1.78) }; -class Lock : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Lock : public settings::ObjectNode { + CONFIG_NODE(Lock, settings::ObjectNode) - CFG_PROPERTY(int, blurAmount, 10) - CFG_PROPERTY(bool, enableFprint, true) - CFG_PROPERTY(int, maxFprintTries, 3) - CFG_PROPERTY(bool, recolorLogo, false) - CFG_PROPERTY(bool, showNotifContent, true) - CFG_PROPERTY(bool, showNotifIcon, true) + CONFIG_PROPERTY(int, blurAmount, 10) + CONFIG_PROPERTY(bool, enableFprint, true) + CONFIG_PROPERTY(int, maxFprintTries, 3) + CONFIG_PROPERTY(bool, recolorLogo, false) + CONFIG_PROPERTY(bool, showNotifContent, true) + CONFIG_PROPERTY(bool, showNotifIcon, true) CONFIG_SUBOBJECT(LockSizes, sizes) - - public: - explicit Lock(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new LockSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/notifs.hpp b/Plugins/ZShell/Config/notifs.hpp index b229f11..9283020 100644 --- a/Plugins/ZShell/Config/notifs.hpp +++ b/Plugins/ZShell/Config/notifs.hpp @@ -1,39 +1,30 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class NotifsSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class NotifsSizes : public settings::ObjectNode { + CONFIG_NODE(NotifsSizes, settings::ObjectNode) - CFG_PROPERTY(int, badge, 20) - CFG_PROPERTY(int, image, 41) - CFG_PROPERTY(int, width, 400) - - public: - explicit NotifsSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, badge, 20) + CONFIG_PROPERTY(int, image, 41) + CONFIG_PROPERTY(int, width, 400) }; -class Notifs : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Notifs : public settings::ObjectNode { + CONFIG_NODE(Notifs, settings::ObjectNode) - CFG_PROPERTY(bool, actionOnClick, false) - CFG_PROPERTY(int, appNotifCooldown, 0) - CFG_PROPERTY(qreal, clearThreshold, 0.3) - CFG_PROPERTY(int, defaultExpireTimeout, 5000) - CFG_PROPERTY(int, expandThreshold, 20) - CFG_PROPERTY(bool, expire, true) - CFG_PROPERTY(int, groupPreviewNum, 5) - CFG_PROPERTY(bool, openExpanded, false) - CFG_PROPERTY(bool, showInFullscreen, false) + CONFIG_PROPERTY(bool, actionOnClick, false) + CONFIG_PROPERTY(int, appNotifCooldown, 0) + CONFIG_PROPERTY(qreal, clearThreshold, 0.3) + CONFIG_PROPERTY(int, defaultExpireTimeout, 5000) + CONFIG_PROPERTY(int, expandThreshold, 20) + CONFIG_PROPERTY(bool, expire, true) + CONFIG_PROPERTY(int, groupPreviewNum, 5) + CONFIG_PROPERTY(bool, openExpanded, false) + CONFIG_PROPERTY(bool, showInFullscreen, false) CONFIG_SUBOBJECT(NotifsSizes, sizes) - - public: - explicit Notifs(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new NotifsSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/osd.hpp b/Plugins/ZShell/Config/osd.hpp index bb4bae3..96de78e 100644 --- a/Plugins/ZShell/Config/osd.hpp +++ b/Plugins/ZShell/Config/osd.hpp @@ -1,34 +1,25 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class OsdSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class OsdSizes : public settings::ObjectNode { + CONFIG_NODE(OsdSizes, settings::ObjectNode) - CFG_PROPERTY(int, sliderHeight, 150) - CFG_PROPERTY(int, sliderWidth, 30) - - public: - explicit OsdSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, sliderHeight, 150) + CONFIG_PROPERTY(int, sliderWidth, 30) }; -class Osd : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Osd : public settings::ObjectNode { + CONFIG_NODE(Osd, settings::ObjectNode) - CFG_PROPERTY(bool, allMonBrightness, true) - CFG_PROPERTY(bool, enableBrightness, true) - CFG_PROPERTY(bool, enableMicrophone, true) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(int, hideDelay, 3000) + CONFIG_PROPERTY(bool, allMonBrightness, true) + CONFIG_PROPERTY(bool, enableBrightness, true) + CONFIG_PROPERTY(bool, enableMicrophone, true) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, hideDelay, 3000) CONFIG_SUBOBJECT(OsdSizes, sizes) - - public: - explicit Osd(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new OsdSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/rootnodes.cpp b/Plugins/ZShell/Config/rootnodes.cpp new file mode 100644 index 0000000..82218b7 --- /dev/null +++ b/Plugins/ZShell/Config/rootnodes.cpp @@ -0,0 +1,191 @@ +#include "rootnodes.hpp" + +#include +#include + +#include "../toaster.hpp" +#include "common.hpp" +#include "migration.hpp" + +namespace ZShell::config { + +using Qt::StringLiterals::operator""_s; + +namespace { + +QString nameFor(const QString& key) { + return key.isEmpty() ? u"global"_s : key; +} + +} // namespace + +namespace detail { + +void loaded(ConfigKind kind, settings::RootNode* layer, const QString& screen) { + if (kind != ConfigKind::Shell || !screen.isEmpty()) return; + + auto* const config = static_cast(layer); + if (!config->utilities()->toasts()->configLoaded()) return; + + const auto issues = static_cast(config->diagnostics().count()); + const auto message = issues > 0 + ? QStringLiteral("Config loaded with %1 issue%2") + .arg(issues) + .arg(issues == 1 ? "" : "s") + : QStringLiteral("Config loaded successfully!"); + Toaster::instance()->toast( + QStringLiteral("Config loaded"), + message, + issues > 0 ? QStringLiteral("settings_alert") + : QStringLiteral("rule_settings"), + issues > 0 ? Toast::Type::Warning : Toast::Type::Info); +} + +void loadFailed(ConfigKind kind, const QString& error, const QString& screen) { + QString title; + if (kind == ConfigKind::Tokens) + title = screen.isEmpty() + ? QStringLiteral("Failed to parse token config") + : QStringLiteral("Failed to parse token config for %1") + .arg(screen); + else + title = + screen.isEmpty() + ? QStringLiteral("Failed to parse config") + : QStringLiteral("Failed to parse config for %1").arg(screen); + Toaster::instance()->toast( + title, error, QStringLiteral("settings_alert"), Toast::Type::Warning); +} + +void saveFailed(ConfigKind kind, const QString& error, const QString& screen) { + if (kind != ConfigKind::Shell) return; + + const auto title = + screen.isEmpty() + ? QStringLiteral("Failed to save config") + : QStringLiteral("Failed to save config for %1").arg(screen); + Toaster::instance()->toast( + title, error, QStringLiteral("settings_alert"), Toast::Type::Error); +} + +} // namespace detail + +ConfigRoot::ConfigRoot( + const QString& path, ConfigRoot* fallback, QObject* parent) + : RootNode(path, fallback, parent) { + migrateFile(path); + bindTokens(); + qCDebug(lcConfig) << "Created config root for" << nameFor(key()); +} + +void ConfigRoot::migrateFile(const QString& path) { + QFile file(path); + if (!file.exists() || !file.open(QIODevice::ReadOnly)) return; + + const auto doc = QJsonDocument::fromJson(file.readAll()); + if (!doc.isObject()) return; + + const QJsonObject raw = doc.object(); + const QJsonObject migrated = ConfigMigrations::apply(raw); + if (migrated == raw) return; + + file.close(); + + QSaveFile save(path); + if (!save.open(QIODevice::WriteOnly)) { + qCWarning(lcConfig) << "Failed to open" << path + << "for migration write:" << save.errorString(); + return; + } + + if (save.write(QJsonDocument(migrated).toJson(QJsonDocument::Indented)) != + save.size()) { + qCWarning(lcConfig) + << "Failed to write migrated config:" << save.errorString(); + return; + } + + if (!save.commit()) { + qCWarning(lcConfig) + << "Failed to commit migrated config:" << save.errorString(); + return; + } + + qCDebug(lcConfig) << "Migrated config file" << path; +} + +void ConfigRoot::bindTokens() { + qCDebug(lcConfig) << "Binding appearance to token values for" + << nameFor(key()); + + auto* const tokens = TokensSingleton::instance()->appearance(); + m_appearance->rounding()->bindTokens(tokens->rounding()); + m_appearance->spacing()->bindTokens(tokens->spacing()); + m_appearance->padding()->bindTokens(tokens->padding()); + m_appearance->anim()->durations()->bindTokens(tokens->animDurations()); +} + +TokensRoot::TokensRoot( + const QString& path, TokensRoot* fallback, QObject* parent) + : RootNode(path, fallback, parent) { + qCDebug(lcConfig) << "Created tokens root for" << nameFor(key()); +} + +#define SINGLETON_IMPL(Type, Root, file, kind) \ + Type* Type::instance() { \ + static Type instance; \ + return &instance; \ + } \ + \ + Type* Type::create(QQmlEngine*, QJSEngine*) { \ + QQmlEngine::setObjectOwnership(instance(), QQmlEngine::CppOwnership); \ + return instance(); \ + } \ + \ + Root* Type::forScreen(const QString& screen) { \ + bool created; \ + auto* const layer = m_layers.get(screen, this, &created); \ + if (created) initLayer(layer); \ + return layer; \ + } \ + \ + Type::Type(QObject* parent) \ + : Root(configDir() + u'/' + file, nullptr, parent) \ + , m_layers(monitorConfigDir(), file, this) { \ + initLayer(this); \ + } \ + \ + void Type::onTreeLoaded(settings::RootNode* layer) { \ + detail::loaded( \ + kind, layer, m_layers.nameFor(static_cast(layer))); \ + } \ + \ + void Type::onTreeLoadFailed( \ + settings::RootNode* layer, const QString& error) { \ + detail::loadFailed( \ + kind, error, m_layers.nameFor(static_cast(layer))); \ + } \ + \ + void Type::onTreeSaveFailed( \ + settings::RootNode* layer, const QString& error) { \ + detail::saveFailed( \ + kind, error, m_layers.nameFor(static_cast(layer))); \ + } \ + \ + void Type::initLayer(Root* layer) { \ + QObject::connect(layer, &Root::treeLoaded, this, &Type::onTreeLoaded); \ + QObject::connect( \ + layer, &Root::treeLoadFailed, this, &Type::onTreeLoadFailed); \ + QObject::connect( \ + layer, &Root::treeSaveFailed, this, &Type::onTreeSaveFailed); \ + layer->load(); \ + } + +SINGLETON_IMPL( + ConfigSingleton, ConfigRoot, u"config.json"_s, detail::ConfigKind::Shell) +SINGLETON_IMPL( + TokensSingleton, TokensRoot, u"tokens.json"_s, detail::ConfigKind::Tokens) + +#undef SINGLETON_IMPL + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/rootnodes.hpp b/Plugins/ZShell/Config/rootnodes.hpp new file mode 100644 index 0000000..308e90a --- /dev/null +++ b/Plugins/ZShell/Config/rootnodes.hpp @@ -0,0 +1,168 @@ +#pragma once + +#include +#include + +#include "settings/layerregistry.hpp" +#include "settings/rootnode.hpp" +#include "appearance.hpp" +#include "background.hpp" +#include "bar.hpp" +#include "clipboard.hpp" +#include "colors.hpp" +#include "common.hpp" +#include "dashboard.hpp" +#include "display.hpp" +#include "dock.hpp" +#include "general.hpp" +#include "launcher.hpp" +#include "llm.hpp" +#include "lock.hpp" +#include "notifs.hpp" +#include "osd.hpp" +#include "screenshot.hpp" +#include "services.hpp" +#include "sidebar.hpp" +#include "tokens.hpp" +#include "utilities.hpp" + +namespace ZShell::config { + +class ConfigRoot : public settings::RootNode { + CONFIG_NODE_NO_CTOR(ConfigRoot, settings::RootNode) + QML_ANONYMOUS + + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_SUBOBJECT(Appearance, appearance) + CONFIG_SUBOBJECT(Background, background) + CONFIG_SUBOBJECT(Bar, bar) + CONFIG_SUBOBJECT(Clipboard, clipboard) + CONFIG_SUBOBJECT(Colors, colors) + CONFIG_SUBOBJECT(Dashboard, dashboard) + CONFIG_SUBOBJECT(Display, display) + CONFIG_SUBOBJECT(Dock, dock) + CONFIG_SUBOBJECT(General, general) + CONFIG_SUBOBJECT(Launcher, launcher) + CONFIG_SUBOBJECT(Llm, llm) + CONFIG_SUBOBJECT(Lock, lock) + CONFIG_SUBOBJECT(Notifs, notifs) + CONFIG_SUBOBJECT(Osd, osd) + CONFIG_SUBOBJECT(Screenshot, screenshot) + CONFIG_SUBOBJECT(Services, services) + CONFIG_SUBOBJECT(Sidebar, sidebar) + CONFIG_SUBOBJECT(Utilities, utilities) + + public: + explicit ConfigRoot( + const QString& path, + ConfigRoot* fallback = nullptr, + QObject* parent = nullptr); + + private: + // Binds the computed appearance values to the global token base values + void bindTokens(); + + // Applies ConfigMigrations to the file before the first load + void migrateFile(const QString& path); +}; + +class TokensRoot : public settings::RootNode { + CONFIG_NODE_NO_CTOR(TokensRoot, settings::RootNode) + QML_ANONYMOUS + + CONFIG_SUBOBJECT(AppearanceTokens, appearance) + CONFIG_SUBOBJECT(SizeTokens, sizes) + + public: + explicit TokensRoot( + const QString& path, + TokensRoot* fallback = nullptr, + QObject* parent = nullptr); +}; + +namespace detail { + +enum class ConfigKind : quint8 { Shell, Tokens }; + +void loaded(ConfigKind kind, settings::RootNode* layer, const QString& screen); +void loadFailed(ConfigKind kind, const QString& error, const QString& screen); +void saveFailed(ConfigKind kind, const QString& error, const QString& screen); + +} // namespace detail + +#define SINGLETON_DECL(Type, Root, QmlName) \ + class Type : public Root { \ + Q_OBJECT \ + QML_NAMED_ELEMENT(QmlName) \ + QML_SINGLETON \ + \ + public: \ + static Type* instance(); \ + static Type* create(QQmlEngine*, QJSEngine*); \ + \ + [[nodiscard]] Q_INVOKABLE Root* forScreen(const QString& screen); \ + \ + private: \ + explicit Type(QObject* parent = nullptr); \ + \ + void onTreeLoaded(settings::RootNode* layer); \ + void onTreeLoadFailed(settings::RootNode* layer, const QString& error); \ + void onTreeSaveFailed(settings::RootNode* layer, const QString& error); \ + \ + void initLayer(Root* layer); \ + \ + settings::LayerRegistry m_layers; \ + }; + +SINGLETON_DECL(ConfigSingleton, ConfigRoot, Config) +SINGLETON_DECL(TokensSingleton, TokensRoot, TokenConfig) + +#undef SINGLETON_DECL + +class Tokens : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY( + ZShell::config::AppearanceRounding* rounding READ rounding CONSTANT) + Q_PROPERTY(ZShell::config::AppearanceSpacing* spacing READ spacing CONSTANT) + Q_PROPERTY(ZShell::config::AppearancePadding* padding READ padding CONSTANT) + Q_PROPERTY(ZShell::config::SizeTokens* sizes READ sizes CONSTANT) + Q_PROPERTY(ZShell::config::FontTokens* font READ font CONSTANT) + Q_PROPERTY(ZShell::config::AnimTokens* anim READ anim CONSTANT) + + public: + explicit Tokens(QObject* parent = nullptr) + : QObject(parent) + , m_font(new FontTokens(this)) + , m_anim(new AnimTokens(this)) { + m_anim->bindCurves(TokensSingleton::instance()->appearance()->curves()); + m_anim->bindDurations( + ConfigSingleton::instance()->appearance()->anim()->durations()); + m_font->bindFont(ConfigSingleton::instance()->appearance()->font()); + } + + [[nodiscard]] AppearanceRounding* rounding() const { + return ConfigSingleton::instance()->appearance()->rounding(); + } + [[nodiscard]] AppearancePadding* padding() const { + return ConfigSingleton::instance()->appearance()->padding(); + } + [[nodiscard]] AppearanceSpacing* spacing() const { + return ConfigSingleton::instance()->appearance()->spacing(); + } + [[nodiscard]] SizeTokens* sizes() const { + return TokensSingleton::instance()->sizes(); + } + [[nodiscard]] FontTokens* font() const { return m_font; } + [[nodiscard]] AnimTokens* anim() const { return m_anim; } + + static Tokens* create(QQmlEngine*, QJSEngine*) { return new Tokens(); } + + private: + FontTokens* m_font; + AnimTokens* m_anim; +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/screenshot.hpp b/Plugins/ZShell/Config/screenshot.hpp index 101ea41..a38828b 100644 --- a/Plugins/ZShell/Config/screenshot.hpp +++ b/Plugins/ZShell/Config/screenshot.hpp @@ -1,27 +1,23 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include #include namespace ZShell::config { -class Screenshot : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Screenshot : public settings::ObjectNode { + CONFIG_NODE(Screenshot, settings::ObjectNode) - CFG_PROPERTY(bool, enable_pp, false) - CFG_PROPERTY(QString, mode, QStringLiteral("manual")) - CFG_PROPERTY(int, radius, 49) - CFG_PROPERTY(bool, rounding, false) - CFG_PROPERTY(bool, saveOnCopy, false) - CFG_PROPERTY(bool, shadow, true) - CFG_PROPERTY(int, shadow_blur, 0) - CFG_PROPERTY(QVariantList, shadow_color, (QVariantList{0, 0, 0, 160})) - CFG_PROPERTY(int, shadow_offset_x, 30) - CFG_PROPERTY(int, shadow_offset_y, 50) - - public: - explicit Screenshot(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, enable_pp, false) + CONFIG_PROPERTY(QString, mode, QStringLiteral("manual")) + CONFIG_PROPERTY(int, radius, 49) + CONFIG_PROPERTY(bool, rounding, false) + CONFIG_PROPERTY(bool, saveOnCopy, false) + CONFIG_PROPERTY(bool, shadow, true) + CONFIG_PROPERTY(int, shadow_blur, 0) + CONFIG_PROPERTY(QVariantList, shadow_color, (QVariantList{0, 0, 0, 160})) + CONFIG_PROPERTY(int, shadow_offset_x, 30) + CONFIG_PROPERTY(int, shadow_offset_y, 50) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/services.hpp b/Plugins/ZShell/Config/services.hpp index 00e0ea5..1f341f8 100644 --- a/Plugins/ZShell/Config/services.hpp +++ b/Plugins/ZShell/Config/services.hpp @@ -1,56 +1,42 @@ #pragma once -#include "configlist.hpp" -#include "configobject.hpp" +#include "common.hpp" #include "enums.hpp" namespace ZShell::config { -class PlayerAliasEntry : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class PlayerAliasEntry : public settings::ObjectNode { + CONFIG_NODE(PlayerAliasEntry, settings::ObjectNode) - CFG_PROPERTY(QString, from) - CFG_PROPERTY(QString, to) - - public: - explicit PlayerAliasEntry(QObject* parent = nullptr) - : ConfigObject(parent) {} - - [[nodiscard]] QStringList identityKeys() const override { - return {QStringLiteral("from")}; - } + CONFIG_PROPERTY(QString, from, {}) + CONFIG_PROPERTY(QString, to, {}) }; CONFIG_LIST_TYPE(PlayerAliasEntry, PlayerAliasList) -class Services : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Services : public settings::ObjectNode { + CONFIG_NODE(Services, settings::ObjectNode) - CFG_PROPERTY(qreal, audioIncrement, 0.1) - CFG_PROPERTY(qreal, brightnessIncrement, 0.1) - CFG_PROPERTY(bool, ddcutilService, true) - CFG_PROPERTY(QString, defaultPlayer, QStringLiteral("Spotify")) - CFG_PROPERTY(QString, gpuType, QStringLiteral("NVIDIA")) - CFG_PROPERTY(LyricsBackend::Enum, lyricsBackend, LyricsBackend::Auto) - CFG_PROPERTY(qreal, maxVolume, 1) - CFG_PROPERTY(qreal, minBrightness, 0.01) + CONFIG_PROPERTY(qreal, audioIncrement, 0.1) + CONFIG_PROPERTY(qreal, brightnessIncrement, 0.1) + CONFIG_PROPERTY(bool, ddcutilService, true) + CONFIG_PROPERTY(QString, defaultPlayer, QStringLiteral("Spotify")) + CONFIG_PROPERTY(QString, gpuType, QStringLiteral("NVIDIA")) + CONFIG_PROPERTY(LyricsBackend::Enum, lyricsBackend, LyricsBackend::Auto) + CONFIG_PROPERTY(qreal, maxVolume, 1) + CONFIG_PROPERTY(qreal, minBrightness, 0.01) CONFIG_LIST( PlayerAliasList, playerAliases, - { - vmap( + DEFAULT_ARG({ + settings::vmap( {{"from", QStringLiteral("com.github.th_ch.youtube_music")}, {"to", QStringLiteral("YT Music")}}), - }) - CFG_PROPERTY(bool, updates, true) - CFG_PROPERTY(bool, useFahrenheit, false) - CFG_PROPERTY(bool, useTwelveHourClock, false) - CFG_PROPERTY(int, visualizerBars, 50) - CFG_PROPERTY(QString, weatherLocation, QString()) - - public: - explicit Services(QObject* parent = nullptr) : ConfigObject(parent) {} + })) + CONFIG_PROPERTY(bool, updates, true) + CONFIG_PROPERTY(bool, useFahrenheit, false) + CONFIG_PROPERTY(bool, useTwelveHourClock, false) + CONFIG_PROPERTY(int, visualizerBars, 50) + CONFIG_PROPERTY(QString, weatherLocation, QString()) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/sidebar.hpp b/Plugins/ZShell/Config/sidebar.hpp index bd9840a..f9db473 100644 --- a/Plugins/ZShell/Config/sidebar.hpp +++ b/Plugins/ZShell/Config/sidebar.hpp @@ -1,30 +1,21 @@ #pragma once -#include "configobject.hpp" +#include "common.hpp" #include namespace ZShell::config { -class SidebarSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class SidebarSizes : public settings::ObjectNode { + CONFIG_NODE(SidebarSizes, settings::ObjectNode) - CFG_PROPERTY(int, width, 520) - - public: - explicit SidebarSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, width, 520) }; -class Sidebar : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Sidebar : public settings::ObjectNode { + CONFIG_NODE(Sidebar, settings::ObjectNode) - CFG_PROPERTY(int, dragThreshold, 30) - CFG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, dragThreshold, 30) + CONFIG_PROPERTY(bool, enabled, true) CONFIG_SUBOBJECT(SidebarSizes, sizes) - - public: - explicit Sidebar(QObject* parent = nullptr) - : ConfigObject(parent), m_sizes(new SidebarSizes(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/tokens.hpp b/Plugins/ZShell/Config/tokens.hpp index 07dd953..25c8388 100644 --- a/Plugins/ZShell/Config/tokens.hpp +++ b/Plugins/ZShell/Config/tokens.hpp @@ -1,205 +1,161 @@ #pragma once -#include "configobject.hpp" -#include "anim.hpp" -#include "appearance.hpp" -#include "config.hpp" -#include "font.hpp" - -#include -#include +#include #include #include #include -#include #include +#include + +#include "settings/objectnode.hpp" +#include "anim.hpp" +#include "common.hpp" +#include "font.hpp" + namespace ZShell::config { -class AnimCurves : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class AnimCurves : public settings::ObjectNode { + CONFIG_NODE(AnimCurves, settings::ObjectNode) - CFG_PROPERTY(QList, emphasized) - CFG_PROPERTY(QList, emphasizedAccel) - CFG_PROPERTY(QList, emphasizedDecel) - CFG_PROPERTY(QList, standard) - CFG_PROPERTY(QList, standardAccel) - CFG_PROPERTY(QList, standardDecel) - CFG_PROPERTY(QList, expressiveFastSpatial) - CFG_PROPERTY(QList, expressiveDefaultSpatial) - CFG_PROPERTY(QList, expressiveSlowSpatial) - CFG_PROPERTY(QList, expressiveFastEffects) - CFG_PROPERTY(QList, expressiveDefaultEffects) - CFG_PROPERTY(QList, expressiveSlowEffects) - - public: - explicit AnimCurves(QObject* parent = nullptr) - : ConfigObject(parent) - , m_emphasized( - {0.05, - 0, - 2.0 / 15.0, - 0.06, - 1.0 / 6.0, - 0.4, - 5.0 / 24.0, - 0.82, - 0.25, - 1, - 1, - 1}) - , m_emphasizedAccel({0.3, 0, 0.8, 0.15, 1, 1}) - , m_emphasizedDecel({0.05, 0.7, 0.1, 1, 1, 1}) - , m_standard({0.2, 0, 0, 1, 1, 1}) - , m_standardAccel({0.3, 0, 1, 1, 1, 1}) - , m_standardDecel({0, 0, 0, 1, 1, 1}) - , m_expressiveFastSpatial({0.42, 1.67, 0.21, 0.9, 1, 1}) - , m_expressiveDefaultSpatial({0.38, 1.21, 0.22, 1, 1, 1}) - , m_expressiveSlowSpatial({0.39, 1.29, 0.35, 0.98, 1, 1}) - , m_expressiveFastEffects({0.31, 0.94, 0.34, 1, 1, 1}) - , m_expressiveDefaultEffects({0.34, 0.8, 0.34, 1, 1, 1}) - , m_expressiveSlowEffects({0.34, 0.88, 0.34, 1, 1, 1}) {} + CONFIG_GLOBAL_PROPERTY( + QList, + emphasized, + DEFAULT_ARG( + {0.05, + 0, + 2.0 / 15.0, + 0.06, + 1.0 / 6.0, + 0.4, + 5.0 / 24.0, + 0.82, + 0.25, + 1, + 1, + 1})) + CONFIG_GLOBAL_PROPERTY( + QList, emphasizedAccel, DEFAULT_ARG({0.3, 0, 0.8, 0.15, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, emphasizedDecel, DEFAULT_ARG({0.05, 0.7, 0.1, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, standard, DEFAULT_ARG({0.2, 0, 0, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, standardAccel, DEFAULT_ARG({0.3, 0, 1, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, standardDecel, DEFAULT_ARG({0, 0, 0, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveFastSpatial, + DEFAULT_ARG({0.42, 1.67, 0.21, 0.9, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveDefaultSpatial, + DEFAULT_ARG({0.38, 1.21, 0.22, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveSlowSpatial, + DEFAULT_ARG({0.39, 1.29, 0.35, 0.98, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveFastEffects, + DEFAULT_ARG({0.31, 0.94, 0.34, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveDefaultEffects, + DEFAULT_ARG({0.34, 0.8, 0.34, 1, 1, 1})) + CONFIG_GLOBAL_PROPERTY( + QList, + expressiveSlowEffects, + DEFAULT_ARG({0.34, 0.88, 0.34, 1, 1, 1})) }; -class AnimTokens : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class RoundingTokens : public settings::ObjectNode { + CONFIG_NODE(RoundingTokens, settings::ObjectNode) - Q_PROPERTY(ZShell::config::AnimDurations* durations READ durations CONSTANT) - - Q_PROPERTY(QEasingCurve emphasized READ emphasized NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve emphasizedAccel READ emphasizedAccel NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve emphasizedDecel READ emphasizedDecel NOTIFY curvesChanged) - Q_PROPERTY(QEasingCurve standard READ standard NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve standardAccel READ standardAccel NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve standardDecel READ standardDecel NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveFastSpatial READ expressiveFastSpatial NOTIFY - curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveDefaultSpatial READ expressiveDefaultSpatial - NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveSlowSpatial READ expressiveSlowSpatial NOTIFY - curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveFastEffects READ expressiveFastEffects NOTIFY - curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveDefaultEffects READ expressiveDefaultEffects - NOTIFY curvesChanged) - Q_PROPERTY( - QEasingCurve expressiveSlowEffects READ expressiveSlowEffects NOTIFY - curvesChanged) - - public: - explicit AnimTokens(QObject* parent = nullptr) - : ConfigObject(parent) - , m_curves(new AnimCurves(this)) - , m_easing(new AnimEasing(this)) { - connect( - m_easing, - &AnimEasing::curvesChanged, - this, - &AnimTokens::curvesChanged); - m_easing->bindCurves(m_curves); - } - - [[nodiscard]] AnimDurations* durations() const { - return Config::instance()->appearance()->anim()->durations(); - } - - [[nodiscard]] QEasingCurve emphasized() const { - return m_easing->emphasized(); - } - [[nodiscard]] QEasingCurve emphasizedAccel() const { - return m_easing->emphasizedAccel(); - } - [[nodiscard]] QEasingCurve emphasizedDecel() const { - return m_easing->emphasizedDecel(); - } - [[nodiscard]] QEasingCurve standard() const { return m_easing->standard(); } - [[nodiscard]] QEasingCurve standardAccel() const { - return m_easing->standardAccel(); - } - [[nodiscard]] QEasingCurve standardDecel() const { - return m_easing->standardDecel(); - } - [[nodiscard]] QEasingCurve expressiveFastSpatial() const { - return m_easing->expressiveFastSpatial(); - } - [[nodiscard]] QEasingCurve expressiveDefaultSpatial() const { - return m_easing->expressiveDefaultSpatial(); - } - [[nodiscard]] QEasingCurve expressiveSlowSpatial() const { - return m_easing->expressiveSlowSpatial(); - } - [[nodiscard]] QEasingCurve expressiveFastEffects() const { - return m_easing->expressiveFastEffects(); - } - [[nodiscard]] QEasingCurve expressiveDefaultEffects() const { - return m_easing->expressiveDefaultEffects(); - } - [[nodiscard]] QEasingCurve expressiveSlowEffects() const { - return m_easing->expressiveSlowEffects(); - } - - signals: - void curvesChanged(); - - private: - AnimCurves* m_curves; - AnimEasing* m_easing; + CONFIG_PROPERTY(int, extraSmall, 4) + CONFIG_PROPERTY(int, small, 8) + CONFIG_PROPERTY(int, medium, 12) + CONFIG_PROPERTY(int, large, 16) + CONFIG_PROPERTY(int, largeIncreased, 20) + CONFIG_PROPERTY(int, extraLarge, 28) + CONFIG_PROPERTY(int, extraLargeIncreased, 32) + CONFIG_PROPERTY(int, extraExtraLarge, 48) + CONFIG_PROPERTY(int, full, std::numeric_limits::max()) }; -class BarTokens : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class SpacingTokens : public settings::ObjectNode { + CONFIG_NODE(SpacingTokens, settings::ObjectNode) - CFG_PROPERTY(int, innerSize, 28) - - public: - explicit BarTokens(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, extraSmall, 4) + CONFIG_PROPERTY(int, small, 8) + CONFIG_PROPERTY(int, medium, 12) + CONFIG_PROPERTY(int, large, 16) + CONFIG_PROPERTY(int, largeIncreased, 20) + CONFIG_PROPERTY(int, extraLarge, 28) + CONFIG_PROPERTY(int, extraLargeIncreased, 32) + CONFIG_PROPERTY(int, extraExtraLarge, 48) }; -class Tokens : public ConfigObject { - Q_OBJECT - QML_ELEMENT - QML_SINGLETON +class PaddingTokens : public settings::ObjectNode { + CONFIG_NODE(PaddingTokens, settings::ObjectNode) - Q_PROPERTY( - ZShell::config::AppearanceRounding* rounding READ rounding CONSTANT) - Q_PROPERTY(ZShell::config::AppearancePadding* padding READ padding CONSTANT) - Q_PROPERTY(ZShell::config::AppearanceSpacing* spacing READ spacing CONSTANT) - CONFIG_SUBOBJECT(FontTokens, font) - CONFIG_SUBOBJECT(AnimTokens, anim) + CONFIG_PROPERTY(int, extraSmall, 4) + CONFIG_PROPERTY(int, smaller, 5) + CONFIG_PROPERTY(int, small, 8) + CONFIG_PROPERTY(int, medium, 12) + CONFIG_PROPERTY(int, large, 16) + CONFIG_PROPERTY(int, largeIncreased, 20) + CONFIG_PROPERTY(int, extraLarge, 28) + CONFIG_PROPERTY(int, extraLargeIncreased, 32) + CONFIG_PROPERTY(int, extraExtraLarge, 48) +}; + +class AnimDurationTokens : public settings::ObjectNode { + CONFIG_NODE(AnimDurationTokens, settings::ObjectNode) + + CONFIG_GLOBAL_PROPERTY(int, small, 200) + CONFIG_GLOBAL_PROPERTY(int, normal, 400) + CONFIG_GLOBAL_PROPERTY(int, large, 600) + CONFIG_GLOBAL_PROPERTY(int, extraLarge, 1000) + CONFIG_GLOBAL_PROPERTY(int, expressiveFastSpatial, 350) + CONFIG_GLOBAL_PROPERTY(int, expressiveDefaultSpatial, 500) + CONFIG_GLOBAL_PROPERTY(int, expressiveSlowSpatial, 650) + CONFIG_GLOBAL_PROPERTY(int, expressiveFastEffects, 150) + CONFIG_GLOBAL_PROPERTY(int, expressiveDefaultEffects, 200) + CONFIG_GLOBAL_PROPERTY(int, expressiveSlowEffects, 300) +}; + +class AppearanceTokens : public settings::ObjectNode { + CONFIG_NODE(AppearanceTokens, settings::ObjectNode) + + CONFIG_SUBOBJECT(AnimCurves, curves) + CONFIG_SUBOBJECT(RoundingTokens, rounding) + CONFIG_SUBOBJECT(SpacingTokens, spacing) + CONFIG_SUBOBJECT(PaddingTokens, padding) + CONFIG_SUBOBJECT(AnimDurationTokens, animDurations) +}; + +class BarTokens : public settings::ObjectNode { + CONFIG_NODE(BarTokens, settings::ObjectNode) + + CONFIG_PROPERTY(int, innerSize, 28) +}; + +class DashboardTokens : public settings::ObjectNode { + CONFIG_NODE(DashboardTokens, settings::ObjectNode) + + CONFIG_PROPERTY(int, processesWidth, 1200) + CONFIG_PROPERTY(int, processesHeight, 800) + CONFIG_PROPERTY(int, processesHeaderHeight, 40) + CONFIG_PROPERTY(int, processesRowHeight, 40) +}; + +class SizeTokens : public settings::ObjectNode { + CONFIG_NODE(SizeTokens, settings::ObjectNode) + + CONFIG_SUBOBJECT(DashboardTokens, dashboard) CONFIG_SUBOBJECT(BarTokens, bar) - - public: - explicit Tokens(QObject* parent = nullptr) - : ConfigObject(parent) - , m_font(new FontTokens(this)) - , m_anim(new AnimTokens(this)) - , m_bar(new BarTokens(this)) { - if (!Config::instance()) new Config(); - } - - [[nodiscard]] AppearanceRounding* rounding() const { - return Config::instance()->appearance()->rounding(); - } - [[nodiscard]] AppearancePadding* padding() const { - return Config::instance()->appearance()->padding(); - } - [[nodiscard]] AppearanceSpacing* spacing() const { - return Config::instance()->appearance()->spacing(); - } - - static Tokens* create(QQmlEngine*, QJSEngine*) { return new Tokens(); } }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/utilities.hpp b/Plugins/ZShell/Config/utilities.hpp index 4d3fc21..553754f 100644 --- a/Plugins/ZShell/Config/utilities.hpp +++ b/Plugins/ZShell/Config/utilities.hpp @@ -1,7 +1,6 @@ #pragma once -#include "configobject.hpp" -#include "configlist.hpp" +#include "common.hpp" #include #include @@ -9,75 +8,57 @@ namespace ZShell::config { -class UtilitiesSizes : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class UtilitiesSizes : public settings::ObjectNode { + CONFIG_NODE(UtilitiesSizes, settings::ObjectNode) - CFG_PROPERTY(int, toastWidth, 430) - CFG_PROPERTY(int, width, 430) - - public: - explicit UtilitiesSizes(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(int, toastWidth, 430) + CONFIG_PROPERTY(int, width, 430) }; -class Toasts : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Toasts : public settings::ObjectNode { + CONFIG_NODE(Toasts, settings::ObjectNode) - CFG_PROPERTY(bool, audioInputChanged, true) - CFG_PROPERTY(bool, audioOutputChanged, true) - CFG_PROPERTY(bool, capsLockChanged, true) - CFG_PROPERTY(bool, chargingChanged, true) - CFG_PROPERTY(bool, configLoaded, true) - CFG_PROPERTY(bool, dndChanged, true) - CFG_PROPERTY(bool, gameModeChanged, true) - CFG_PROPERTY(bool, kbLayoutChanged, true) - CFG_PROPERTY(bool, kbLimit, true) - CFG_PROPERTY(bool, nowPlaying, false) - CFG_PROPERTY(bool, numLockChanged, true) - CFG_PROPERTY(bool, vpnChanged, true) - - public: - explicit Toasts(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, audioInputChanged, true) + CONFIG_PROPERTY(bool, audioOutputChanged, true) + CONFIG_PROPERTY(bool, capsLockChanged, true) + CONFIG_PROPERTY(bool, chargingChanged, true) + CONFIG_PROPERTY(bool, configLoaded, true) + CONFIG_PROPERTY(bool, dndChanged, true) + CONFIG_PROPERTY(bool, gameModeChanged, true) + CONFIG_PROPERTY(bool, kbLayoutChanged, true) + CONFIG_PROPERTY(bool, kbLimit, true) + CONFIG_PROPERTY(bool, nowPlaying, false) + CONFIG_PROPERTY(bool, numLockChanged, true) + CONFIG_PROPERTY(bool, vpnChanged, true) }; -class Vpn : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Vpn : public settings::ObjectNode { + CONFIG_NODE(Vpn, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, false) - CFG_PROPERTY(QStringList, provider, (QStringList{QStringLiteral("netbird")})) - - public: - explicit Vpn(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(bool, enabled, false) + CONFIG_PROPERTY( + QStringList, provider, (QStringList{QStringLiteral("netbird")})) }; -class Utilities : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Utilities : public settings::ObjectNode { + CONFIG_NODE(Utilities, settings::ObjectNode) - CFG_PROPERTY(bool, enabled, true) - CFG_PROPERTY(int, maxToasts, 4) + CONFIG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(int, maxToasts, 4) CONFIG_SUBOBJECT(UtilitiesSizes, sizes) CONFIG_SUBOBJECT(Toasts, toasts) CONFIG_SUBOBJECT(Vpn, vpn) CONFIG_LIST( EntryList, quickToggles, - {LIST_ENTRY(dnd, true), - LIST_ENTRY(wifi, true), - LIST_ENTRY(settings, true), - LIST_ENTRY(bluetooth, true), - LIST_ENTRY(mic, true), - LIST_ENTRY(audio, true), - LIST_ENTRY(gameMode, true)}) - - public: - explicit Utilities(QObject* parent = nullptr) - : ConfigObject(parent) - , m_sizes(new UtilitiesSizes(this)) - , m_toasts(new Toasts(this)) - , m_vpn(new Vpn(this)) {} + DEFAULT_ARG( + {LIST_ENTRY(dnd, true), + LIST_ENTRY(wifi, true), + LIST_ENTRY(settings, true), + LIST_ENTRY(bluetooth, true), + LIST_ENTRY(mic, true), + LIST_ENTRY(audio, true), + LIST_ENTRY(gameMode, true)})) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index 0726068..f789bba 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -1,12 +1,12 @@ qml_module(ZShell-internal URI ZShell.Internal + INCLUDE_PREFIX internal SOURCES hyprextras.hpp hyprextras.cpp hyprdevices.hpp hyprdevices.cpp cachingimagemanager.hpp cachingimagemanager.cpp circularindicatormanager.hpp circularindicatormanager.cpp circularbuffer.hpp circularbuffer.cpp - sparklineitem.hpp sparklineitem.cpp arcgauge.hpp arcgauge.cpp wallpaperimage.hpp wallpaperimage.cpp lidwatcher.hpp lidwatcher.cpp @@ -14,6 +14,7 @@ qml_module(ZShell-internal linearindicatormanager.hpp linearindicatormanager.cpp strokecanvasitem.hpp strokecanvasitem.cpp strokecanvasrenderer.hpp strokecanvasrenderer.cpp + cursor.hpp LIBRARIES Qt::Gui Qt::Quick diff --git a/Plugins/ZShell/Internal/circularbuffer.cpp b/Plugins/ZShell/Internal/circularbuffer.cpp index b1c12bc..dcf7b8c 100644 --- a/Plugins/ZShell/Internal/circularbuffer.cpp +++ b/Plugins/ZShell/Internal/circularbuffer.cpp @@ -19,6 +19,8 @@ void CircularBuffer::setCapacity(int capacity) { m_capacity = capacity; m_data.resize(capacity); m_data.fill(0.0); + m_smoothed.resize(capacity); + m_smoothed.fill(0.0); m_head = 0; m_count = 0; @@ -30,6 +32,8 @@ void CircularBuffer::setCapacity(int capacity) { m_count++; } + recomputeAllSmoothed(); + emit capacityChanged(); emit countChanged(); emit valuesChanged(); @@ -56,6 +60,19 @@ qreal CircularBuffer::maximum() const { return maxVal; } +int CircularBuffer::smoothingRadius() const { + return m_smoothingRadius; +} + +void CircularBuffer::setSmoothingRadius(int radius) { + radius = std::max(0, radius); + if (m_smoothingRadius == radius) return; + m_smoothingRadius = radius; + recomputeAllSmoothed(); + emit smoothingRadiusChanged(); + emit valuesChanged(); +} + void CircularBuffer::push(qreal value) { if (m_capacity <= 0) return; @@ -65,6 +82,9 @@ void CircularBuffer::push(qreal value) { m_count++; emit countChanged(); } + + updateSmoothedTail(); + emit valuesChanged(); } @@ -85,4 +105,44 @@ qreal CircularBuffer::at(int index) const { return m_data[actualIndex]; } +qreal CircularBuffer::smoothedAt(int index) const { + if (index < 0 || index >= m_count) return 0.0; + + const int actualIndex = + (m_head - m_count + index + m_capacity) % m_capacity; + return m_smoothed[actualIndex]; +} + +void CircularBuffer::writeSmoothedAt(int logicalIndex) { + const int actualIndex = + (m_head - m_count + logicalIndex + m_capacity) % m_capacity; + + if (m_smoothingRadius <= 0) { + m_smoothed[actualIndex] = m_data[actualIndex]; + return; + } + + qreal sum = 0.0; + int sampleCount = 0; + for (int j = -m_smoothingRadius; j <= m_smoothingRadius; ++j) { + const int idx = logicalIndex + j; + if (idx < 0 || idx >= m_count) continue; + const int a = (m_head - m_count + idx + m_capacity) % m_capacity; + sum += m_data[a]; + sampleCount++; + } + m_smoothed[actualIndex] = sampleCount > 0 ? sum / sampleCount : 0.0; +} + +void CircularBuffer::updateSmoothedTail() { + const int start = std::max(0, m_count - 1 - m_smoothingRadius); + for (int i = start; i < m_count; ++i) + writeSmoothedAt(i); +} + +void CircularBuffer::recomputeAllSmoothed() { + for (int i = 0; i < m_count; ++i) + writeSmoothedAt(i); +} + } // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/circularbuffer.hpp b/Plugins/ZShell/Internal/circularbuffer.hpp index a2496f5..26475c1 100644 --- a/Plugins/ZShell/Internal/circularbuffer.hpp +++ b/Plugins/ZShell/Internal/circularbuffer.hpp @@ -15,6 +15,9 @@ class CircularBuffer : public QObject { Q_PROPERTY(int count READ count NOTIFY countChanged) Q_PROPERTY(QList values READ values NOTIFY valuesChanged) Q_PROPERTY(qreal maximum READ maximum NOTIFY valuesChanged) + Q_PROPERTY( + int smoothingRadius READ smoothingRadius WRITE setSmoothingRadius NOTIFY + smoothingRadiusChanged) public: explicit CircularBuffer(QObject* parent = nullptr); @@ -26,20 +29,34 @@ class CircularBuffer : public QObject { [[nodiscard]] QList values() const; [[nodiscard]] qreal maximum() const; + [[nodiscard]] int smoothingRadius() const; + void setSmoothingRadius(int radius); + Q_INVOKABLE void push(qreal value); Q_INVOKABLE void clear(); Q_INVOKABLE [[nodiscard]] qreal at(int index) const; + Q_INVOKABLE [[nodiscard]] qreal smoothedAt(int index) const; + signals: void capacityChanged(); void countChanged(); void valuesChanged(); + void smoothingRadiusChanged(); private: + void writeSmoothedAt(int logicalIndex); + + void updateSmoothedTail(); + + void recomputeAllSmoothed(); + QVector m_data; + QVector m_smoothed; int m_head = 0; int m_count = 0; int m_capacity = 0; + int m_smoothingRadius = 0; }; } // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/cursor.hpp b/Plugins/ZShell/Internal/cursor.hpp new file mode 100644 index 0000000..7b1356e --- /dev/null +++ b/Plugins/ZShell/Internal/cursor.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include + +namespace ZShell::internal { + +class Cursor : public QObject { + Q_OBJECT + QML_SINGLETON + QML_ELEMENT + + public: + Q_INVOKABLE void setOverride(Qt::CursorShape shape) { + if (m_override) + QGuiApplication::changeOverrideCursor(QCursor(shape)); + else { + QGuiApplication::setOverrideCursor(QCursor(shape)); + m_override = true; + } + } + + Q_INVOKABLE void clearOverride() { + if (!m_override) return; + + QGuiApplication::restoreOverrideCursor(); + m_override = false; + } + + private: + bool m_override = false; +}; + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/sparklineitem.cpp b/Plugins/ZShell/Internal/sparklineitem.cpp deleted file mode 100644 index 7c0b2f2..0000000 --- a/Plugins/ZShell/Internal/sparklineitem.cpp +++ /dev/null @@ -1,322 +0,0 @@ -#include "sparklineitem.hpp" - -#include -#include -#include -#include -#include -#include - -namespace ZShell::internal { - -SparklineItem::SparklineItem(QQuickItem* parent) : QQuickPaintedItem(parent) { - setAntialiasing(true); -} - -void SparklineItem::paint(QPainter* painter) { - const bool has1 = m_line1 && m_line1->count() >= 2; - const bool has2 = m_line2 && m_line2->count() >= 2; - if (!has1 && !has2) return; - - painter->setRenderHint(QPainter::Antialiasing, true); - - // Draw line1 first (behind), then line2 (in front) - if (has1) drawLine(painter, m_line1, m_line1Color, m_line1FillAlpha); - if (has2) drawLine(painter, m_line2, m_line2Color, m_line2FillAlpha); -} - -void SparklineItem::drawLine( - QPainter* painter, - CircularBuffer* buffer, - const QColor& color, - qreal fillAlpha) { - if (m_historyLength < 2) return; - - const qreal w = width(); - const qreal h = height(); - const int len = buffer->count(); - if (len < 2 || m_maxValue <= 0.0) return; - - qreal stepX = 0.0; - - if (m_sliding) - stepX = w / static_cast(m_historyLength - 1); - else - stepX = w / static_cast(len - 1); - - qreal startX = 0.0; - - if (m_sliding) - startX = w - (len - 1) * stepX - stepX * m_slideProgress + stepX; - else - startX = 0; - - const qreal strokePad = qCeil(m_lineWidth / 2); - const qreal curvePad = 3.0; - const qreal topPad = strokePad + curvePad; - const qreal bottomPad = strokePad; - const qreal plotTop = topPad; - const qreal plotBottom = h - bottomPad; - const qreal fillBottom = h; - const qreal plotH = qMax(1.0, plotBottom - plotTop); - - QVector points; - points.reserve(len); - - for (int i = 0; i < len; ++i) { - qreal value = 0.0; - - if (m_smoothing > 0.0) { - const int radius = qRound(m_smoothing); - qreal sum = 0.0; - int count = 0; - - for (int j = -radius; j <= radius; ++j) { - const int index = i + j; - - if (index < 0 || index >= len) continue; - - sum += buffer->at(index); - count++; - } - - value = sum / static_cast(count); - } else { - value = buffer->at(i); - } - - value = qBound(0.0, value, m_maxValue); - - const qreal x = startX + i * stepX; - const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH; - - points.append(QPointF(x, y)); - } - - QPainterPath linePath; - linePath.moveTo(points[0]); - - if (m_curveSmoothing && points.size() > 2) { - // 1.0 = standard uniform Catmull-Rom. Lower (e.g. 0.5) = tighter, less overshoot. - const qreal tension = 1.0; - const qreal k = - tension / 6.0; // Catmull-Rom -> Bezier factor (1/6 for uniform) - - const int n = points.size(); - - for (int i = 0; i < n - 1; ++i) { - // Duplicate endpoints so the first/last segments are well-defined - const QPointF& p0 = points[qMax(i - 1, 0)]; - const QPointF& p1 = points[i]; - const QPointF& p2 = points[i + 1]; - const QPointF& p3 = points[qMin(i + 2, n - 1)]; - - QPointF c1 = p1 + (p2 - p0) * k; - QPointF c2 = p2 - (p3 - p1) * k; - - // Keep control points inside the vertical range of the segment's - // endpoints so we never overshoot (no dips below zero, no - // bulges above a peak). Remember: smaller y = higher value. - const qreal segMinY = qMin(p1.y(), p2.y()); - const qreal segMaxY = qMax(p1.y(), p2.y()); - c1.setY(qBound(segMinY, c1.y(), segMaxY)); - c2.setY(qBound(segMinY, c2.y(), segMaxY)); - - linePath.cubicTo(c1, c2, p2); - } - } else { - // Existing horizontal-tangent (smoothstep-style) curve - for (int i = 0; i < points.size() - 1; ++i) { - const QPointF& p0 = points[i]; - const QPointF& p1 = points[i + 1]; - - const qreal ctrlX = (p0.x() + p1.x()) * 0.5; - linePath.cubicTo(QPointF(ctrlX, p0.y()), QPointF(ctrlX, p1.y()), p1); - } - } - - QPen pen(color, m_lineWidth); - pen.setCapStyle(Qt::RoundCap); - pen.setJoinStyle(Qt::RoundJoin); - painter->setPen(pen); - painter->setBrush(Qt::NoBrush); - painter->drawPath(linePath); - - QPainterPath fillPath = linePath; - fillPath.lineTo(startX + (len - 1) * stepX, fillBottom); - fillPath.lineTo(startX, fillBottom); - fillPath.closeSubpath(); - - QColor fillColor = color; - fillColor.setAlphaF(static_cast(fillAlpha)); - painter->setPen(Qt::NoPen); - painter->setBrush(fillColor); - painter->drawPath(fillPath); -} - -void SparklineItem::connectBuffer(CircularBuffer* buffer) { - if (!buffer) return; - - connect(buffer, &CircularBuffer::valuesChanged, this, [this]() { - update(); - }); - connect(buffer, &QObject::destroyed, this, [this, buffer]() { - if (m_line1 == buffer) { - m_line1 = nullptr; - emit line1Changed(); - } - if (m_line2 == buffer) { - m_line2 = nullptr; - emit line2Changed(); - } - update(); - }); -} - -CircularBuffer* SparklineItem::line1() const { - return m_line1; -} - -void SparklineItem::setLine1(CircularBuffer* buffer) { - if (m_line1 == buffer) return; - if (m_line1) disconnect(m_line1, nullptr, this, nullptr); - m_line1 = buffer; - connectBuffer(buffer); - emit line1Changed(); - update(); -} - -CircularBuffer* SparklineItem::line2() const { - return m_line2; -} - -void SparklineItem::setLine2(CircularBuffer* buffer) { - if (m_line2 == buffer) return; - if (m_line2) disconnect(m_line2, nullptr, this, nullptr); - m_line2 = buffer; - connectBuffer(buffer); - emit line2Changed(); - update(); -} - -QColor SparklineItem::line1Color() const { - return m_line1Color; -} - -void SparklineItem::setLine1Color(const QColor& color) { - if (m_line1Color == color) return; - m_line1Color = color; - emit line1ColorChanged(); - update(); -} - -QColor SparklineItem::line2Color() const { - return m_line2Color; -} - -void SparklineItem::setLine2Color(const QColor& color) { - if (m_line2Color == color) return; - m_line2Color = color; - emit line2ColorChanged(); - update(); -} - -qreal SparklineItem::line1FillAlpha() const { - return m_line1FillAlpha; -} - -void SparklineItem::setLine1FillAlpha(qreal alpha) { - if (qFuzzyCompare(m_line1FillAlpha, alpha)) return; - m_line1FillAlpha = alpha; - emit line1FillAlphaChanged(); - update(); -} - -qreal SparklineItem::line2FillAlpha() const { - return m_line2FillAlpha; -} - -void SparklineItem::setLine2FillAlpha(qreal alpha) { - if (qFuzzyCompare(m_line2FillAlpha, alpha)) return; - m_line2FillAlpha = alpha; - emit line2FillAlphaChanged(); - update(); -} - -qreal SparklineItem::maxValue() const { - return m_maxValue; -} - -void SparklineItem::setMaxValue(qreal value) { - if (qFuzzyCompare(m_maxValue, value)) return; - m_maxValue = value; - emit maxValueChanged(); - update(); -} - -qreal SparklineItem::slideProgress() const { - return m_slideProgress; -} - -void SparklineItem::setSlideProgress(qreal progress) { - if (qFuzzyCompare(m_slideProgress, progress)) return; - m_slideProgress = progress; - emit slideProgressChanged(); - update(); -} - -int SparklineItem::historyLength() const { - return m_historyLength; -} - -void SparklineItem::setHistoryLength(int length) { - if (m_historyLength == length) return; - m_historyLength = length; - emit historyLengthChanged(); - update(); -} - -qreal SparklineItem::lineWidth() const { - return m_lineWidth; -} - -void SparklineItem::setLineWidth(qreal width) { - if (qFuzzyCompare(m_lineWidth, width)) return; - m_lineWidth = width; - emit lineWidthChanged(); - update(); -} - -void SparklineItem::setSliding(bool sliding) { - if (m_sliding == sliding) return; - m_sliding = sliding; - emit slidingChanged(); -} - -bool SparklineItem::sliding() const { - return m_sliding; -}; - -void SparklineItem::setSmoothing(qreal smoothing) { - smoothing = qMax(0.0, smoothing); - - if (qFuzzyCompare(m_smoothing, smoothing)) return; - m_smoothing = smoothing; - emit smoothingChanged(); -} - -qreal SparklineItem::smoothing() const { - return m_smoothing; -}; - -void SparklineItem::setCurveSmoothing(bool sliding) { - if (m_curveSmoothing == sliding) return; - m_curveSmoothing = sliding; - emit curveSmoothingChanged(); -} - -bool SparklineItem::curveSmoothing() const { - return m_curveSmoothing; -}; - -} // namespace ZShell::internal diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index 4dcae3c..e6c413c 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -18,6 +18,7 @@ qml_module(ZShell-services memory.hpp memory.cpp diskinfo.hpp diskinfo.cpp storage.hpp storage.cpp + processes.hpp processes.cpp lyriccandidate.hpp lyriccandidate.cpp lyrics.hpp lyrics.cpp LIBRARIES @@ -28,6 +29,7 @@ qml_module(ZShell-services PkgConfig::Cava Sensors::Sensors ZShell-config + ZShell-settings DEPENDENCIES ZShell.Config ) diff --git a/Plugins/ZShell/Services/lyrics.cpp b/Plugins/ZShell/Services/lyrics.cpp index 6a85e75..29fe616 100644 --- a/Plugins/ZShell/Services/lyrics.cpp +++ b/Plugins/ZShell/Services/lyrics.cpp @@ -7,7 +7,7 @@ #include #include -#include "config/config.hpp" +#include "config/rootnodes.hpp" #include "config/general.hpp" #include "config/services.hpp" @@ -75,7 +75,7 @@ Lyrics::Lyrics(QObject* parent) m_loadDebounce->setInterval(k_loadDebounceMs); QObject::connect(m_loadDebounce, &QTimer::timeout, this, &Lyrics::doLoad); - const auto* cfg = config::Config::instance(); + const auto* cfg = config::ConfigSingleton::instance(); const auto* svcCfg = cfg->services(); const auto* genCfg = cfg->general(); @@ -114,7 +114,7 @@ void Lyrics::setPreferredBackend(LyricsBackend value) { m_preferredBackend = value; emit preferredBackendChanged(); - config::Config::instance()->services()->set_lyricsBackend(value); + config::ConfigSingleton::instance()->services()->set_lyricsBackend(value); scheduleLoad(); } @@ -870,7 +870,7 @@ QNetworkReply* Lyrics::getJson( void Lyrics::onPreferredBackendConfigChanged() { const LyricsBackend desired = - config::Config::instance()->services()->lyricsBackend(); + config::ConfigSingleton::instance()->services()->lyricsBackend(); if (desired == m_preferredBackend) { return; } @@ -942,7 +942,7 @@ void Lyrics::persistTrackPrefs() { } QString Lyrics::lyricsDir() { - QString dir = config::Config::instance()->general()->lyricsDir(); + QString dir = config::ConfigSingleton::instance()->general()->lyricsDir(); if (dir.isEmpty()) { return {}; } diff --git a/Plugins/ZShell/Services/memory.cpp b/Plugins/ZShell/Services/memory.cpp index eb8020c..1b1c2b9 100644 --- a/Plugins/ZShell/Services/memory.cpp +++ b/Plugins/ZShell/Services/memory.cpp @@ -3,6 +3,8 @@ #include #include +#include + namespace ZShell::services { Memory::Memory(QObject* parent) : TickingService(parent) {} @@ -15,6 +17,18 @@ qreal Memory::total() const { return m_total; } +qreal Memory::cached() const { + return m_cached; +} + +qreal Memory::readTotal() const { + return m_readTotal; +} + +qreal Memory::writeTotal() const { + return m_writeTotal; +} + qreal Memory::percentage() const { return m_total > 0.0 ? m_used / m_total : 0.0; } @@ -31,6 +45,8 @@ void Memory::tick() { QStringLiteral("MemTotal: *(\\d+)")); static const QRegularExpression reAvail( QStringLiteral("MemAvailable: *(\\d+)")); + static const QRegularExpression reCached(QStringLiteral( + "Cached: *(\\d+)")); const QString text = QString::fromLatin1(data); const auto totalMatch = reTotal.match(text); @@ -45,14 +61,50 @@ void Memory::tick() { return; } const quint64 usedKib = totalKib > availKib ? totalKib - availKib : 0; + const auto cachedMatch = reCached.match(text); + const quint64 cachedKib = + cachedMatch.hasMatch() ? cachedMatch.captured(1).toULongLong() : 0; - if (totalKib == m_lastTotal && usedKib == m_lastUsed) { + static const QRegularExpression rePgin(QStringLiteral("pgpgin (\\d+)")); + static const QRegularExpression rePgout(QStringLiteral("pgpgout (\\d+)")); + quint64 readKib = 0; + quint64 writeKib = 0; + { + QFile vf(QStringLiteral("/proc/vmstat")); + if (vf.open(QIODevice::ReadOnly | QIODevice::Text)) { + const QString vtext = QString::fromLatin1(vf.readAll()); + vf.close(); + const double pageKib = getpagesize() / 1024.0; + const auto pgin = rePgin.match(vtext); + const auto pgout = rePgout.match(vtext); + if (pgin.hasMatch()) { + readKib = static_cast(static_cast( + pgin.captured(1).toULongLong()) * + pageKib); + } + if (pgout.hasMatch()) { + writeKib = static_cast(static_cast( + pgout.captured(1).toULongLong()) * + pageKib); + } + } + } + + if (totalKib == m_lastTotal && usedKib == m_lastUsed && + cachedKib == m_lastCached && readKib == m_lastReadKib && + writeKib == m_lastWriteKib) { return; } m_lastTotal = totalKib; m_lastUsed = usedKib; + m_lastCached = cachedKib; + m_lastReadKib = readKib; + m_lastWriteKib = writeKib; m_total = static_cast(totalKib); m_used = static_cast(usedKib); + m_cached = static_cast(cachedKib); + m_readTotal = static_cast(readKib); + m_writeTotal = static_cast(writeKib); Q_EMIT changed(); } diff --git a/Plugins/ZShell/Services/memory.hpp b/Plugins/ZShell/Services/memory.hpp index e21bbc5..cb33ac7 100644 --- a/Plugins/ZShell/Services/memory.hpp +++ b/Plugins/ZShell/Services/memory.hpp @@ -14,14 +14,20 @@ class Memory : public TickingService { Q_PROPERTY(qreal used READ used NOTIFY changed) Q_PROPERTY(qreal total READ total NOTIFY changed) + Q_PROPERTY(qreal cached READ cached NOTIFY changed) Q_PROPERTY(qreal percentage READ percentage NOTIFY changed) + Q_PROPERTY(qreal readTotal READ readTotal NOTIFY changed) + Q_PROPERTY(qreal writeTotal READ writeTotal NOTIFY changed) public: explicit Memory(QObject* parent = nullptr); [[nodiscard]] qreal used() const; [[nodiscard]] qreal total() const; + [[nodiscard]] qreal cached() const; [[nodiscard]] qreal percentage() const; + [[nodiscard]] qreal readTotal() const; + [[nodiscard]] qreal writeTotal() const; signals: void changed(); @@ -32,8 +38,14 @@ class Memory : public TickingService { private: qreal m_used = 0.0; qreal m_total = 1.0; + qreal m_cached = 0.0; + qreal m_readTotal = 0.0; + qreal m_writeTotal = 0.0; quint64 m_lastUsed = 0; quint64 m_lastTotal = 0; + quint64 m_lastCached = 0; + quint64 m_lastReadKib = 0; + quint64 m_lastWriteKib = 0; }; } // namespace ZShell::services diff --git a/Plugins/ZShell/Services/processes.cpp b/Plugins/ZShell/Services/processes.cpp new file mode 100644 index 0000000..6175736 --- /dev/null +++ b/Plugins/ZShell/Services/processes.cpp @@ -0,0 +1,1161 @@ +#include "processes.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcProcessModel, "ZShell.services.processes", QtInfoMsg) + +namespace ZShell::services { + +namespace { + +QVector splitWhitespace(QByteArrayView s) { + QVector out; + qsizetype i = 0; + const qsizetype n = s.size(); + while (i < n) { + while (i < n && std::isspace(static_cast(s[i]))) { + ++i; + } + const qsizetype start = i; + while (i < n && !std::isspace(static_cast(s[i]))) { + ++i; + } + if (i > start) { + out.push_back(s.mid(start, i - start)); + } + } + return out; +} + +qint64 toI64(QByteArrayView v) { + return QByteArray(v.data(), v.size()).toLongLong(); +} + +quint64 toU64(QByteArrayView v) { + return QByteArray(v.data(), v.size()).toULongLong(); +} + +template int cmpVals(const T& a, const T& b) { + return (a < b) - (a > b); +} + + +struct FuzzyText { + QString chars; + QVector classes; +}; + +constexpr int kFuzzyScoreMatch = 16; +constexpr int kFuzzyScoreGapStart = -3; +constexpr int kFuzzyScoreGapExtension = -1; +constexpr int kFuzzyBonusBoundary = kFuzzyScoreMatch / 2; +constexpr int kFuzzyBonusNonWord = kFuzzyScoreMatch / 2; +constexpr int kFuzzyBonusCamel123 = + kFuzzyBonusBoundary + kFuzzyScoreGapExtension; +constexpr int kFuzzyBonusConsecutive = + -(kFuzzyScoreGapStart + kFuzzyScoreGapExtension); +constexpr int kFuzzyFirstCharMultiplier = 2; + +int fuzzyCharClass(QChar c) { + if (c.isLower()) { + return 1; + } + if (c.isUpper()) { + return 2; + } + if (c.isDigit() || c.category() == QChar::Number_Other) { + return 4; + } + if (c.isLetter()) { + return 3; + } + return 0; +} + +int fuzzyBonusFor(int prevClass, int currClass) { + if (prevClass == 0 && currClass != 0) { + return kFuzzyBonusBoundary; + } + if ((prevClass == 1 && currClass == 2) || + (prevClass != 4 && currClass == 4)) { + return kFuzzyBonusCamel123; + } + if (currClass == 0) { + return kFuzzyBonusNonWord; + } + return 0; +} + +FuzzyText fuzzyPrepare(const QString& src) { + FuzzyText out; + const QString decomposed = src.normalized(QString::NormalizationForm_D); + out.chars.reserve(decomposed.size()); + out.classes.reserve(decomposed.size()); + for (int i = 0; i < decomposed.size(); ++i) { + const QChar c = decomposed.at(i); + const uint cp = c.unicode(); + if (cp >= 0x0300 && cp <= 0x036F) { + continue; + } + out.classes.append(static_cast(fuzzyCharClass(c))); + out.chars.append(c.toLower()); + } + return out; +} + +int fuzzyTermScore( + const FuzzyText& text, const QString& term, int* matchEnd = nullptr) { + const int n = static_cast(text.chars.size()); + const int m = static_cast(term.size()); + if (m == 0) { + if (matchEnd) { + *matchEnd = 0; + } + return 0; + } + if (m > n) { + return -1; + } + + int pidx = 0; + int sidx = -1; + int eidx = -1; + for (int i = 0; i < n; ++i) { + if (text.chars.at(i) == term.at(pidx)) { + if (sidx < 0) { + sidx = i; + } + ++pidx; + if (pidx == m) { + eidx = i + 1; + break; + } + } + } + if (eidx < 0) { + return -1; + } + + pidx = m - 1; + for (int i = eidx - 1; i >= sidx; --i) { + if (text.chars.at(i) == term.at(pidx)) { + --pidx; + if (pidx < 0) { + sidx = i; + break; + } + } + } + if (matchEnd) { + *matchEnd = eidx; + } + + int score = 0; + pidx = 0; + bool inGap = false; + int consecutive = 0; + int firstBonus = 0; + int prevClass = sidx > 0 ? text.classes.at(sidx - 1) : 0; + for (int i = sidx; i < eidx; ++i) { + const int cc = text.classes.at(i); + if (text.chars.at(i) == term.at(pidx)) { + score += kFuzzyScoreMatch; + int bonus = fuzzyBonusFor(prevClass, cc); + if (consecutive == 0) { + firstBonus = bonus; + } else { + if (bonus == kFuzzyBonusBoundary) { + firstBonus = bonus; + } + bonus = qMax(bonus, qMax(firstBonus, kFuzzyBonusConsecutive)); + } + score += (pidx == 0) ? bonus * kFuzzyFirstCharMultiplier : bonus; + inGap = false; + ++consecutive; + ++pidx; + } else { + score += inGap ? kFuzzyScoreGapExtension : kFuzzyScoreGapStart; + inGap = true; + consecutive = 0; + firstBonus = 0; + } + prevClass = cc; + } + return score; +} + +int fuzzyQueryScore(const FuzzyText& text, const QStringList& terms) { + int score = 0; + int from = 0; + for (const QString& term : terms) { + const FuzzyText rest{text.chars.mid(from), text.classes.mid(from)}; + int end = 0; + const int s = fuzzyTermScore(rest, term, &end); + if (s < 0) { + return -1; + } + score += s; + from += end; + } + return score; +} + +const QSet& launcherWrappers() { + static const QSet s = { + "flatpak", + "env", + "distrobox", + "distrobox-enter", + "toolbox", + "toolbox-enter", + "toolbx", + "toolbx-enter", + }; + return s; +} + +const QSet& cmdlineInterpreters() { + static const QSet s = { + "sh", + "bash", + "dash", + "zsh", + "fish", + "ash", + "python", + "python2", + "python3", + "node", + "nodejs", + "perl", + "ruby", + "lua", + "php", + "java", + "dotnet", + }; + return s; +} + +const QHash& execNameExceptions() { + static const QHash s = { + {"firefox-bin", "firefox"}, + {"soffice.bin", "libreoffice"}, + {"oosplash", "libreoffice"}, + {"gnome-terminal-server", "gnome-terminal"}, + {"chrome", "google-chrome-stable"}, + }; + return s; +} + +QString sanitizeExec(const QString& execLine, const QStringList& pathDirs) { + const QStringList tokens = + execLine.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + + bool skippingLaunchers = true; + for (const QString& rawTok : tokens) { + if (rawTok.startsWith('-') || rawTok.startsWith('%')) { + continue; + } + + QString tok = rawTok; + tok.remove('"'); + + QString resolved; + const QFileInfo fi(tok); + + if (fi.isAbsolute()) { + resolved = fi.canonicalFilePath(); + } else { + for (const QString& dir : pathDirs) { + QFileInfo cand(QDir(dir).filePath(tok)); + if (cand.exists()) { + resolved = cand.canonicalFilePath(); + break; + } + } + } + + if (resolved.isEmpty()) { + continue; + } + + const QString base = QFileInfo(resolved).fileName(); + if (skippingLaunchers && launcherWrappers().contains(base)) { + continue; + } + + skippingLaunchers = false; + + return cmdlineInterpreters().contains(base) ? QString() : resolved; + } + + return {}; +} + +} // namespace + +ProcessModel::ProcessModel(QObject* parent) : QAbstractListModel(parent) { + const long ticks = ::sysconf(_SC_CLK_TCK); + if (ticks > 0) { + mClockTicksPerSec = ticks; + } + m_currentUid = ::getuid(); +} + +int ProcessModel::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) { + return 0; + } + return static_cast(mProcesses.size()); +} + +QVariant ProcessModel::data(const QModelIndex& index, int role) const { + if (!index.isValid() || index.row() < 0 || + index.row() >= mProcesses.size()) { + return {}; + } + + const ProcessEntry& p = mProcesses.at(index.row()); + switch (role) { + case PidRole: + return static_cast(p.pid); + case PpidRole: + return static_cast(p.ppid); + case CommandRole: + return p.command; + case CommandLineRole: + return p.commandLine; + case StateRole: + return QString(QChar::fromLatin1(p.state)); + case SleepingRole: + return p.state == 'S' || p.state == 'D'; + case CpuUsageRole: + return p.cpuPercent; + case MemoryUsageRole: + return static_cast(p.rssKb); + case ElapsedRole: + return p.elapsedSecs; + case UidRole: + return static_cast(p.uid); + case UserRole: + return p.username; + case IoReadBytesRole: + return static_cast(p.ioReadBytes); + case IoWriteBytesRole: + return static_cast(p.ioWriteBytes); + case IconNameRole: + return p.iconName; + default: + return {}; + } +} + +QHash ProcessModel::roleNames() const { + return { + {PidRole, "pid"}, + {PpidRole, "ppid"}, + {CommandRole, "command"}, + {CommandLineRole, "commandLine"}, + {StateRole, "state"}, + {SleepingRole, "sleeping"}, + {CpuUsageRole, "cpuUsage"}, + {MemoryUsageRole, "memoryUsage"}, + {ElapsedRole, "elapsed"}, + {UidRole, "uid"}, + {UserRole, "user"}, + {IoReadBytesRole, "ioReadBytes"}, + {IoWriteBytesRole, "ioWriteBytes"}, + {IconNameRole, "iconName"}, + }; +} + +int ProcessModel::count() const { + return static_cast(mProcesses.size()); +} + +void ProcessModel::refresh() { + doRefresh(); +} + +bool ProcessModel::killProcess(qint64 pid, int signal) const { + if (pid <= 0) { + return false; + } + return ::kill(static_cast(pid), signal) == 0; +} + +QString ProcessModel::searchText() const { + return m_searchText; +} + +void ProcessModel::setSearchText(const QString& text) { + if (text == m_searchText) { + return; + } + m_searchText = text; + Q_EMIT searchTextChanged(); + applyView(); +} + +bool ProcessModel::currentUserOnly() const { + return m_currentUserOnly; +} + +void ProcessModel::setCurrentUserOnly(bool enabled) { + if (enabled == m_currentUserOnly) { + return; + } + m_currentUserOnly = enabled; + Q_EMIT currentUserOnlyChanged(); + applyView(); +} + +int ProcessModel::sortField() const { + return m_sortField; +} + +void ProcessModel::setSortField(int field) { + if (field == m_sortField) { + return; + } + m_sortField = field; + Q_EMIT sortFieldChanged(); + applyView(); +} + +bool ProcessModel::sortAscending() const { + return m_sortAscending; +} + +void ProcessModel::setSortAscending(bool ascending) { + if (ascending == m_sortAscending) { + return; + } + m_sortAscending = ascending; + Q_EMIT sortAscendingChanged(); + applyView(); +} + +void ProcessModel::setDesktopEntryIcons( + QHash execPathToIcon, + QHash execNameToIcon) { + mExecPathToIcon = std::move(execPathToIcon); + mExecNameToIcon = std::move(execNameToIcon); + ++m_iconVersion; +} + +QString ProcessModel::resolveProcessExe(pid_t pid) { + const QString path = QStringLiteral("/proc/%1/exe").arg(pid); + const QString target = QFile::symLinkTarget(path); + + return target; +} + +std::optional ProcessModel::readStat(pid_t pid) { + QFile f(QStringLiteral("/proc/%1/stat").arg(pid)); + if (!f.open(QIODevice::ReadOnly)) { + return std::nullopt; + } + const QByteArray data = f.readAll(); + f.close(); + + const qsizetype openParen = data.indexOf('('); + const qsizetype closeParen = data.lastIndexOf(')'); + if (openParen < 0 || closeParen < 0 || closeParen <= openParen) { + return std::nullopt; + } + + RawStat out; + out.pid = pid; + out.comm = QString::fromLatin1( + data.mid(openParen + 1, closeParen - openParen - 1)); + + const QByteArrayView rest = + QByteArrayView(data).mid(closeParen + 1).trimmed(); + const auto fields = splitWhitespace(rest); + if (fields.size() < 20) { + return std::nullopt; + } + out.state = fields[0].isEmpty() ? '?' : fields[0][0]; + out.ppid = static_cast(toI64(fields[1])); + out.utime = toU64(fields[11]); + out.stime = toU64(fields[12]); + out.starttime = toU64(fields[19]); + return out; +} + +QString ProcessModel::readCommandLine(pid_t pid, const QString& fallbackComm) { + QFile f(QStringLiteral("/proc/%1/cmdline").arg(pid)); + if (!f.open(QIODevice::ReadOnly)) { + return QStringLiteral("[%1]").arg(fallbackComm); + } + QByteArray data = f.readAll(); + f.close(); + + if (data.isEmpty()) { + return QStringLiteral("[%1]").arg(fallbackComm); + } + + while (!data.isEmpty() && data.endsWith('\0')) { + data.chop(1); + } + for (qsizetype i = 0; i < data.size(); ++i) { + if (data[i] == '\0') { + data[i] = ' '; + } + } + return QString::fromUtf8(data); +} + +ProcessModel::StatusInfo ProcessModel::readStatus(pid_t pid) { + QFile f(QStringLiteral("/proc/%1/status").arg(pid)); + if (!f.open(QIODevice::ReadOnly)) { + return {}; + } + const QByteArray data = f.readAll(); + f.close(); + + StatusInfo out; + qsizetype pos = 0; + while (pos < data.size()) { + const qsizetype nl = data.indexOf('\n', pos); + const qsizetype lineLen = nl < 0 ? data.size() - pos : nl - pos; + const QByteArrayView line = QByteArrayView(data).mid(pos, lineLen); + pos = nl < 0 ? data.size() : nl + 1; + + if (line.startsWith("VmRSS:")) { + const auto fields = splitWhitespace(line); + if (fields.size() >= 2) { + out.rssKb = toU64(fields[1]); + } + } else if (line.startsWith("Uid:")) { + const auto fields = splitWhitespace(line); + if (fields.size() >= 2) { + out.uid = static_cast(toU64(fields[1])); + } + } + } + return out; +} + +ProcessModel::IoInfo ProcessModel::readIo(pid_t pid) { + QFile f(QStringLiteral("/proc/%1/io").arg(pid)); + if (!f.open(QIODevice::ReadOnly)) { + return {}; + } + const QByteArray data = f.readAll(); + f.close(); + + IoInfo out; + qsizetype pos = 0; + while (pos < data.size()) { + const qsizetype nl = data.indexOf('\n', pos); + const qsizetype lineLen = nl < 0 ? data.size() - pos : nl - pos; + const QByteArrayView line = QByteArrayView(data).mid(pos, lineLen); + pos = nl < 0 ? data.size() : nl + 1; + + if (line.startsWith("read_bytes:")) { + const auto fields = splitWhitespace(line); + if (fields.size() >= 2) { + out.readBytes = toU64(fields[1]); + } + } else if (line.startsWith("write_bytes:")) { + const auto fields = splitWhitespace(line); + if (fields.size() >= 2) { + out.writeBytes = toU64(fields[1]); + } + } + } + return out; +} + +QString ProcessModel::resolveUsername(uid_t uid, QHash& cache) { + if (const auto it = cache.constFind(uid); it != cache.constEnd()) { + return it.value(); + } + + std::array buf{}; + struct passwd pwd{}; + struct passwd* result = nullptr; + + QString name; + if (::getpwuid_r(uid, &pwd, buf.data(), buf.size(), &result) == 0 && + result != nullptr) { + name = QString::fromLocal8Bit(pwd.pw_name); + } else { + name = QString::number(uid); + } + + cache.insert(uid, name); + return name; +} + +std::optional ProcessModel::readUptimeSeconds() { + QFile f(QStringLiteral("/proc/uptime")); + if (!f.open(QIODevice::ReadOnly)) { + return std::nullopt; + } + const QByteArray data = f.readAll(); + f.close(); + + const auto fields = splitWhitespace(QByteArrayView(data)); + if (fields.isEmpty()) { + return std::nullopt; + } + bool ok = false; + const double uptime = + QByteArray(fields[0].data(), fields[0].size()).toDouble(&ok); + return ok ? std::make_optional(uptime) : std::nullopt; +} + +ProcessModel::ScanResult ProcessModel::scanProcesses( + double uptime, + double deltaSecs, + QHash prevPidToAll, + QVector prevAll, + QHash uidCache, + long clockTicksPerSec, + QHash execPathToIcon, + QHash execNameToIcon, + QStringList pathDirs, + bool desktopEntryIconsChanged, + quint64 iconVersion) { + Q_UNUSED( + pathDirs); // kept for future cgroup fallback; sanitizeExec already ran on GUI thread + + QDir procDir(QStringLiteral("/proc")); + const auto entries = + procDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::NoSort); + + QVector fresh; + fresh.reserve(entries.size()); + + for (const QString& name : entries) { + bool isPid = false; + const pid_t pid = static_cast(name.toLong(&isPid)); + if (!isPid || pid <= 0) { + continue; + } + + const auto statOpt = readStat(pid); + if (!statOpt) { + continue; + } + const RawStat& stat = *statOpt; + + ProcessEntry entry; + entry.pid = stat.pid; + entry.ppid = stat.ppid; + entry.command = stat.comm; + entry.state = stat.state; + entry.starttime = stat.starttime; + + const auto prevIt = prevPidToAll.constFind(pid); + const bool samePidInstance = prevIt != prevPidToAll.constEnd() && + prevAll.at(prevIt.value()).starttime == + stat.starttime; + if (samePidInstance) { + entry.commandLine = prevAll.at(prevIt.value()).commandLine; + } else { + entry.commandLine = readCommandLine(pid, stat.comm); + } + + if (!samePidInstance || desktopEntryIconsChanged) { + const QString exePath = resolveProcessExe(pid); + + QString iconName; + + if (!exePath.isEmpty()) { + iconName = execPathToIcon.value(exePath); + } + + if (iconName.isEmpty()) { + QString execName = execNameExceptions().value( + QFileInfo(exePath).fileName(), + QFileInfo(exePath).fileName()); + + iconName = execNameToIcon.value(execName); + } + + if (iconName.isEmpty()) { + QString baseName = stat.comm; + const auto exIt = execNameExceptions().constFind(baseName); + if (exIt != execNameExceptions().constEnd()) { + baseName = exIt.value(); + } + iconName = execNameToIcon.value(baseName); + } + + entry.iconName = iconName; + } else { + entry.iconName = prevAll.at(prevIt.value()).iconName; + } + + const StatusInfo status = readStatus(pid); + entry.rssKb = status.rssKb; + entry.uid = status.uid; + entry.username = resolveUsername(status.uid, uidCache); + + const IoInfo io = readIo(pid); + entry.ioReadBytes = io.readBytes; + entry.ioWriteBytes = io.writeBytes; + + entry.elapsedSecs = std::max( + 0.0, + uptime - (static_cast(stat.starttime) / + static_cast(clockTicksPerSec))); + entry.lastUtime = stat.utime; + entry.lastStime = stat.stime; + + if (samePidInstance && deltaSecs > 0.0) { + const ProcessEntry& prev = prevAll.at(prevIt.value()); + const quint64 prevTicks = prev.lastUtime + prev.lastStime; + const quint64 nowTicks = stat.utime + stat.stime; + const quint64 deltaTicks = + nowTicks > prevTicks ? nowTicks - prevTicks : 0; + entry.cpuPercent = + (static_cast(deltaTicks) / + (deltaSecs * static_cast(clockTicksPerSec))) * + 100.0; + } else { + entry.cpuPercent = 0.0; + } + + fresh.push_back(std::move(entry)); + } + + ScanResult result; + result.entries = std::move(fresh); + result.newUidNames = std::move(uidCache); + result.iconVersion = iconVersion; + return result; +} + +void ProcessModel::doRefresh() { + if (mWatcher) { + mRefreshPending = true; + return; + } + + const auto uptimeOpt = readUptimeSeconds(); + if (!uptimeOpt) { + qCWarning(lcProcessModel, "failed to read /proc/uptime"); + return; + } + const double uptime = *uptimeOpt; + const double deltaSecs = mLastUptimeSecs >= 0.0 ? (uptime - mLastUptimeSecs) + : -1.0; + mPendingUptime = uptime; + + QHash prevPidToAll = mPidToAll; + QVector prevAll = mAll; + QHash uidCache = mUidToName; + QHash execPathToIcon = mExecPathToIcon; + QHash execNameToIcon = mExecNameToIcon; + const long ticks = mClockTicksPerSec; + const bool desktopEntryIconsChanged = + (m_iconVersion != m_scannedIconVersion); + + mWatcher = new QFutureWatcher(this); + QObject::connect( + mWatcher, + &QFutureWatcher::finished, + this, + &ProcessModel::onScanFinished); + + mWatcher->setFuture( + QtConcurrent::run( + &ProcessModel::scanProcesses, + uptime, + deltaSecs, + std::move(prevPidToAll), + std::move(prevAll), + std::move(uidCache), + ticks, + std::move(execPathToIcon), + std::move(execNameToIcon), + QStringList{}, + desktopEntryIconsChanged, + m_iconVersion)); +} + +void ProcessModel::onScanFinished() { + QFutureWatcher* watcher = mWatcher; + mWatcher = nullptr; + if (!watcher) { + return; + } + ScanResult result = watcher->result(); + watcher->deleteLater(); + + mAll = std::move(result.entries); + m_scannedIconVersion = result.iconVersion; + mPidToAll.clear(); + mPidToAll.reserve(mAll.size()); + for (int i = 0; i < mAll.size(); ++i) { + mPidToAll.insert(mAll.at(i).pid, i); + } + for (auto it = result.newUidNames.constBegin(); + it != result.newUidNames.constEnd(); + ++it) { + mUidToName.insert(it.key(), it.value()); + } + + mLastUptimeSecs = mPendingUptime; + applyView(); + + if (mRefreshPending) { + mRefreshPending = false; + doRefresh(); + } +} + +void ProcessModel::applyView() { + const bool searching = !m_searchText.isEmpty(); + QStringList terms; + if (searching) { + terms = m_searchText.split( + QRegularExpression(QStringLiteral("\\s+")), Qt::SkipEmptyParts); + for (int i = 0; i < terms.size(); ++i) { + terms[i] = fuzzyPrepare(terms.at(i)).chars; + } + } + + QVector filtered; + filtered.reserve(mAll.size()); + + for (const ProcessEntry& src : mAll) { + if (m_currentUserOnly && src.uid != m_currentUid) { + continue; + } + + ProcessEntry entry = src; + if (searching) { + int score = fuzzyQueryScore(fuzzyPrepare(src.command), terms); + if (!src.commandLine.isEmpty() && src.commandLine != src.command) { + score = qMax( + score, + fuzzyQueryScore(fuzzyPrepare(src.commandLine), terms)); + } + if (score < 0) { + continue; + } + entry.searchScore = score; + } + filtered.push_back(std::move(entry)); + } + + QVector desired; + desired.reserve(filtered.size()); + if (searching || m_sortField != 0) { + desired = filtered; + std::stable_sort( + desired.begin(), + desired.end(), + [this, searching](const ProcessEntry& a, const ProcessEntry& b) { + if (searching && a.searchScore != b.searchScore) { + return a.searchScore > b.searchScore; + } + const int c = compareEntries(a, b); + if (c != 0) { + return m_sortAscending ? c < 0 : c > 0; + } + return a.pid < b.pid; + }); + } else { + QSet shown; + shown.reserve(mProcesses.size()); + for (const ProcessEntry& e : mProcesses) { + shown.insert(e.pid); + } + QSet placed; + placed.reserve(filtered.size()); + for (const ProcessEntry& e : filtered) { + if (shown.contains(e.pid)) { + desired.push_back(e); + placed.insert(e.pid); + } + } + for (const ProcessEntry& e : filtered) { + if (!placed.contains(e.pid)) { + desired.push_back(e); + } + } + } + + converge(desired); +} + +void ProcessModel::converge(const QVector& desired) { + const qsizetype oldCount = mProcesses.size(); + + if (desired.size() == oldCount) { + bool sameOrder = true; + for (qsizetype i = 0; i < desired.size(); ++i) { + if (desired.at(i).pid != mProcesses.at(i).pid) { + sameOrder = false; + break; + } + } + if (sameOrder) { + for (qsizetype i = 0; i < desired.size(); ++i) { + mProcesses[i] = desired.at(i); + } + static const QList kVolatileRoles = { + CpuUsageRole, + MemoryUsageRole, + ElapsedRole, + StateRole, + SleepingRole, + IoReadBytesRole, + IoWriteBytesRole, + IconNameRole}; + if (!mProcesses.isEmpty()) { + const int last = static_cast(desired.size()) - 1; + Q_EMIT dataChanged(index(0), index(last), kVolatileRoles); + } + return; + } + } + + + QSet oldPids; + oldPids.reserve(oldCount); + for (const auto& e : mProcesses) { + oldPids.insert(e.pid); + } + QSet keepPids; + keepPids.reserve(desired.size()); + QVector added; + for (const auto& e : desired) { + keepPids.insert(e.pid); + if (!oldPids.contains(e.pid)) { + added.append(&e); + } + } + + QVector removed; + for (qsizetype i = 0; i < oldCount; ++i) { + if (!keepPids.contains(mProcesses.at(i).pid)) { + removed.append(static_cast(i)); + } + } + std::sort(removed.begin(), removed.end(), [](int a, int b) { + return a > b; + }); + for (int idx : removed) { + beginRemoveRows(QModelIndex(), idx, idx); + mProcesses.removeAt(idx); + endRemoveRows(); + } + + if (!added.isEmpty()) { + const int start = static_cast(mProcesses.size()); + beginInsertRows( + QModelIndex(), start, start + static_cast(added.size()) - 1); + for (const auto* e : added) { + mProcesses.append(*e); + } + endInsertRows(); + } + + bool ordered = true; + for (qsizetype i = 0; i < desired.size(); ++i) { + if (mProcesses.at(i).pid != desired.at(i).pid) { + ordered = false; + break; + } + } + if (!ordered) { + mProcesses = desired; + layoutChanged( + QList(), + QAbstractItemModel::VerticalSortHint); + } + + if (mProcesses.size() != oldCount) { + Q_EMIT countChanged(); + } +} + +int ProcessModel::compareEntries( + const ProcessEntry& a, const ProcessEntry& b) const { + switch (m_sortField) { + case 1: + return cmpVals(a.pid, b.pid); + case 2: + return cmpVals(a.ppid, b.ppid); + case 3: + return QString::compare(a.command, b.command, Qt::CaseInsensitive); + case 4: + return QString::compare( + a.commandLine, b.commandLine, Qt::CaseInsensitive); + case 5: + return cmpVals(a.state, b.state); + case 6: + return cmpVals(isSleeping(a), isSleeping(b)); + case 7: + return cmpVals(a.cpuPercent, b.cpuPercent); + case 8: + return cmpVals(a.rssKb, b.rssKb); + case 9: + return cmpVals(a.elapsedSecs, b.elapsedSecs); + case 10: + return cmpVals(a.uid, b.uid); + case 11: + return QString::compare(a.username, b.username, Qt::CaseInsensitive); + case 12: + return cmpVals(a.ioReadBytes, b.ioReadBytes); + case 13: + return cmpVals(a.ioWriteBytes, b.ioWriteBytes); + default: + return 0; + } +} + +bool ProcessModel::isSleeping(const ProcessEntry& e) { + return e.state == 'S' || e.state == 'D'; +} + +Processes::Processes(QObject* parent) + : TickingService(parent), m_model(new ProcessModel(this)) { + QObject::connect( + m_model, &ProcessModel::countChanged, this, &Processes::countChanged); + QObject::connect( + m_model, + &ProcessModel::sortFieldChanged, + this, + &Processes::sortFieldChanged); + QObject::connect( + m_model, + &ProcessModel::sortAscendingChanged, + this, + &Processes::sortAscendingChanged); + QObject::connect( + m_model, + &ProcessModel::searchTextChanged, + this, + &Processes::searchTextChanged); + QObject::connect( + m_model, + &ProcessModel::currentUserOnlyChanged, + this, + &Processes::currentUserOnlyChanged); +} + +QAbstractItemModel* Processes::list() const { + return m_model; +} + +int Processes::count() const { + return m_model->count(); +} + +Processes::SortField Processes::sortField() const { + return static_cast(m_model->sortField()); +} + +void Processes::setSortField(SortField field) { + m_model->setSortField(static_cast(field)); +} + +bool Processes::sortAscending() const { + return m_model->sortAscending(); +} + +void Processes::setSortAscending(bool ascending) { + m_model->setSortAscending(ascending); +} + +QString Processes::searchText() const { + return m_model->searchText(); +} + +void Processes::setSearchText(const QString& text) { + m_model->setSearchText(text); +} + +bool Processes::currentUserOnly() const { + return m_model->currentUserOnly(); +} + +void Processes::setCurrentUserOnly(bool enabled) { + m_model->setCurrentUserOnly(enabled); +} + +void Processes::setDesktopEntries(const QVariantList& entries) { + QHash execPathToIcon; + QHash execNameToIcon; + execPathToIcon.reserve(entries.size()); + execNameToIcon.reserve(entries.size()); + + const QStringList pathDirs = + qEnvironmentVariable("PATH").split(':', Qt::SkipEmptyParts); + + for (const QVariant& v : entries) { + const QVariantMap m = v.toMap(); + + const QString icon = m.value(QStringLiteral("icon")).toString(); + + const QStringList command = + m.value(QStringLiteral("command")).toStringList(); + + if (icon.isEmpty()) { + continue; + } + + if (command.isEmpty()) { + continue; + } + + const QString execPath = sanitizeExec(command.join(' '), pathDirs); + + if (execPath.isEmpty()) { + continue; + } + + execPathToIcon.insert(execPath, icon); + execNameToIcon.insert(QFileInfo(execPath).fileName(), icon); + } + + m_model->setDesktopEntryIcons( + std::move(execPathToIcon), std::move(execNameToIcon)); +} + +void Processes::sortBy(SortField field) { + if (m_model->sortField() == static_cast(field)) { + m_model->setSortAscending(!m_model->sortAscending()); + } else { + m_model->setSortField(static_cast(field)); + m_model->setSortAscending(true); + } +} + +void Processes::refresh() { + m_model->refresh(); +} + +void Processes::tick() { + m_model->refresh(); +} + +bool Processes::killProcess(qint64 pid, int signal) const { + return m_model->killProcess(pid, signal); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/processes.hpp b/Plugins/ZShell/Services/processes.hpp new file mode 100644 index 0000000..b82cac6 --- /dev/null +++ b/Plugins/ZShell/Services/processes.hpp @@ -0,0 +1,259 @@ +#pragma once + +#include "tickingservice.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +template class QFutureWatcher; + +namespace ZShell::services { + +class ProcessModel : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(int count READ count NOTIFY countChanged) + + public: + enum Roles : quint16 { + PidRole = Qt::UserRole + 1, + PpidRole, + CommandRole, + CommandLineRole, + StateRole, + SleepingRole, + CpuUsageRole, + MemoryUsageRole, + ElapsedRole, + UidRole, + UserRole, + IoReadBytesRole, + IoWriteBytesRole, + IconNameRole, + }; + Q_ENUM(Roles) + + explicit ProcessModel(QObject* parent = nullptr); + + [[nodiscard]] int rowCount( + const QModelIndex& parent = QModelIndex()) const override; + [[nodiscard]] QVariant data( + const QModelIndex& index, int role) const override; + [[nodiscard]] QHash roleNames() const override; + + [[nodiscard]] int count() const; + + void refresh(); + bool killProcess(qint64 pid, int signal = 15) const; + void setDesktopEntryIcons( + QHash execPathToIcon, + QHash execNameToIcon); + + [[nodiscard]] QString searchText() const; + void setSearchText(const QString& text); + + [[nodiscard]] bool currentUserOnly() const; + void setCurrentUserOnly(bool enabled); + + [[nodiscard]] int sortField() const; + void setSortField(int field); + + [[nodiscard]] bool sortAscending() const; + void setSortAscending(bool ascending); + + signals: + void countChanged(); + void searchTextChanged(); + void currentUserOnlyChanged(); + void sortFieldChanged(); + void sortAscendingChanged(); + + private: + struct RawStat { + pid_t pid = 0; + pid_t ppid = 0; + QString comm; + char state = '?'; + quint64 utime = 0; + quint64 stime = 0; + quint64 starttime = 0; + }; + + struct ProcessEntry { + pid_t pid = 0; + pid_t ppid = 0; + QString command; + QString commandLine; + char state = '?'; + double cpuPercent = 0.0; + quint64 rssKb = 0; + double elapsedSecs = 0.0; + uid_t uid = 0; + QString username; + QString iconName; + quint64 ioReadBytes = 0; + quint64 ioWriteBytes = 0; + int searchScore = -1; + + quint64 lastUtime = 0; + quint64 lastStime = 0; + quint64 starttime = 0; + }; + + struct StatusInfo { + quint64 rssKb = 0; + uid_t uid = 0; + }; + + struct IoInfo { + quint64 readBytes = 0; + quint64 writeBytes = 0; + }; + + struct ScanResult { + QVector entries; + QHash newUidNames; + quint64 iconVersion = 0; + }; + + static std::optional readStat(pid_t pid); + static QString readCommandLine(pid_t pid, const QString& fallbackComm); + static StatusInfo readStatus(pid_t pid); + static IoInfo readIo(pid_t pid); + static std::optional readUptimeSeconds(); + static QString resolveUsername(uid_t uid, QHash& cache); + static QString resolveProcessExe(pid_t pid); + + static ScanResult scanProcesses( + double uptime, + double deltaSecs, + QHash prevPidToAll, + QVector prevAll, + QHash uidCache, + long clockTicksPerSec, + QHash execPathToIcon, + QHash execNameToIcon, + QStringList pathDirs, + bool desktopEntryIconsChanged, + quint64 iconVersion); + + void doRefresh(); + void onScanFinished(); + void applyView(); + void converge(const QVector& desired); + [[nodiscard]] int compareEntries( + const ProcessEntry& a, const ProcessEntry& b) const; + [[nodiscard]] static bool isSleeping(const ProcessEntry& e); + + QVector mAll; + QHash mPidToAll; + + QVector mProcesses; + QHash mUidToName; + + double mLastUptimeSecs = -1.0; + long mClockTicksPerSec = 100; + + QFutureWatcher* mWatcher = nullptr; + bool mRefreshPending = false; + double mPendingUptime = -1.0; + + QString m_searchText; + bool m_currentUserOnly = true; + uid_t m_currentUid = 0; + int m_sortField = 0; + bool m_sortAscending = true; + + QHash mExecPathToIcon; + QHash mExecNameToIcon; + + quint64 m_iconVersion = 0; + quint64 m_scannedIconVersion = 0; +}; + +class Processes : public TickingService { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + public: + enum SortField : int { + Unsorted = 0, + Pid, + Ppid, + Command, + CommandLine, + State, + Sleeping, + CpuUsage, + MemoryUsage, + Elapsed, + Uid, + User, + IoReadBytes, + IoWriteBytes + }; + Q_ENUM(SortField) + + private: + Q_PROPERTY(QAbstractItemModel* list READ list CONSTANT) + Q_PROPERTY(int count READ count NOTIFY countChanged) + Q_PROPERTY( + SortField sortField READ sortField WRITE setSortField NOTIFY + sortFieldChanged) + Q_PROPERTY( + bool sortAscending READ sortAscending WRITE setSortAscending NOTIFY + sortAscendingChanged) + Q_PROPERTY( + QString searchText READ searchText WRITE setSearchText NOTIFY + searchTextChanged) + Q_PROPERTY( + bool currentUserOnly READ currentUserOnly WRITE setCurrentUserOnly + NOTIFY currentUserOnlyChanged) + + public: + explicit Processes(QObject* parent = nullptr); + + [[nodiscard]] QAbstractItemModel* list() const; + + [[nodiscard]] int count() const; + + [[nodiscard]] SortField sortField() const; + void setSortField(SortField field); + + [[nodiscard]] bool sortAscending() const; + void setSortAscending(bool ascending); + + [[nodiscard]] QString searchText() const; + void setSearchText(const QString& text); + + [[nodiscard]] bool currentUserOnly() const; + void setCurrentUserOnly(bool enabled); + + Q_INVOKABLE void sortBy(SortField field); + Q_INVOKABLE void refresh(); + Q_INVOKABLE bool killProcess(qint64 pid, int signal = 15) const; + Q_INVOKABLE void setDesktopEntries(const QVariantList& entries); + + + signals: + void countChanged(); + void sortFieldChanged(); + void sortAscendingChanged(); + void searchTextChanged(); + void currentUserOnlyChanged(); + + protected: + void tick() override; + + private: + ProcessModel* m_model; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/tickingservice.cpp b/Plugins/ZShell/Services/tickingservice.cpp index 96083d5..05e6c83 100644 --- a/Plugins/ZShell/Services/tickingservice.cpp +++ b/Plugins/ZShell/Services/tickingservice.cpp @@ -12,7 +12,10 @@ namespace ZShell::services { TickingService::TickingService(QObject* parent) : Service(parent), m_timer(new QTimer(this)) { m_timer->setSingleShot(false); - QObject::connect(m_timer, &QTimer::timeout, this, [this] { tick(); }); + QObject::connect(m_timer, &QTimer::timeout, this, [this] { + tick(); + updateTriggered(); + }); QString configPath = QDir::homePath() + QStringLiteral("/.config/zshell/config.json"); diff --git a/Plugins/ZShell/Services/tickingservice.hpp b/Plugins/ZShell/Services/tickingservice.hpp index 324707f..e73a911 100644 --- a/Plugins/ZShell/Services/tickingservice.hpp +++ b/Plugins/ZShell/Services/tickingservice.hpp @@ -18,6 +18,7 @@ class TickingService : public Service { signals: void updateIntervalChanged(); + void updateTriggered(); protected: void start() final; diff --git a/Plugins/ZShell/Services/usagefmt.cpp b/Plugins/ZShell/Services/usagefmt.cpp index 1012da1..1d4cf32 100644 --- a/Plugins/ZShell/Services/usagefmt.cpp +++ b/Plugins/ZShell/Services/usagefmt.cpp @@ -14,6 +14,24 @@ bool finitePositive(qreal v) { namespace ZShell::services::usagefmt { +FormatResult UsageFmt::formatKib(qreal kib) const { + if (!finitePositive(kib)) { + return {0.0, 0.0, "KiB"}; + } + + if (kib >= kGib) { + return {kib / kGib, 0.0, "GiB"}; + } + if (kib >= kMib) { + return {kib / kMib, 0.0, "MiB"}; + } + if (kib >= kKib) { + return {kib / kKib, 0.0, "KiB"}; + } + + return {kib, 0.0, "KiB"}; +} + FormatResult UsageFmt::formatKib(qreal kib, qreal total) const { if (!finitePositive(kib) || !finitePositive(total)) { return {0.0, 0.0, "KiB"}; diff --git a/Plugins/ZShell/Services/usagefmt.hpp b/Plugins/ZShell/Services/usagefmt.hpp index ca910d4..38a06fb 100644 --- a/Plugins/ZShell/Services/usagefmt.hpp +++ b/Plugins/ZShell/Services/usagefmt.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -27,6 +28,7 @@ class UsageFmt : public QObject { QML_SINGLETON public: + Q_INVOKABLE [[nodiscard]] FormatResult formatKib(qreal kib) const; Q_INVOKABLE [[nodiscard]] FormatResult formatKib( qreal kib, qreal total) const; }; diff --git a/Plugins/ZShell/Settings/CMakeLists.txt b/Plugins/ZShell/Settings/CMakeLists.txt new file mode 100644 index 0000000..eb0c6f2 --- /dev/null +++ b/Plugins/ZShell/Settings/CMakeLists.txt @@ -0,0 +1,17 @@ +qml_module(ZShell-settings + URI ZShell.Settings + INCLUDE_PREFIX settings + SOURCES + common.cpp + schema.cpp + codecs.cpp + quarantine.cpp + node.cpp + objectnode.cpp + listnode.cpp + rootnode.cpp + settingsfile.cpp + changebatcher.cpp + LIBRARIES + zshell-util +) diff --git a/Plugins/ZShell/Settings/changebatcher.cpp b/Plugins/ZShell/Settings/changebatcher.cpp new file mode 100644 index 0000000..ac4b215 --- /dev/null +++ b/Plugins/ZShell/Settings/changebatcher.cpp @@ -0,0 +1,25 @@ +#include "changebatcher.hpp" + +namespace ZShell::settings { + +ChangeBatcher::ChangeBatcher(QObject* parent) + : QObject(parent) + , m_dirty(false) {} + +void ChangeBatcher::dirty() { + if (m_dirty) + return; + + m_dirty = true; + QMetaObject::invokeMethod(this, &ChangeBatcher::flush, Qt::QueuedConnection); +} + +void ChangeBatcher::flush() { + if (!m_dirty) + return; + + m_dirty = false; + emit dirtied(); +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/changebatcher.hpp b/Plugins/ZShell/Settings/changebatcher.hpp new file mode 100644 index 0000000..17ee2b3 --- /dev/null +++ b/Plugins/ZShell/Settings/changebatcher.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace ZShell::settings { + +class ChangeBatcher : public QObject { + Q_OBJECT + +public: + explicit ChangeBatcher(QObject* parent = nullptr); + + void dirty(); + +signals: + void dirtied(); + +private: + bool m_dirty; + + void flush(); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/codecs.cpp b/Plugins/ZShell/Settings/codecs.cpp new file mode 100644 index 0000000..478d34c --- /dev/null +++ b/Plugins/ZShell/Settings/codecs.cpp @@ -0,0 +1,371 @@ +#include "codecs.hpp" + +#include +#include +#include + +#include + +#include "util/metaenum.hpp" + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +namespace { + +// We don't know the option here so it isn't set, it should be set by the caller +DecodeResult error(DiagnosticType::Type type, const QString& message) { + Diagnostic error; + error.type = type; + error.message = message; + return { .value = QVariant(), .error = error, .indexPath = {} }; +} + +DecodeResult mismatch(ExpectedType expected, const QJsonValue& value) { + return { .value = QVariant(), .error = Diagnostic::mismatch(expected, value), .indexPath = {} }; +} + +DecodeResult mismatch(const QList& expected, const QJsonValue& value) { + return { .value = QVariant(), .error = Diagnostic::mismatch(expected, value), .indexPath = {} }; +} + +template ValueCodec* makeListCodec(const QMetaType& type) { + const auto* elementCodec = ValueCodec::codecFor(QMetaType::fromType()); + return elementCodec ? new ListCodec(type, elementCodec) : nullptr; +} + +// Unions are keyed by their alternatives in order, since order decides which one wins +QString unionKey(const QList& types) { + QStringList ids; + ids.reserve(types.size()); + for (const auto& type : types) + ids << QString::number(type.id()); + return ids.join(u","_s); +} + +QList expectedTypesOf(const QList& alternatives) { + QList types; + types.reserve(alternatives.size()); + for (const auto* codec : alternatives) + types << codec->expected(); + return types; +} + +using ListFactory = ValueCodec* (*)(const QMetaType&); + +const QHash& listFactories() { + static const QHash k_factories{ + { QMetaType::fromType().id(), &makeListCodec }, + { QMetaType::fromType>().id(), &makeListCodec> }, + }; + return k_factories; +} + +} // namespace + +ValueCodec::ValueCodec(const QMetaType& type, ExpectedType expected) + : m_type(type) + , m_expected(expected) {} + +QMetaType ValueCodec::type() const { + return m_type; +} + +ExpectedType ValueCodec::expected() const { + return m_expected; +} + +ValueCodec* ValueCodec::codecFor(const QMetaType& type) { + // Cache for codecs, keyed by type id + static QHash s_registry; + + // Cached lookup + if (const auto it = s_registry.constFind(type.id()); it != s_registry.constEnd()) + return *it; + + ValueCodec* codec = nullptr; + switch (type.id()) { + case QMetaType::Bool: + codec = new BoolCodec(type); + break; + case QMetaType::Int: + codec = new IntCodec(type); + break; + case QMetaType::Double: + codec = new RealCodec(type); + break; + case QMetaType::QString: + codec = new StringCodec(type); + break; + case QMetaType::QVariantList: + codec = new VariantListCodec(type); + break; + case QMetaType::QVariantMap: + codec = new VariantMapCodec(type); + break; + default: + if (const auto factory = listFactories().constFind(type.id()); factory != listFactories().constEnd()) + codec = (*factory)(type); + else if (util::isSupportedEnum(type)) + codec = new EnumCodec(type, util::metaEnumFor(type)); + break; + } + + // Cache codec + if (codec) + s_registry.insert(type.id(), codec); + + return codec; +} + +ValueCodec* ValueCodec::unionFor(const QList& types) { + // Cache for union codecs, keyed by their alternatives + static QHash s_registry; + + if (types.size() < 2) { + qCCritical(lcSettings, "A union needs at least two types, got %lld", types.size()); + return nullptr; + } + + const auto key = unionKey(types); + + // Cached lookup + if (const auto it = s_registry.constFind(key); it != s_registry.constEnd()) + return *it; + + QList alternatives; + alternatives.reserve(types.size()); + + for (const auto& type : types) { + const auto* codec = codecFor(type); + if (!codec) { + qCCritical(lcSettings, "No codec found for type %s, cannot build union", type.name()); + return nullptr; + } + alternatives << codec; + } + + auto* const codec = new UnionCodec(alternatives); + s_registry.insert(key, codec); + + return codec; +} + +QJsonValue BoolCodec::encode(const QVariant& value) const { + return value.toBool(); +} + +DecodeResult BoolCodec::decode(const QJsonValue& value) const { + // 1 and "true" are not booleans + if (!value.isBool()) + return mismatch(ExpectedType::Bool, value); + + return { .value = value.toBool(), .error = std::nullopt, .indexPath = {} }; +} + +QJsonValue IntCodec::encode(const QVariant& value) const { + return value.toInt(); +} + +DecodeResult IntCodec::decode(const QJsonValue& value) const { + if (!value.isDouble()) + return mismatch(ExpectedType::Int, value); + + const auto num = value.toDouble(); + + double integral; + if (std::fpclassify(std::modf(num, &integral)) != FP_ZERO) + return error( + DiagnosticType::InvalidValue, QStringLiteral("Expected an integer, got the real %1").arg(QString::number(num))); + + constexpr auto k_min = std::numeric_limits::min(); + constexpr auto k_max = std::numeric_limits::max(); + if (num < static_cast(k_min) || num > static_cast(k_max)) { + const auto message = QStringLiteral("Integer %1 is out of range, expected between %2 and %3").arg(QString::number(num, 'f', 0), QString::number(k_min), QString::number(k_max)); + return error(DiagnosticType::InvalidValue, message); + } + + return { .value = static_cast(num), .error = std::nullopt, .indexPath = {} }; +} + +QJsonValue RealCodec::encode(const QVariant& value) const { + return value.toDouble(); +} + +DecodeResult RealCodec::decode(const QJsonValue& value) const { + if (!value.isDouble()) + return mismatch(ExpectedType::Real, value); + + return { .value = QVariant::fromValue(value.toDouble()), .error = std::nullopt, .indexPath = {} }; +} + +QJsonValue StringCodec::encode(const QVariant& value) const { + return value.toString(); +} + +DecodeResult StringCodec::decode(const QJsonValue& value) const { + if (!value.isString()) + return mismatch(ExpectedType::String, value); + + return { .value = value.toString(), .error = std::nullopt, .indexPath = {} }; +} + +QJsonValue VariantListCodec::encode(const QVariant& value) const { + return QJsonArray::fromVariantList(value.toList()); +} + +DecodeResult VariantListCodec::decode(const QJsonValue& value) const { + if (!value.isArray()) + return mismatch(ExpectedType::Array, value); + + return { .value = value.toArray().toVariantList(), .error = std::nullopt, .indexPath = {} }; +} + +QJsonValue VariantMapCodec::encode(const QVariant& value) const { + return QJsonObject::fromVariantMap(value.toMap()); +} + +DecodeResult VariantMapCodec::decode(const QJsonValue& value) const { + if (!value.isObject()) + return mismatch(ExpectedType::Object, value); + + return { .value = value.toObject().toVariantMap(), .error = std::nullopt, .indexPath = {} }; +} + +EnumCodec::EnumCodec(const QMetaType& type, const QMetaEnum& metaEnum) + : ValueCodec(type, ExpectedType::String) + , m_metaEnum(metaEnum) {} + +QJsonValue EnumCodec::encode(const QVariant& value) const { + const auto* key = util::enumKeyFor(m_metaEnum, value); + if (!key) { + qCWarning( + lcSettings, "Cannot encode value %lld of enum %s, no such enumerator", value.toLongLong(), m_type.name()); + return {}; + } + + return QString::fromUtf8(key); +} + +DecodeResult EnumCodec::decode(const QJsonValue& value) const { + if (!value.isString()) + return mismatch(ExpectedType::String, value); + + const auto key = value.toString(); + + for (int i = 0; i < m_metaEnum.keyCount(); ++i) { + if (QString::compare(QString::fromUtf8(m_metaEnum.key(i)), key, Qt::CaseInsensitive) != 0) + continue; + + const auto raw = m_metaEnum.value(i); + QVariant decoded(m_type); + if (!QMetaType::convert(QMetaType::fromType(), &raw, m_type, decoded.data())) { + qCCritical(lcSettings, "Failed to convert value %d to enum %s", raw, m_type.name()); + return error(DiagnosticType::InvalidValue, + QStringLiteral("Could not convert %1 to %2").arg(key, QString::fromUtf8(m_type.name()))); + } + + return { .value = decoded, .error = std::nullopt, .indexPath = {} }; + } + + QStringList options; + options.reserve(m_metaEnum.keyCount()); + for (int i = 0; i < m_metaEnum.keyCount(); ++i) + options << QString::fromUtf8(m_metaEnum.key(i)); + + return error(DiagnosticType::InvalidValue, + QStringLiteral("Invalid enum value %1. Expected one of %2").arg(key, options.join(u", "_s))); +} + +template +ListCodec::ListCodec(const QMetaType& type, const ValueCodec* elementCodec) + : ValueCodec(type, ExpectedType::Array) + , m_elementCodec(elementCodec) {} + +template QJsonValue ListCodec::encode(const QVariant& value) const { + QJsonArray array; + const auto list = value.value(); + for (const auto& element : list) + array.append(m_elementCodec->encode(QVariant::fromValue(element))); + return array; +} + +template DecodeResult ListCodec::decode(const QJsonValue& value) const { + if (!value.isArray()) + return mismatch(ExpectedType::Array, value); + + const auto array = value.toArray(); + Container list; + list.reserve(array.size()); + + for (qsizetype i = 0; i < array.size(); ++i) { + auto result = m_elementCodec->decode(array.at(i)); + + // Reject the entire list if any element is invalid + if (result.error) { + result.indexPath.prepend(i); + return result; + } + + list.append(result.value.value()); + } + + return { .value = QVariant::fromValue(list), .error = std::nullopt, .indexPath = {} }; +} + +UnionCodec::UnionCodec(const QList& alternatives) + : ValueCodec(QMetaType::fromType(), alternatives.first()->expected()) + , m_alternatives(alternatives) + , m_expectedTypes(expectedTypesOf(alternatives)) { + m_byType.reserve(alternatives.size()); + + for (const auto* codec : alternatives) + m_byType.insert(codec->type().id(), codec); +} + +QJsonValue UnionCodec::encode(const QVariant& value) const { + // An unset union is simply absent from the file + if (!value.isValid()) + return QJsonValue::Undefined; + + const auto* codec = m_byType.value(value.metaType().id()); + if (!codec) { + qCWarning(lcSettings, "Cannot encode value of type %s, it is not one of the allowed types", value.typeName()); + return QJsonValue::Undefined; + } + + return codec->encode(value); +} + +DecodeResult UnionCodec::decode(const QJsonValue& value) const { + std::optional best; + + for (const auto* codec : m_alternatives) { + auto result = codec->decode(value); + + // The first alternative to accept the value wins + if (!result.error) + return result; + + // Non-nested type mismatch just means try another alternative + if (result.indexPath.isEmpty() && result.error->type == DiagnosticType::TypeMismatch) + continue; + + // Report the deepest error that isn't a type mismatch + if (!best || result.indexPath.size() > best->indexPath.size()) + best = std::move(result); + } + + if (best) + return *best; + + // Nothing matched the shape, so report every alternative + return mismatch(m_expectedTypes, value); +} + +// Instantiated for types as needed +template class ListCodec; +template class ListCodec>; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/codecs.hpp b/Plugins/ZShell/Settings/codecs.hpp new file mode 100644 index 0000000..2bbf2b2 --- /dev/null +++ b/Plugins/ZShell/Settings/codecs.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "common.hpp" + +namespace ZShell::settings { + +struct DecodeResult { + QVariant value; + std::optional error; + QList indexPath; // Index path of the failing element, outermost list first +}; + +class ValueCodec { +public: + explicit ValueCodec(const QMetaType& type, ExpectedType expected); + virtual ~ValueCodec() = default; + + // Returns the shared codec for a type, or nullptr if the type is unsupported + static ValueCodec* codecFor(const QMetaType& type); + + // Returns the shared codec for a union of types, or nullptr if any of them is unsupported + static ValueCodec* unionFor(const QList& types); + + [[nodiscard]] QMetaType type() const; + [[nodiscard]] ExpectedType expected() const; // The JSON type this decodes from + [[nodiscard]] virtual QJsonValue encode(const QVariant& value) const = 0; + [[nodiscard]] virtual DecodeResult decode(const QJsonValue& value) const = 0; + +protected: + const QMetaType m_type; + const ExpectedType m_expected; + + Q_DISABLE_COPY_MOVE(ValueCodec) +}; + +#define CODEC(Type, Expected) \ + class Type##Codec : public ValueCodec { \ + public: \ + explicit Type##Codec(const QMetaType& type) \ + : ValueCodec(type, ExpectedType::Expected) {} \ + \ + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; \ + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; \ + }; + +CODEC(Bool, Bool) +CODEC(Int, Int) +CODEC(Real, Real) +CODEC(String, String) +CODEC(VariantList, Array) +CODEC(VariantMap, Object) + +#undef CODEC + +class EnumCodec : public ValueCodec { +public: + explicit EnumCodec(const QMetaType& type, const QMetaEnum& metaEnum); + + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + +private: + const QMetaEnum m_metaEnum; +}; + +template class ListCodec : public ValueCodec { + using Value = Container::value_type; + +public: + explicit ListCodec(const QMetaType& type, const ValueCodec* elementCodec); + + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + +private: + const ValueCodec* m_elementCodec; +}; + +// Decodes any one of several types, for options that accept more than one shape +class UnionCodec : public ValueCodec { +public: + explicit UnionCodec(const QList& alternatives); + + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + +private: + const QList m_alternatives; // Tried in order, so the first to accept a value wins + const QList m_expectedTypes; // Types of the alternatives, for diagnostics + QHash m_byType; // Type id to alternative, for encoding +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/common.cpp b/Plugins/ZShell/Settings/common.cpp new file mode 100644 index 0000000..b7113fc --- /dev/null +++ b/Plugins/ZShell/Settings/common.cpp @@ -0,0 +1,131 @@ +#include "common.hpp" + +#include "node.hpp" + +using Qt::StringLiterals::operator""_s; + +namespace ZShell::settings { + +Q_LOGGING_CATEGORY(lcSettings, "zshell.settings", QtInfoMsg) + +WriteScope::WriteScope(Node* node, WriteOrigin origin) + : m_root(node->rootNode()), m_previous(m_root->m_writeOrigin) { + m_root->m_writeOrigin = origin; +} + +WriteScope::~WriteScope() { + m_root->m_writeOrigin = m_previous; +} + +InternalRead::InternalRead(Node* node) + : m_root(node->rootNode()), m_previous(m_root->m_internalRead) { + m_root->m_internalRead = true; +} + +InternalRead::~InternalRead() { + m_root->m_internalRead = m_previous; +} + +QString DiagnosticType::toString(Type t) { + switch (t) { + case UnknownOption: + return u"UnknownOption"_s; + case GlobalOption: + return u"GlobalOption"_s; + case TypeMismatch: + return u"TypeMismatch"_s; + case InvalidValue: + return u"InvalidValue"_s; + } + + Q_UNREACHABLE_RETURN(QString()); +} + +namespace { + +QString expectedStr(ExpectedType expected) { + switch (expected) { + case ExpectedType::Bool: + return QStringLiteral("a boolean"); + case ExpectedType::Int: + return QStringLiteral("an integer"); + case ExpectedType::Real: + return QStringLiteral("a number"); + case ExpectedType::String: + return QStringLiteral("a string"); + case ExpectedType::Array: + return QStringLiteral("an array"); + case ExpectedType::Object: + return QStringLiteral("an object"); + } + + Q_UNREACHABLE_RETURN(QString()); +} + +QString receivedStr(const QJsonValue& value) { + switch (value.type()) { + case QJsonValue::Null: + return QStringLiteral("null"); + case QJsonValue::Bool: + return QStringLiteral("a boolean"); + case QJsonValue::Double: + return QStringLiteral("a number"); + case QJsonValue::String: + return QStringLiteral("a string"); + case QJsonValue::Array: + return QStringLiteral("an array"); + case QJsonValue::Object: + return QStringLiteral("an object"); + default: + return QStringLiteral("nothing"); + } +} + +QString mismatchStr( + const QList& expected, const QJsonValue& value) { + QStringList types; + types.reserve(expected.size()); + for (const auto type : expected) + types << expectedStr(type); + const auto received = receivedStr(value); + + switch (expected.size()) { + case 2: + return QStringLiteral("Expected %1 or %2, got %3") + .arg(types[0], types[1], received); + case 3: + return QStringLiteral("Expected %1, %2 or %3, got %4") + .arg(types[0], types[1], types[2], received); + case 4: + return QStringLiteral("Expected one of: %1, %2, %3, %4; got %5") + .arg(types[0], types[1], types[2], types[3], received); + default: + // The bounds are checked in macros.hpp `unionType<...T>` + Q_UNREACHABLE_RETURN(QString()); + } +} + +} // namespace + +Diagnostic Diagnostic::mismatch( + ExpectedType expected, const QJsonValue& value, const QString& option) { + return { + .type = DiagnosticType::TypeMismatch, + .option = option, + .message = QStringLiteral("Expected %1, got %2") + .arg(expectedStr(expected), receivedStr(value)), + }; +} + +Diagnostic Diagnostic::mismatch( + const QList& expected, + const QJsonValue& value, + const QString& option) { + return { + .type = DiagnosticType::TypeMismatch, + .option = option, + .message = mismatchStr(expected, value), + }; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/common.hpp b/Plugins/ZShell/Settings/common.hpp new file mode 100644 index 0000000..829de23 --- /dev/null +++ b/Plugins/ZShell/Settings/common.hpp @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ZShell::settings { + +Q_DECLARE_LOGGING_CATEGORY(lcSettings) + +class Node; + +enum class WriteOrigin : quint8 { + Init, // On init + File, // From the JSON file + FileReset, // When option not present in file + Layer, // From the fallback layer + Qml, // From QML + QmlReset, // On option reset +}; + +class WriteScope { + public: + explicit WriteScope(Node* node, WriteOrigin origin); + ~WriteScope(); + + private: + Node* const m_root; + const WriteOrigin m_previous; + + Q_DISABLE_COPY_MOVE(WriteScope) +}; + +class InternalRead { + public: + explicit InternalRead(Node* node); + ~InternalRead(); + + private: + Node* const m_root; + const bool m_previous; + + Q_DISABLE_COPY_MOVE(InternalRead) +}; + +class DiagnosticType : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + public: + enum Type : quint8 { + UnknownOption = 0, + GlobalOption, + TypeMismatch, + InvalidValue, + }; + Q_ENUM(Type) + + Q_INVOKABLE static QString toString(Type t); +}; + +enum class ExpectedType : quint8 { + Bool, + Int, + Real, + String, + Array, + Object, +}; + +struct Diagnostic { + Q_GADGET + QML_VALUE_TYPE(diagnostic) + + Q_PROPERTY(DiagnosticType::Type type MEMBER type) + Q_PROPERTY(QString option MEMBER option) + Q_PROPERTY(QString message MEMBER message) + + public: + DiagnosticType::Type type = DiagnosticType::UnknownOption; + QString option; + QString message; + + static Diagnostic mismatch( + ExpectedType expected, + const QJsonValue& value, + const QString& option = {}); + static Diagnostic mismatch( + const QList& expected, + const QJsonValue& value, + const QString& option = {}); + + bool operator==(const Diagnostic& other) const = default; +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/layerregistry.hpp b/Plugins/ZShell/Settings/layerregistry.hpp new file mode 100644 index 0000000..a41d6b1 --- /dev/null +++ b/Plugins/ZShell/Settings/layerregistry.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include + +#include "node.hpp" + +namespace ZShell::settings { + +// Derived from Node and has ctor(path, fallback, parent) +template +concept LayerType = std::derived_from && std::constructible_from; + +template class LayerRegistry { +public: + explicit LayerRegistry(const QString& prefix, const QString& suffix, QObject* parent); + + [[nodiscard]] QString pathFor(const QString& name) const; + [[nodiscard]] QString nameFor(T* layer) const; + [[nodiscard]] T* get(const QString& name, T* fallback, bool* created = nullptr); // Created on demand + +private: + const QString m_prefix; + const QString m_suffix; + QObject* const m_parent; + QHash m_layers; +}; + +namespace detail { + +inline QString stripTrailingSlashes(QStringView str) { + while (str.endsWith(u'/')) + str = str.left(str.length() - 1); + return str.toString(); +} + +inline QString stripLeadingSlashes(QStringView str) { + while (str.startsWith(u'/')) + str = str.mid(1); + return str.toString(); +} + +} // namespace detail + +template +LayerRegistry::LayerRegistry(const QString& prefix, const QString& suffix, QObject* parent) + : m_prefix(detail::stripTrailingSlashes(prefix)) + , m_suffix(detail::stripLeadingSlashes(suffix)) + , m_parent(parent) {} + +template QString LayerRegistry::pathFor(const QString& name) const { + return m_prefix + u'/' + name + u'/' + m_suffix; +} + +template QString LayerRegistry::nameFor(T* layer) const { + return m_layers.key(layer); +} + +template T* LayerRegistry::get(const QString& name, T* fallback, bool* created) { + if (auto* const layer = m_layers.value(name)) { + if (created) + *created = false; + return layer; + } + + if (created) + *created = true; + + auto* const layer = new T(pathFor(name), fallback, m_parent); + m_layers.insert(name, layer); + return layer; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/listnode.cpp b/Plugins/ZShell/Settings/listnode.cpp new file mode 100644 index 0000000..f1cd4ea --- /dev/null +++ b/Plugins/ZShell/Settings/listnode.cpp @@ -0,0 +1,445 @@ +#include "listnode.hpp" + +#include +#include + + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +namespace { + +QString valuesKey() { + return u"values"_s; +} + +void deleteNode(Node* node) { + node->detachFallback(); + node->setParent(nullptr); + node->deleteLater(); +} + +bool isVariantListType(const QVariant& value) { + return value.typeId() == QMetaType::QVariantList || value.typeId() == qMetaTypeId>(); +} + +QList toMaps(const QVariant& value) { + if (value.typeId() == qMetaTypeId>()) + return value.value>(); + + const auto list = value.toList(); + QList maps; + maps.reserve(list.count()); + + for (const auto& el : list) { + if (el.typeId() != QMetaType::QVariantMap) { + qCCritical(lcSettings, "Expected a map in list properties, got %s.", el.typeName()); + continue; + } + + maps << el.toMap(); + } + + return maps; +} + +void syncNodeRecursive(Node* node, const QVariantMap& props) { + const auto& schema = node->schema(); + + for (const auto& [key, val] : props.asKeyValueRange()) { + const auto* desc = schema.get(key); + + if (!desc) { + qCCritical(lcSettings, "Unknown key %s in props for %s, something is wrong.", qUtf8Printable(key), + qUtf8Printable(node->path())); + continue; + } + + // `setValue` warns on type mismatches itself + if (!desc->isNode) { + node->setValue(key, val); + continue; + } + + auto* const child = node->value(key).value(); + + // Nested lists take their elements straight from props + if (auto* const list = qobject_cast(child)) { + if (!isVariantListType(val)) { + qCCritical(lcSettings, "Expected a list for node key %s on %s, got %s.", qUtf8Printable(key), + qUtf8Printable(node->path()), val.typeName()); + continue; + } + + list->setValue(valuesKey(), val); + continue; + } + + if (val.typeId() != QMetaType::QVariantMap) { + qCCritical(lcSettings, "Expected a map for node key %s on %s, got %s.", qUtf8Printable(key), + qUtf8Printable(node->path()), val.typeName()); + continue; + } + + syncNodeRecursive(child, val.toMap()); + } +} + +} // namespace + +ListNode::ListNode(ListNode* fallback, QObject* parent, bool globalOnly) + : Node(fallback, parent, globalOnly) { + if (fallback) { + // Disconnect generic fallback notify, lists use a custom one + QObject::disconnect(fallback, &ListNode::optionChanged, this, nullptr); + + if (!m_globalOnly) + QObject::connect(fallback, &ListNode::elementsChanged, this, &ListNode::onFallbackListNotify); + } +} + +qsizetype ListNode::count() const { + return m_elements.count(); +} + +QVariantList ListNode::values() const { + QVariantList list; + list.reserve(m_elements.count()); + for (auto* const element : m_elements) + list << QVariant::fromValue(element); + return list; +} + +void ListNode::remove(qsizetype index) { + if (rejectGlobalMutation()) + return; + + const WriteScope scope(this, WriteOrigin::Qml); + + if (!validIndex(index)) { + qCWarning(lcSettings, "Attempted to remove invalid index %lld from list node %s, ignoring.", index, + qUtf8Printable(path())); + + recordWrite(valuesKey(), false); + return; + } + + deleteNode(m_elements.takeAt(index)); + + recordWrite(valuesKey(), true); + emit countChanged(); + emit elementsChanged({}, { index }, {}); +} + +void ListNode::move(qsizetype from, qsizetype to) { + if (rejectGlobalMutation()) + return; + + const WriteScope scope(this, WriteOrigin::Qml); + + const auto fromInvalid = !validIndex(from); + if (fromInvalid || !validIndex(to)) { + if (fromInvalid) + qCWarning(lcSettings, "Attempted to move invalid index %lld to index %lld on list node %s, ignoring.", from, + to, qUtf8Printable(path())); + else + qCWarning(lcSettings, "Attempted to move index %lld to invalid index %lld on list node %s, ignoring.", from, + to, qUtf8Printable(path())); + + recordWrite(valuesKey(), false); + return; + } + + m_elements.move(from, to); + + recordWrite(valuesKey(), true); + emit elementsChanged({}, {}, { { from, to } }); +} + +void ListNode::clear() { + if (rejectGlobalMutation()) + return; + + const WriteScope scope(this, WriteOrigin::Qml); + + if (m_elements.isEmpty()) { + recordWrite(valuesKey(), false); + return; + } + + const auto len = m_elements.count(); + + for (auto* const element : std::as_const(m_elements)) + deleteNode(element); + m_elements.clear(); + + recordWrite(valuesKey(), true); + emit countChanged(); + + NodeChanges removed(len); + std::iota(removed.begin(), removed.end(), 0); + emit elementsChanged({}, removed, {}); +} + +QString ListNode::pathFor(const QString& key) const { + return elementPath(path(), key); +} + +const Schema& ListNode::schema() const { + // Offset +1 so count is not registered (allow read only cause values is read only) + static const auto k_schema = Schema::build(&staticMetaObject, Node::staticMetaObject.propertyCount() + 1, true); + return k_schema; +} + +QVariant ListNode::value(const QString& key) const { + if (key != valuesKey()) { + qCCritical(lcSettings, + "Attempted to read %s on list node %s. List nodes only have a 'values' key, something is wrong.", + qUtf8Printable(key), qUtf8Printable(path())); + return {}; + } + + return QVariant::fromValue(m_elements); +} + +bool ListNode::setValue(const QString& key, const QVariant& value) { + return setValue(key, value, nullptr); +} + +bool ListNode::setValue(const QString& key, const QVariant& value, QList* diagnostics) { + if (key != valuesKey()) { + qCCritical(lcSettings, + "Attempted to write %s on list node %s. List nodes only have a 'values' key, something is wrong.", + qUtf8Printable(key), qUtf8Printable(path())); + return false; + } + + const auto len = m_elements.count(); + + for (auto* const element : std::as_const(m_elements)) + deleteNode(element); + m_elements.clear(); + + if (value.typeId() == qMetaTypeId>()) { + // On reset to fallback + const auto nodes = value.value>(); + for (auto* const node : nodes) + insertNode(m_elements.count(), node); + } else if (isVariantListType(value)) { + // From defaults or QML + const auto list = toMaps(value); + for (qsizetype i = 0; i < list.count(); ++i) + insertNode(i, list.at(i), fallbackFor(i)); + } else if (value.typeId() == QMetaType::QJsonArray) { + // From syncJson + const auto array = value.toJsonArray(); + for (const auto& json : array) + insertNode(m_elements.count(), json.toObject(), *diagnostics); + } else { + qCCritical(lcSettings, "Unexpected type %s for list node %s, something is wrong.", value.typeName(), + qUtf8Printable(path())); + return false; + } + + if (!recordWrite(valuesKey(), true)) + return false; + + if (len != m_elements.count()) + emit countChanged(); + + NodeChanges added(m_elements.count()); + NodeChanges removed(len); + std::iota(added.begin(), added.end(), 0); + std::iota(removed.begin(), removed.end(), 0); + emit elementsChanged(added, removed, {}); + + return true; +} + +void ListNode::resetToDefaults() { + if (isNested()) { + qCCritical(lcSettings, "List node %s has a parent list node, resetToDefaults should never be called on it.", + qUtf8Printable(path())); + return; + } + + // Don't reset global only list nodes on overlays + if (fallbackNode() && m_globalOnly) + return; + + const WriteScope scope(this, WriteOrigin::FileReset); + setValue(valuesKey(), fallbackNode() ? fallbackNode()->value(valuesKey()) : QVariant::fromValue(defaultValue())); +} + +QJsonValue ListNode::toJson(bool sparse) const { + Q_UNUSED(sparse) // Lists can't be sparse + + QJsonArray array; + for (auto* const element : m_elements) + array << element->toJson(false); + return array; +} + +bool ListNode::syncJson(const QJsonValue& json, QList& diagnostics) { + if (!json.isArray()) { + const auto d = Diagnostic::mismatch(ExpectedType::Array, json, path()); + qCWarning(lcSettings, "Error decoding option %s: %s", qUtf8Printable(d.option), + qUtf8Printable(d.message)); + diagnostics << d; + return false; + } + + // Refuse syncs to global only list nodes on overlays + if (rejectGlobalSync(diagnostics)) + return false; + + const WriteScope scope(this, WriteOrigin::File); + setValue(valuesKey(), json.toArray(), &diagnostics); + + return true; +} + +bool ListNode::recordWrite(const QString& key, bool changed) { + const auto wasOverride = isOverride(key); + const auto notify = Node::recordWrite(key, changed); + + // Elements inside overridden lists have no fallbacks, so detach here + if (!wasOverride && isOverride(key)) + for (auto* const element : std::as_const(m_elements)) + element->detachFallback(); + + return notify; +} + +QString ListNode::keyOf(const Node* child) const { + return QString::number(m_elements.indexOf(const_cast(child))); +} + +Node* ListNode::elementAt(qsizetype index) const { + if (!validIndex(index)) + return nullptr; + return m_elements.at(index); +} + +Node* ListNode::insertElement(const QVariantMap& props, qsizetype index) { + if (rejectGlobalMutation()) + return nullptr; + + const WriteScope scope(this, WriteOrigin::Qml); + + if (!validIndex(index)) // Invalid index means append + index = m_elements.count(); + + auto* const element = insertNode(index, props, nullptr); + + recordWrite(valuesKey(), true); + emit countChanged(); + emit elementsChanged({ index }, {}, {}); + + return element; +} + +QList ListNode::defaultValue() const { + const auto* desc = getDescriptor(); + if (!desc) + return {}; + return desc->defaultValue().value>(); +} + +bool ListNode::isNested() const { + return qobject_cast(parentNode()); +} + +bool ListNode::rejectGlobalMutation() const { + if (!m_globalOnly || !fallbackNode()) + return false; + + qCWarning(lcSettings, + "Attempted to mutate global list %s from an overlay layer, ignoring. " + "This should not be used, mutate global lists from the global layer instead.", + qUtf8Printable(path())); + + return true; +} + +bool ListNode::validIndex(qsizetype index) const { + return index >= 0 && index < m_elements.count(); +} + +const Descriptor* ListNode::getDescriptor() const { + if (!parentNode()) { // List nodes should not be root nodes + qCCritical(lcSettings, "List node %s has no parent, something is wrong.", qUtf8Printable(path())); + return nullptr; + } + + const auto* desc = parentNode()->schema().get(key()); + + if (!desc) { + qCCritical(lcSettings, "List node %s is not in parent schema, something is wrong.", qUtf8Printable(path())); + return nullptr; + } + + return desc; +} + +Node* ListNode::fallbackFor(qsizetype index) const { + if (isOverride(valuesKey())) + return nullptr; + + const auto* fallback = static_cast(fallbackNode()); + if (!fallback || !fallback->validIndex(index)) + return nullptr; + + return fallback->m_elements.at(index); +} + +Node* ListNode::insertNode(qsizetype index, const QVariantMap& props, Node* fallback) { + auto* const node = createElement(fallback); + m_elements.insert(index, node); + + const WriteScope scope(node, WriteOrigin::Init); + syncNodeRecursive(node, props); + + return node; +} + +Node* ListNode::insertNode(qsizetype index, Node* node) { + auto* const copy = createElement(node); + m_elements.insert(index, copy); + return copy; +} + +Node* ListNode::insertNode(qsizetype index, const QJsonObject& json, QList& diagnostics) { + // Write origin will be file/file reset, which is correct. + // This function should only be called from the JSON path of setValue. + auto* const node = createElement(nullptr); // Nodes synced from JSON get no fallback as they are overrides + m_elements.insert(index, node); + + node->syncJson(json, diagnostics); + + return node; +} + +void ListNode::onFallbackListNotify(const NodeChanges& added, const NodeChanges& removed, const MoveChanges& moved) { + if (isOverride(valuesKey())) + return; + + const WriteScope scope(this, WriteOrigin::Layer); + + for (const auto index : removed | std::views::reverse) + deleteNode(m_elements.takeAt(index)); + for (const auto index : added) + insertNode(index, fallbackFor(index)); + for (const auto& move : moved) + m_elements.move(move.first, move.second); + + if (!recordWrite(valuesKey(), true)) + return; + + if (!added.isEmpty() || !removed.isEmpty()) + emit countChanged(); + emit elementsChanged(added, removed, moved); +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/listnode.hpp b/Plugins/ZShell/Settings/listnode.hpp new file mode 100644 index 0000000..e6b51dc --- /dev/null +++ b/Plugins/ZShell/Settings/listnode.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include "node.hpp" + +namespace ZShell::settings { + +class ListNode : public Node { + Q_OBJECT + QML_ANONYMOUS + + Q_PROPERTY(qsizetype count READ count NOTIFY countChanged) + Q_PROPERTY(QVariantList values READ values NOTIFY elementsChanged) + + using NodeChanges = QList; + using MoveChanges = QList>; + +public: + explicit ListNode(ListNode* fallback, QObject* parent = nullptr, bool globalOnly = false); + + [[nodiscard]] qsizetype count() const; + [[nodiscard]] QVariantList values() const; + + Q_INVOKABLE void remove(qsizetype index); + Q_INVOKABLE void move(qsizetype from, qsizetype to); + Q_INVOKABLE void clear(); + + [[nodiscard]] QString pathFor(const QString& key) const override; + [[nodiscard]] const Schema& schema() const override; + [[nodiscard]] QVariant value(const QString& key) const override; + bool setValue(const QString& key, const QVariant& value) override; + bool setValue(const QString& key, const QVariant& value, QList* diagnostics); + void resetToDefaults() override; + + [[nodiscard]] QJsonValue toJson(bool sparse = true) const override; + bool syncJson(const QJsonValue& json, QList& diagnostics) override; + +signals: + void countChanged(); + void elementsChanged(const NodeChanges& added, const NodeChanges& removed, const MoveChanges& moved); + +protected: + bool recordWrite(const QString& key, bool changed) override; + [[nodiscard]] QString keyOf(const Node* child) const override; + + [[nodiscard]] Node* elementAt(qsizetype index) const; + [[nodiscard]] Node* insertElement(const QVariantMap& props, qsizetype index = -1); + [[nodiscard]] virtual Node* createElement(Node* fallback) = 0; + +private: + QList m_elements; + + [[nodiscard]] QList defaultValue() const; + [[nodiscard]] bool isNested() const; + + // Returns true if the mutation should be skipped, overlays cannot mutate global lists + [[nodiscard]] bool rejectGlobalMutation() const; + + [[nodiscard]] bool validIndex(qsizetype index) const; + [[nodiscard]] const Descriptor* getDescriptor() const; + [[nodiscard]] Node* fallbackFor(qsizetype index) const; + + Node* insertNode(qsizetype index, const QVariantMap& props, Node* fallback); + Node* insertNode(qsizetype index, Node* node); + Node* insertNode(qsizetype index, const QJsonObject& json, QList& diagnostics); + + void onFallbackListNotify(const NodeChanges& added, const NodeChanges& removed, const MoveChanges& moved); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/macros.hpp b/Plugins/ZShell/Settings/macros.hpp new file mode 100644 index 0000000..6d065fe --- /dev/null +++ b/Plugins/ZShell/Settings/macros.hpp @@ -0,0 +1,193 @@ +#pragma once + +#include +#include +#include + +#include "listnode.hpp" // IWYU pragma: keep +#include "schema.hpp" // IWYU pragma: keep + +namespace ZShell::settings { + +inline QVariantMap vmap(std::initializer_list> entries) { + QVariantMap map; + for (const auto& [key, value] : entries) + map.insert(key, value); + return map; +} + +template inline QList unionTypes() { + static_assert(sizeof...(Ts) >= 2, "A union needs at least two types"); + // If the max size is changed, common.cpp `mismatchStr` must be updated + static_assert(sizeof...(Ts) <= 4, "A union cannot have more than 4 types"); + static_assert((!std::is_same_v && ...), "A union cannot contain QVariant"); + + return { QMetaType::fromType()... }; +} + +namespace detail { + +template inline bool compare(const T& a, const T& b) { + return a == b; +} + +template bool compare(const T& a, const T& b) { + return qFuzzyCompare(a + T(1), b + T(1)); +} + +template bool compare(const QList& a, const QList& b) { + if (a.size() != b.size()) + return false; + + for (qsizetype i = 0; i < a.size(); ++i) + if (!compare(a.at(i), b.at(i))) + return false; + return true; +} + +} // namespace detail + +} // namespace ZShell::settings + +// Helper macro to prevent splitting initialiser lists +#define DEFAULT_ARG(...) __VA_ARGS__ + +// Declares a class to be a node class. This replaces the Q_OBJECT call at the top of the class. +#define SETTINGS_NODE_NO_CTOR(Class, Base) \ + Q_OBJECT \ + \ +public: \ + [[nodiscard]] const ZShell::settings::Schema& schema() const override { \ + static const auto schema = \ + ZShell::settings::Schema::build(&staticMetaObject, Base::staticMetaObject.propertyCount()); \ + return schema; \ + } \ + \ +private: \ + using Self = Class; // For use in the below macros + +#define SETTINGS_NODE(Class, Base) \ + SETTINGS_NODE_NO_CTOR(Class, Base) \ + QML_ANONYMOUS \ + \ +public: \ + explicit Class(Class* fallback = nullptr, QObject* parent = nullptr, bool globalOnly = false) \ + : Base(fallback, parent, globalOnly) {} \ + \ +private: + +// Defines a property on a node. +#define SETTINGS_PROPERTY_IMPL(Type, name, global, defaultVal, ...) \ + Q_PROPERTY(Type name READ name WRITE set_##name NOTIFY name##Changed) \ + \ +public: \ + [[nodiscard]] Type name() const { \ + if (global || m_globalOnly) \ + warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ + void set_##name(const Type& value) { \ + if (!true /* TODO: validation */) \ + return; \ + \ + if (rejectInvalidWrite(QStringLiteral(#name), value)) \ + return; /* Skip writes of the wrong type */ \ + \ + if (rejectGlobalWrite(QStringLiteral(#name))) \ + return; /* Skip writes to global only keys, they should be sent to the global layer */ \ + \ + const auto needsNotify = !ZShell::settings::detail::compare(value, m_##name); \ + m_##name = value; \ + if (recordWrite(QStringLiteral(#name), needsNotify)) \ + Q_EMIT name##Changed(); \ + } \ + \ + Q_SIGNAL void name##Changed(); \ + \ +private: \ + Type m_##name = fallbackValue(&Self::m_##name, defaultVal); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate(&staticMetaObject, QStringLiteral(#name), \ + { .defaultValue = QVariant::fromValue(defaultVal), .globalOnly = global, __VA_ARGS__ }), \ + true); + +#define SETTINGS_PROPERTY(Type, name, defaultVal, ...) \ + SETTINGS_PROPERTY_IMPL(Type, name, false, DEFAULT_ARG(defaultVal), __VA_ARGS__) + +// Defines a global property on a node. Shorthand for .globalOnly = true. +#define SETTINGS_GLOBAL_PROPERTY(Type, name, defaultVal, ...) \ + SETTINGS_PROPERTY_IMPL(Type, name, true, DEFAULT_ARG(defaultVal), __VA_ARGS__) + +// Defines a subobject property on a node. Subobject properties are CONSTANT. +#define SETTINGS_SUBOBJECT_IMPL(Type, name, global) \ + Q_PROPERTY(Type* name READ name CONSTANT) \ + \ +public: \ + [[nodiscard]] Type* name() const { \ + if (global || m_globalOnly) \ + warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ +private: \ + Type* m_##name = new Type(fallbackValue(&Self::m_##name, nullptr), this, global); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate( \ + &staticMetaObject, QStringLiteral(#name), { .defaultValue = {}, .globalOnly = global }), \ + true); + +#define SETTINGS_SUBOBJECT(Type, name) SETTINGS_SUBOBJECT_IMPL(Type, name, false) + +// Defines a global subobject on a node. Everything inside it is global only. +#define SETTINGS_GLOBAL_SUBOBJECT(Type, name) SETTINGS_SUBOBJECT_IMPL(Type, name, true) + +// Defines a list type for use with SETTINGS_LIST. +#define SETTINGS_LIST_TYPE(Element, Name) \ + class Name : public ZShell::settings::ListNode { \ + Q_OBJECT \ + QML_ANONYMOUS \ + \ + public: \ + explicit Name(Name* fallback = nullptr, QObject* parent = nullptr, bool globalOnly = false) \ + : ZShell::settings::ListNode(fallback, parent, globalOnly) {} \ + \ + [[nodiscard]] Q_INVOKABLE Element* at(qsizetype index) const { /* Format ugh */ \ + return static_cast(elementAt(index)); \ + } \ + [[nodiscard]] Q_INVOKABLE Element* insert(const QVariantMap& props, qsizetype index = -1) { \ + return static_cast(insertElement(props, index)); \ + } \ + \ + protected: \ + [[nodiscard]] ZShell::settings::Node* createElement(ZShell::settings::Node* fallback) override { \ + return new Element(static_cast(fallback), this); \ + } \ + }; + +// Defines a list property on a node. List properties are CONSTANT. +#define SETTINGS_LIST_IMPL(Type, name, global, defaultVal, ...) \ + Q_PROPERTY(Type* name READ name CONSTANT) \ + \ +public: \ + [[nodiscard]] Type* name() const { \ + if (global || m_globalOnly) \ + warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ +private: \ + Type* m_##name = new Type(fallbackValue(&Self::m_##name, nullptr), this, global); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate(&staticMetaObject, QStringLiteral(#name), \ + { .defaultValue = QVariant::fromValue>(defaultVal), \ + .globalOnly = global, \ + __VA_ARGS__ }), \ + true); + +#define SETTINGS_LIST(Type, name, defaultVal, ...) \ + SETTINGS_LIST_IMPL(Type, name, false, DEFAULT_ARG(defaultVal), __VA_ARGS__) + +// Defines a global list on a node. Everything inside it is global only. +#define SETTINGS_GLOBAL_LIST(Type, name, defaultVal, ...) \ + SETTINGS_LIST_IMPL(Type, name, true, DEFAULT_ARG(defaultVal), __VA_ARGS__) diff --git a/Plugins/ZShell/Settings/node.cpp b/Plugins/ZShell/Settings/node.cpp new file mode 100644 index 0000000..9bacd19 --- /dev/null +++ b/Plugins/ZShell/Settings/node.cpp @@ -0,0 +1,280 @@ +#include "node.hpp" + + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +Node::Node(Node* fallback, QObject* parent, bool globalOnly) + : QObject(parent) + , m_globalOnly(globalOnly || (parentNode() && parentNode()->m_globalOnly)) + , m_rootNode(parentNode() ? parentNode()->rootNode() : this) + , m_fallbackNode(fallback) + , m_writeOrigin(WriteOrigin::Qml) + , m_internalRead(false) + , m_batcher(m_rootNode == this ? new ChangeBatcher(this) : nullptr) { + if (fallback && !m_globalOnly) + QObject::connect(fallback, &Node::optionChanged, this, &Node::onFallbackNotify); +} + +QString Node::key() const { + return parentNode() ? parentNode()->keyOf(this) : QString(); +} + +QString Node::path() const { + return parentNode() ? parentNode()->pathFor(key()) : key(); +} + +QString Node::pathFor(const QString& key) const { + const auto p = path(); + return p.isEmpty() ? key : p + u'.' + key; +} + +QString Node::elementPath(const QString& path, const QString& index) { + return path + u'[' + index + u']'; +} + +Node* Node::parentNode() const { + return qobject_cast(parent()); +} + +Node* Node::rootNode() const { + return m_rootNode; +} + +Node* Node::fallbackNode() const { + return m_fallbackNode; +} + +void Node::detachFallback() { + if (m_fallbackNode) { + QObject::disconnect(m_fallbackNode, nullptr, this, nullptr); + m_fallbackNode = nullptr; + } + + const auto childNodes = findChildren(Qt::FindDirectChildrenOnly); + for (auto* const child : childNodes) + child->detachFallback(); +} + +bool Node::isGlobalOnly() const { + return m_globalOnly; +} + +bool Node::isOverride(const QString& key) const { + return m_overrides.contains(key); +} + +const QSet& Node::overrides() const { + return m_overrides; +} + +bool Node::hasContent() const { + return !m_overrides.isEmpty() || m_quarantine || + std::ranges::any_of(findChildren(Qt::FindDirectChildrenOnly), &Node::hasContent); +} + +QVariant Node::value(const QString& key) const { + const auto* desc = schema().get(key); + if (!desc) { + qCCritical( + lcSettings, "Attempted to read an unknown key %s, something is wrong.", qUtf8Printable(pathFor(key))); + return {}; + } + + // Generated getters warn on global reads, this silences them as the warning is only for QML reads + const InternalRead guard(m_rootNode); + return metaObject()->property(desc->metaIndex).read(this); +} + +bool Node::setValue(const QString& key, const QVariant& value) { + const auto* desc = schema().get(key); + if (!desc) { + qCCritical(lcSettings, "Attempted to set an unknown key %s, something is wrong.", qUtf8Printable(pathFor(key))); + return false; + } + + if (desc->isNode) { + qCCritical(lcSettings, "Attempted to set node %s directly, something is wrong.", qUtf8Printable(pathFor(key))); + return false; + } + + // Type mismatch, conversion should happen before this function is called + if (rejectInvalidWrite(key, value)) + return false; + + return metaObject()->property(desc->metaIndex).write(this, value); +} + +void Node::resetToDefaults() { + // Don't reset global only nodes on overlays, the whole subtree belongs to the global layer + if (m_globalOnly && m_fallbackNode) + return; + + m_quarantine.reset(); // Reset quarantine as well + + // No write scope, callers should create the scope + for (const auto& desc : schema().descriptors()) { + if (desc.isNode) + value(desc.key).value()->resetToDefaults(); + else if (!desc.globalOnly() || !m_fallbackNode) // Skip resetting global options on overlays + setValue(desc.key, m_fallbackNode ? m_fallbackNode->value(desc.key) : desc.defaultValue()); + } +} + +const Quarantine* Node::quarantine() const { + return m_quarantine.get(); +} + +bool Node::rejectInvalidWrite(const QString& key, const QVariant& value) const { + const auto* desc = schema().get(key); + if (!desc || desc->accepts(value.metaType())) + return false; + + qCWarning(lcSettings, "Type mismatch for %s, expected %s got %s", qUtf8Printable(pathFor(key)), + qUtf8Printable(desc->typeString()), value.metaType().name()); + return true; +} + +void Node::warnGlobalRead(const QString& key) const { + if (!m_fallbackNode || m_rootNode->m_internalRead) + return; + + qCWarning(lcSettings, + "Global option %s was read from an overlay layer. " + "This should not be used, read global options from the global layer instead.", + qUtf8Printable(pathFor(key))); +} + +bool Node::rejectGlobalWrite(const QString& key) { + const auto* desc = schema().get(key); + if (!desc) { + qCCritical(lcSettings, "Attempted to check a write for an unknown key %s, something is seriously wrong...", + qUtf8Printable(pathFor(key))); + return false; + } + + const auto origin = m_rootNode->m_writeOrigin; + const auto fromUser = origin == WriteOrigin::Qml || origin == WriteOrigin::QmlReset; + + if ((!m_globalOnly && !desc->globalOnly()) || !fromUser || !m_fallbackNode) + return false; + + if (origin == WriteOrigin::QmlReset) + qCWarning(lcSettings, + "Attempted to reset global option %s from an overlay layer, ignoring. " + "This should not be used, reset global options from the global layer instead.", + qUtf8Printable(pathFor(key))); + else + qCWarning(lcSettings, + "Attempted to write global option %s from an overlay layer, ignoring. " + "This should not be used, write global options from the global layer instead.", + qUtf8Printable(pathFor(key))); + + return true; +} + +void Node::warnGlobalSync(QList& diagnostics, const QString& path) { + qCWarning(lcSettings, "Global option definition %s found in overlay file, ignoring.", qUtf8Printable(path)); + diagnostics << Diagnostic{ + .type = DiagnosticType::GlobalOption, + .option = path, + .message = QStringLiteral("Global options should not be defined in overlay files"), + }; +} + +bool Node::rejectGlobalSync(QList& diagnostics) const { + if (!m_globalOnly || !m_fallbackNode) + return false; + + warnGlobalSync(diagnostics, path()); + return true; +} + +bool Node::recordWrite(const QString& key, bool changed) { + const auto* desc = schema().get(key); + if (!desc) { + qCCritical(lcSettings, "Attempted to record a write for an unknown key %s, something is seriously wrong...", + qUtf8Printable(pathFor(key))); + return false; + } + + const auto origin = m_rootNode->m_writeOrigin; + const auto fromUser = origin == WriteOrigin::Qml || origin == WriteOrigin::QmlReset; + + bool dirty = changed; + switch (origin) { + // Init does not notify or write to file + case WriteOrigin::Init: + return false; + + // File and qml both count as overrides + case WriteOrigin::File: + case WriteOrigin::Qml: + dirty |= !m_overrides.contains(key); + m_overrides << key; + break; + + // Layer is not an override, it is a sync with the fallback value + case WriteOrigin::Layer: + break; + + // Both resets clear the override + case WriteOrigin::FileReset: + case WriteOrigin::QmlReset: + dirty |= m_overrides.remove(key); + break; + } + + // User writes/reset override quarantine + if (fromUser) + dirty |= removeQuarantined(key); + + // Both qml and reset write to the file (only write if dirty) + if (fromUser && dirty) + m_rootNode->m_batcher->dirty(); + + if (changed) + emit optionChanged(key); + + return changed; +} + +bool Node::removeQuarantined(const QString& key) { + if (!m_quarantine) + return false; + + const auto removed = m_quarantine->remove(key); + if (m_quarantine->isEmpty()) + m_quarantine.reset(); + + return removed; +} + +ChangeBatcher* Node::batcher() const { + return m_rootNode->m_batcher; +} + +QString Node::keyOf(const Node* child) const { + for (const auto& desc : schema().descriptors()) { + if (desc.isNode && child == value(desc.key).value()) + return desc.key; + } + + return {}; +} + +void Node::onFallbackNotify(const QString& key) { + if (m_overrides.contains(key)) + return; + + // Don't mirror global options onto overlays + const auto* desc = schema().get(key); + if (desc && desc->globalOnly()) + return; + + const WriteScope scope(this, WriteOrigin::Layer); + setValue(key, m_fallbackNode->value(key)); +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/node.hpp b/Plugins/ZShell/Settings/node.hpp new file mode 100644 index 0000000..23237a8 --- /dev/null +++ b/Plugins/ZShell/Settings/node.hpp @@ -0,0 +1,100 @@ +#pragma once + +#include +#include +#include + +#include "changebatcher.hpp" +#include "common.hpp" +#include "quarantine.hpp" +#include "schema.hpp" + +namespace ZShell::settings { + +class Node : public QObject { + Q_OBJECT + +public: + // Global only nodes are inherited, anything inside one is also global only + explicit Node(Node* fallback, QObject* parent = nullptr, bool globalOnly = false); + + [[nodiscard]] QString key() const; // The key of this in the parent node + [[nodiscard]] QString path() const; + [[nodiscard]] virtual QString pathFor(const QString& key) const; + [[nodiscard]] static QString elementPath(const QString& path, const QString& index); + [[nodiscard]] Node* parentNode() const; + [[nodiscard]] Node* rootNode() const; + [[nodiscard]] Node* fallbackNode() const; + void detachFallback(); // Recursive, unlinks this and its children from the fallback layer + + [[nodiscard]] Q_INVOKABLE bool isGlobalOnly() const; + [[nodiscard]] bool isOverride(const QString& key) const; + [[nodiscard]] const QSet& overrides() const; + [[nodiscard]] bool hasContent() const; // Recursive + + [[nodiscard]] virtual const Schema& schema() const = 0; + + [[nodiscard]] virtual QVariant value(const QString& key) const; + virtual bool setValue(const QString& key, const QVariant& value); // Returns whether the write was successful or not + virtual void resetToDefaults(); // Recursive, resets to fallbacks then defaults if not overridden + + [[nodiscard]] virtual QJsonValue toJson(bool sparse = true) const = 0; + // Returns false if the entire node was rejected + virtual bool syncJson(const QJsonValue& json, QList& diagnostics) = 0; + [[nodiscard]] const Quarantine* quarantine() const; + +signals: + void optionChanged(const QString& key); + +protected: + // Null means empty, otherwise it has content + std::unique_ptr m_quarantine; + const bool m_globalOnly; // Own flag or inherited from the parent node + + void warnGlobalRead(const QString& key) const; + // Returns true if the write should be skipped afterwards, the value is not one of the allowed types + [[nodiscard]] bool rejectInvalidWrite(const QString& key, const QVariant& value) const; + template [[nodiscard]] bool rejectInvalidWrite(const QString& key, const T& value) const; + // Returns true if the write should be skipped afterwards, overlays cannot write global options + bool rejectGlobalWrite(const QString& key); + static void warnGlobalSync(QList& diagnostics, const QString& path); + bool rejectGlobalSync(QList& diagnostics) const; // Returns true if the sync should be rejected + // Returns true if the notify signal should be emitted + virtual bool recordWrite(const QString& key, bool changed); + + [[nodiscard]] bool removeQuarantined(const QString& key); + [[nodiscard]] ChangeBatcher* batcher() const; + + [[nodiscard]] virtual QString keyOf(const Node* child) const; + + template + [[nodiscard]] T fallbackValue(T C::* member, std::type_identity_t defaultValue) const; + +private: + QSet m_overrides; // Overridden keys from file/qml writes + Node* const m_rootNode; + Node* m_fallbackNode; // No fallback node either means global tree or inside overridden list + + // For root node use only + WriteOrigin m_writeOrigin; + bool m_internalRead; + ChangeBatcher* const m_batcher; + + void onFallbackNotify(const QString& key); + + friend class WriteScope; + friend class InternalRead; +}; + +template bool Node::rejectInvalidWrite(const QString& key, const T& value) const { + Q_UNUSED(key) + Q_UNUSED(value) + return false; // Only QVariant unions can be given the wrong type +} + +template T Node::fallbackValue(T C::* member, std::type_identity_t defaultValue) const { + const auto* fallback = static_cast(m_fallbackNode); + return fallback ? fallback->*member : defaultValue; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/objectnode.cpp b/Plugins/ZShell/Settings/objectnode.cpp new file mode 100644 index 0000000..5453989 --- /dev/null +++ b/Plugins/ZShell/Settings/objectnode.cpp @@ -0,0 +1,219 @@ +#include "objectnode.hpp" + +#include + +#include "codecs.hpp" + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +ObjectNode::ObjectNode(ObjectNode* fallback, QObject* parent, bool globalOnly) + : Node(fallback, parent, globalOnly) {} + +void ObjectNode::resetOption(const QString& key) { + const auto* desc = schema().get(key); + + if (!desc) { + qCWarning(lcSettings) + << "Attempted to reset unknown option" << pathFor(key); + return; + } + + // Warn and ignore if this is a global node property and we are an overlay + if (desc->isNode && (m_globalOnly || desc->globalOnly()) && + fallbackNode()) { + qCWarning( + lcSettings, + "Attempted to reset global node %s, ignoring. " + "This should not be used, reset global nodes from the global layer " + "instead.", + qUtf8Printable(pathFor(key))); + return; + } + + const WriteScope scope(this, WriteOrigin::QmlReset); + if (desc->isNode) + value(key).value()->resetToDefaults(); + else + setValue( + key, + fallbackNode() ? fallbackNode()->value(key) : desc->defaultValue()); +} + +Descriptor ObjectNode::descriptorFor(const QString& key) const { + const auto* desc = schema().get(key); + if (!desc) { + qCWarning(lcSettings) + << "Attempted to get descriptor for unknown option" << pathFor(key); + return {}; + } + + return *desc; +} + +QJsonValue ObjectNode::toJson(bool sparse) const { + QJsonObject json; + + for (const auto& desc : schema().descriptors()) { + const auto val = value(desc.key); + + if (const auto* node = val.value()) { + if (!sparse || node->hasContent()) + json.insert(desc.key, node->toJson(sparse)); + continue; + } + + if (sparse && !isOverride(desc.key)) continue; + + if (!desc.codec) { // This should not happen + qCCritical( + lcSettings, + "No codec found for type %s, not serialising %s", + desc.type.name(), + qUtf8Printable(pathFor(desc.key))); + continue; + } + + json.insert(desc.key, desc.codec->encode(val)); + } + + if (m_quarantine) return m_quarantine->apply(json); + + return json; +} + +bool ObjectNode::syncJson( + const QJsonValue& json, QList& diagnostics) { + m_quarantine.reset(); // Clear out old quarantine + + if (!json.isObject()) { + const auto d = Diagnostic::mismatch(ExpectedType::Object, json, path()); + qCWarning( + lcSettings, + "Error decoding option %s: %s", + qUtf8Printable(d.option), + qUtf8Printable(d.message)); + diagnostics << d; + return false; + } + + // Refuse syncs to global only nodes on overlays + if (rejectGlobalSync(diagnostics)) return false; + + const auto obj = json.toObject(); + + qCDebug(lcSettings) << "Loading JSON into" << metaObject()->className() + << "with" << obj.size() << "keys:" << obj.keys(); + + const auto visited = loadFromJson(obj, diagnostics); + resetUnvisited(visited); + + return true; +} + +void ObjectNode::quarantineKey(const QString& key, const QJsonValue& value) { + if (!m_quarantine) m_quarantine = std::make_unique(); + m_quarantine->insert(key, value); +} + +QSet ObjectNode::loadFromJson( + const QJsonObject& json, QList& diagnostics) { + const WriteScope scope(this, WriteOrigin::File); + + QSet visited; + visited.reserve(json.size()); + + // Convenience macro for quarantine then skip +#define SKIP \ + quarantineKey(key, v); \ + continue + + for (const auto [k, v] : json.asKeyValueRange()) { + const auto key = k.toString(); + const auto* desc = schema().get(key); + + if (!desc) { + const auto path = pathFor(key); + qCWarning(lcSettings) << "Unknown option" << path; + diagnostics << Diagnostic{ + .type = DiagnosticType::UnknownOption, + .option = path, + .message = QStringLiteral("Unknown option %1").arg(key), + }; + SKIP; + } + + // Recurse into child nodes + if (desc->isNode) { + qCDebug(lcSettings) << " Recursing into" << key; + auto* const node = value(key).value(); + if (node->syncJson(v, diagnostics)) + visited << key; + else + quarantineKey( + key, v); // Quarantine entire node cause sync failed + continue; + } + + if ((m_globalOnly || desc->globalOnly()) && fallbackNode()) { + warnGlobalSync(diagnostics, pathFor(key)); + SKIP; + } + + if (!desc->codec) { // This should not happen + qCCritical( + lcSettings, + "No codec found for type %s, not loading %s", + desc->type.name(), + qUtf8Printable(pathFor(key))); + SKIP; + } + + auto val = desc->codec->decode(v); + if (val.error) { + auto path = pathFor(key); + for (const auto index : std::as_const(val.indexPath)) + path = elementPath(path, QString::number(index)); + qCWarning( + lcSettings, + "Error decoding option %s: %s", + qUtf8Printable(path), + qUtf8Printable(val.error->message)); + val.error->option = path; + diagnostics << *val.error; + SKIP; + } + + visited << key; + setValue(key, val.value); + } + +#undef SKIP + + return visited; +} + +void ObjectNode::resetUnvisited(const QSet& visited) { + const WriteScope scope(this, WriteOrigin::FileReset); + + for (const auto& desc : schema().descriptors()) { + if (visited.contains(desc.key)) continue; + + // Reset nodes recursively + if (desc.isNode) { + value(desc.key).value()->resetToDefaults(); + continue; + } + + // Skip global options on overlays + if ((m_globalOnly || desc.globalOnly()) && fallbackNode()) continue; + + setValue( + desc.key, + fallbackNode() ? fallbackNode()->value(desc.key) + : desc.defaultValue()); + } +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/objectnode.hpp b/Plugins/ZShell/Settings/objectnode.hpp new file mode 100644 index 0000000..2aa24fd --- /dev/null +++ b/Plugins/ZShell/Settings/objectnode.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +#include "node.hpp" + +namespace ZShell::settings { + +class ObjectNode : public Node { + Q_OBJECT + + public: + explicit ObjectNode( + ObjectNode* fallback, + QObject* parent = nullptr, + bool globalOnly = false); + + Q_INVOKABLE void resetOption(const QString& key); + [[nodiscard]] Q_INVOKABLE Descriptor descriptorFor(const QString& key) const; + + [[nodiscard]] QJsonValue toJson(bool sparse = true) const override; + bool syncJson( + const QJsonValue& json, QList& diagnostics) override; + + private: + void quarantineKey(const QString& key, const QJsonValue& value); + + QSet loadFromJson( + const QJsonObject& json, QList& diagnostics); + void resetUnvisited(const QSet& visited); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/quarantine.cpp b/Plugins/ZShell/Settings/quarantine.cpp new file mode 100644 index 0000000..d573c6d --- /dev/null +++ b/Plugins/ZShell/Settings/quarantine.cpp @@ -0,0 +1,27 @@ +#include "quarantine.hpp" + +#include + +namespace ZShell::settings { + +void ObjectQuarantine::insert(const QString& key, const QJsonValue& value) { + m_quarantine.insert(key, value); +} + +bool ObjectQuarantine::remove(const QString& key) { + return m_quarantine.remove(key); +} + +bool ObjectQuarantine::isEmpty() const { + return m_quarantine.isEmpty(); +} + +QJsonValue ObjectQuarantine::apply(const QJsonValue& json) const { + auto result = json.toObject(); + for (const auto& [key, value] : m_quarantine.asKeyValueRange()) + if (!result.contains(key)) // Don't clobber existing values + result.insert(key, value); + return result; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/quarantine.hpp b/Plugins/ZShell/Settings/quarantine.hpp new file mode 100644 index 0000000..979995d --- /dev/null +++ b/Plugins/ZShell/Settings/quarantine.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +namespace ZShell::settings { + +class Quarantine { +public: + explicit Quarantine() = default; + virtual ~Quarantine() = default; + + virtual void insert(const QString& key, const QJsonValue& value) = 0; + [[nodiscard]] virtual bool remove(const QString& key) = 0; + [[nodiscard]] virtual bool isEmpty() const = 0; + [[nodiscard]] virtual QJsonValue apply(const QJsonValue& json) const = 0; + + Q_DISABLE_COPY_MOVE(Quarantine) +}; + +class ObjectQuarantine : public Quarantine { +public: + void insert(const QString& key, const QJsonValue& value) override; + [[nodiscard]] bool remove(const QString& key) override; + [[nodiscard]] bool isEmpty() const override; + [[nodiscard]] QJsonValue apply(const QJsonValue& json) const override; + +private: + QHash m_quarantine; +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/rootnode.cpp b/Plugins/ZShell/Settings/rootnode.cpp new file mode 100644 index 0000000..b9d8868 --- /dev/null +++ b/Plugins/ZShell/Settings/rootnode.cpp @@ -0,0 +1,46 @@ +#include "rootnode.hpp" + +#include + +namespace ZShell::settings { + +RootNode::RootNode(const QString& path, RootNode* fallback, QObject* parent) + : ObjectNode(fallback, parent) + , m_file(new SettingsFile(path, this)) { + QObject::connect(batcher(), &ChangeBatcher::dirtied, this, &RootNode::saveToFile); + QObject::connect(m_file, &SettingsFile::changed, this, &RootNode::reloadFromFile); + QObject::connect(m_file, &SettingsFile::readFailed, this, [this](const QString& error) { + emit treeLoadFailed(this, error); + }); + QObject::connect(m_file, &SettingsFile::writeFailed, this, [this](const QString& error) { + emit treeSaveFailed(this, error); + }); +} + +void RootNode::load() { + if (m_file->load() != SettingsFile::LoadResult::Changed) + reloadFromFile(); +} + +QList RootNode::diagnostics() const { + return m_diagnostics; +} + +void RootNode::reloadFromFile() { + const auto oldDiagnostics = m_diagnostics; + m_diagnostics.clear(); + + const auto data = m_file->read(); + syncJson(data ? data.value() : QJsonObject(), m_diagnostics); + + if (m_diagnostics != oldDiagnostics) + emit diagnosticsChanged(); + + emit treeLoaded(this); +} + +void RootNode::saveToFile() { + m_file->write(toJson()); +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/rootnode.hpp b/Plugins/ZShell/Settings/rootnode.hpp new file mode 100644 index 0000000..71a5ead --- /dev/null +++ b/Plugins/ZShell/Settings/rootnode.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "common.hpp" +#include "objectnode.hpp" +#include "settingsfile.hpp" + +namespace ZShell::settings { + +class RootNode : public ObjectNode { + Q_OBJECT + + Q_PROPERTY(QList diagnostics READ diagnostics NOTIFY diagnosticsChanged) + +public: + explicit RootNode(const QString& path, RootNode* fallback, QObject* parent = nullptr); + + [[nodiscard]] QList diagnostics() const; + + void load(); + +signals: + void diagnosticsChanged(); + void treeLoaded(RootNode* self); + void treeLoadFailed(RootNode* self, const QString& error); + void treeSaveFailed(RootNode* self, const QString& error); + +private: + SettingsFile* const m_file; + QList m_diagnostics; + + void reloadFromFile(); + void saveToFile(); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/schema.cpp b/Plugins/ZShell/Settings/schema.cpp new file mode 100644 index 0000000..370a695 --- /dev/null +++ b/Plugins/ZShell/Settings/schema.cpp @@ -0,0 +1,133 @@ +#include "schema.hpp" + +#include "codecs.hpp" +#include "node.hpp" + +namespace { + +Q_LOGGING_CATEGORY(lcSchema, "zshell.settings.schema", QtInfoMsg) + +} // namespace + +namespace ZShell::settings { + +namespace { + +// Descriptor annotations are stored here by Schema::annotate, which is called in the CONFIG_XXX macros. +// Schema::build then takes them from here. +QHash>& annotationCache() { + static QHash> s_cache; + return s_cache; +} + +// Union options carry their alternatives in the annotation, everything else goes by property type +const ValueCodec* resolveCodec(Descriptor& desc) { + auto& allowed = desc.annotation.allowedTypes; + + if (allowed.isEmpty()) + return ValueCodec::codecFor(desc.type); + + if (desc.type.id() != QMetaType::QVariant) { + qCCritical(lcSchema, "Allowed types are only valid for QVariant properties, ignoring them for %s", + qUtf8Printable(desc.key)); + allowed.clear(); + return ValueCodec::codecFor(desc.type); + } + + if (const auto* codec = ValueCodec::unionFor(allowed)) + return codec; + + allowed.clear(); + return nullptr; +} + +bool isNodeType(const QMetaType& type) { + if (!type.flags().testFlag(QMetaType::PointerToQObject)) + return false; + const auto* meta = type.metaObject(); + return meta && meta->inherits(&Node::staticMetaObject); +} + +} // namespace + +QString Descriptor::typeString() const { + if (annotation.allowedTypes.isEmpty()) + return QString::fromUtf8(type.name()); + + QStringList names; + names.reserve(annotation.allowedTypes.size()); + for (const auto& allowed : annotation.allowedTypes) + names << QString::fromUtf8(allowed.name()); + + return names.join(QStringLiteral(" | ")); +} + +bool Descriptor::accepts(const QMetaType& valueType) const { + // Unset is a valid state for QVariant options, they are simply absent from the file + if (type.id() == QMetaType::QVariant && !valueType.isValid()) + return true; + + if (annotation.allowedTypes.isEmpty()) + return type == valueType; + + return annotation.allowedTypes.contains(valueType); +} + +Schema Schema::build(const QMetaObject* meta, int baseOffset, bool includeReadOnly) { + Schema schema; + schema.m_descriptors.reserve(meta->propertyCount() - baseOffset); + + // Descriptors are taken from cache since this should be called exactly once per class + const auto annotations = annotationCache().take(meta); + + qCDebug(lcSchema) << "Building schema for" << meta->className(); + + for (int i = baseOffset; i < meta->propertyCount(); ++i) { + const auto prop = meta->property(i); + const auto key = QString::fromUtf8(prop.name()); + const auto isNode = isNodeType(prop.metaType()); + + // Skip read only properties (unless includeReadOnly) + if (!isNode && !includeReadOnly && !prop.isWritable()) { + qCDebug(lcSchema) << " Skipping computed property" << key; + continue; + } + + qCDebug(lcSchema) << " Adding property" << key; + + Descriptor desc{ + .key = key, + .type = prop.metaType(), + .metaIndex = i, + .isNode = isNode, + .annotation = annotations.value(key), + }; + + if (!isNode) { + desc.codec = resolveCodec(desc); + if (!desc.codec) + qCCritical(lcSchema, "No codec for %s of type %s, it will not be loaded or saved", qUtf8Printable(key), + desc.type.name()); + } + + schema.m_descriptors.append(std::move(desc)); + schema.m_keyToIndex.insert(key, schema.m_descriptors.size() - 1); + } + + return schema; +} + +void Schema::annotate(const QMetaObject* meta, const QString& key, Annotation annotation) { + annotationCache()[meta].insert(key, std::move(annotation)); +} + +const QList& Schema::descriptors() const { + return m_descriptors; +} + +const Descriptor* Schema::get(const QString& key) const { + const auto it = m_keyToIndex.find(key); + return it != m_keyToIndex.end() ? &m_descriptors[it.value()] : nullptr; +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/schema.hpp b/Plugins/ZShell/Settings/schema.hpp new file mode 100644 index 0000000..2857530 --- /dev/null +++ b/Plugins/ZShell/Settings/schema.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::settings { + +class ValueCodec; + +struct Annotation { + QVariant defaultValue; + bool globalOnly = false; + // NOLINTNEXTLINE(readability-redundant-member-init) callers will get missing field init warnings without + QList allowedTypes = {}; // For QVariant properties +}; + +namespace detail { + +// Return trivially copyable types (e.g. bool) by value and others (e.g. QVariant) by const ref +template +using AnnotationReturn = std::conditional_t && sizeof(T) <= sizeof(void*), T, const T&>; + +} // namespace detail + +#define ANNOTATION(Type, name) \ + Q_PROPERTY(Type name READ name) \ + \ +public: \ + [[nodiscard]] detail::AnnotationReturn name() const { \ + return annotation.name; \ + } \ + \ +private: + +struct Descriptor { + Q_GADGET + QML_VALUE_TYPE(descriptor) + + Q_PROPERTY(QString key MEMBER key) + Q_PROPERTY(QString type READ typeString) + Q_PROPERTY(int metaIndex MEMBER metaIndex) + Q_PROPERTY(bool isNode MEMBER isNode) + + ANNOTATION(QVariant, defaultValue) + ANNOTATION(bool, globalOnly) + +public: + QString key; + QMetaType type; + int metaIndex; + bool isNode; + Annotation annotation; + const ValueCodec* codec = nullptr; // Null for nodes and unsupported types + + [[nodiscard]] QString typeString() const; + [[nodiscard]] bool accepts(const QMetaType& valueType) const; +}; + +#undef ANNOTATION + +class Schema { +public: + static Schema build(const QMetaObject* meta, int baseOffset, bool includeReadOnly = false); + static void annotate(const QMetaObject* meta, const QString& key, Annotation annotation); + + [[nodiscard]] const QList& descriptors() const; + [[nodiscard]] const Descriptor* get(const QString& key) const; + +private: + explicit Schema() = default; + + QList m_descriptors; + QHash m_keyToIndex; +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/settingsfile.cpp b/Plugins/ZShell/Settings/settingsfile.cpp new file mode 100644 index 0000000..abd4883 --- /dev/null +++ b/Plugins/ZShell/Settings/settingsfile.cpp @@ -0,0 +1,213 @@ +#include "settingsfile.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + + +namespace { + +Q_LOGGING_CATEGORY(lcSettingsFile, "zshell.settings.file", QtInfoMsg) + +} // namespace + +namespace ZShell::settings { + +using Qt::StringLiterals::operator""_s; + +namespace { + +// Max retries for loads which fail due to malformed JSON, e.g. partial writes +constexpr int k_maxLoadRetries = 3; + +} // namespace + +SettingsFile::SettingsFile(QString path, QObject* parent) + : QObject(parent) + , m_path(std::move(path)) + , m_watcher(new QFileSystemWatcher(this)) + , m_saveDebounce(new QTimer(this)) + , m_loadDebounce(new QTimer(this)) + , m_loadRetries(0) { + m_saveDebounce->setSingleShot(true); + m_saveDebounce->setInterval(500); // Save at most once every 500ms + + m_loadDebounce->setSingleShot(true); + m_loadDebounce->setInterval(50); // Coalesce watcher events within 50ms + QObject::connect(m_loadDebounce, &QTimer::timeout, this, &SettingsFile::onLoadDebounced); + + QObject::connect(m_watcher, &QFileSystemWatcher::fileChanged, this, &SettingsFile::onFileChanged); + QObject::connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, &SettingsFile::onDirChanged); + + initWatcher(); +} + +std::optional SettingsFile::read() const { + return m_lastData; +} + +void SettingsFile::write(const QJsonValue& json) { + m_pendingWrite = json; + save(); +} + +void SettingsFile::onFileChanged() { + initWatcher(); // Re-add path in case the file was replaced + scheduleLoad(); +} + +void SettingsFile::onDirChanged() { + // Ignore if the file is already watched + if (m_watcher->files().contains(m_path)) + return; + + initWatcher(); + + if (QFile::exists(m_path)) + scheduleLoad(); +} + +void SettingsFile::initWatcher() { + const QFileInfo info(m_path); + const auto dir = info.absolutePath(); + + if (QDir(dir).exists() && !m_watcher->directories().contains(dir)) + m_watcher->addPath(dir); + + if (info.exists() && !m_watcher->files().contains(m_path)) + m_watcher->addPath(m_path); +} + +void SettingsFile::scheduleLoad() { + // Ignore if a load is already pending + if (m_loadDebounce->isActive()) + return; + + m_loadRetries = 0; + m_loadDebounce->start(); +} + +void SettingsFile::onLoadDebounced() { + const auto isFinalTry = m_loadRetries >= k_maxLoadRetries; + + if (load(isFinalTry) == LoadResult::ParseError && !isFinalTry) { + // Likely a partial write, retry after another debounce + ++m_loadRetries; + qCDebug(lcSettingsFile, "Retrying load of %s (%d/%d)", qUtf8Printable(m_path), m_loadRetries, k_maxLoadRetries); + m_loadDebounce->start(); + } +} + +SettingsFile::LoadResult SettingsFile::load(bool reportErrors) { + QFile file(m_path); + + if (!file.exists()) { + if (m_lastData) { + m_lastData = std::nullopt; + emit changed(); + return LoadResult::Changed; + } + return LoadResult::Unchanged; + } + + if (!file.open(QIODevice::ReadOnly)) { + qCWarning(lcSettingsFile, "Failed to open %s for reading: %s", qUtf8Printable(m_path), + qUtf8Printable(file.errorString())); + emit readFailed(QStringLiteral("Failed to open: %1").arg(file.errorString())); + return LoadResult::Error; + } + + const auto data = file.readAll(); + file.close(); + + QJsonParseError error; + const auto doc = QJsonDocument::fromJson(data, &error); + + if (error.error != QJsonParseError::NoError) { + if (reportErrors) { + qCWarning(lcSettingsFile, "Failed to parse %s as JSON: %s", qUtf8Printable(m_path), + qUtf8Printable(error.errorString())); + emit readFailed(QStringLiteral("Failed to parse: %1").arg(error.errorString())); + } else { + qCDebug(lcSettingsFile, "Failed to parse %s as JSON: %s", qUtf8Printable(m_path), + qUtf8Printable(error.errorString())); + } + return LoadResult::ParseError; + } + + const auto json = doc.isObject() ? QJsonValue(doc.object()) : QJsonValue(doc.array()); + + if (json == m_lastData) + return LoadResult::Unchanged; + + // Clear pending write, stuff loaded from file should take precedence + m_pendingWrite = std::nullopt; + + m_lastData = json; + emit changed(); + + qCDebug(lcSettingsFile) << "Read JSON from" << m_path; + qCDebug(lcSettingsFile) << " " << json; + + return LoadResult::Changed; +} + +void SettingsFile::save() { + if (!m_pendingWrite) + return; + + if (m_saveDebounce->isActive()) { + // Queue save for debounce end + QObject::connect(m_saveDebounce, &QTimer::timeout, this, &SettingsFile::save, + // NOLINTNEXTLINE(clang-analyzer-optin.core.EnumCastOutOfRange) ConnectionType is OR-able by design + static_cast(Qt::UniqueConnection | Qt::SingleShotConnection)); + return; + } + + // Debounce regardless of whether the save actually succeeded + m_saveDebounce->start(); + + const auto json = m_pendingWrite.value(); + const auto data = (json.isObject() ? QJsonDocument(json.toObject()) : QJsonDocument(json.toArray())).toJson(); + m_pendingWrite = std::nullopt; + + const auto dir = QFileInfo(m_path).absolutePath(); + + if (!QDir().mkpath(dir)) { + qCWarning(lcSettingsFile) << "Failed to create dir" << dir; + emit writeFailed(QStringLiteral("Failed to create parent directory")); + return; + } + + // Write atomically + QSaveFile file(m_path); + + if (!file.open(QIODevice::WriteOnly)) { + qCWarning(lcSettingsFile, "Failed to open %s for writing: %s", qUtf8Printable(m_path), + qUtf8Printable(file.errorString())); + emit writeFailed(QStringLiteral("Failed to open: %1").arg(file.errorString())); + return; + } + + file.write(data); + + if (!file.commit()) { + qCWarning(lcSettingsFile, "Failed to write %s: %s", qUtf8Printable(m_path), qUtf8Printable(file.errorString())); + emit writeFailed(QStringLiteral("Failed to write: %1").arg(file.errorString())); + return; + } + + m_lastData = json; + qCDebug(lcSettingsFile) << "Saved" << m_path; + + initWatcher(); // The file may have just been created, or replaced by the rename +} + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/settingsfile.hpp b/Plugins/ZShell/Settings/settingsfile.hpp new file mode 100644 index 0000000..d517d33 --- /dev/null +++ b/Plugins/ZShell/Settings/settingsfile.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include + +namespace ZShell::settings { + +class SettingsFile : public QObject { + Q_OBJECT + +public: + enum class LoadResult : quint8 { + Unchanged, + Changed, + ParseError, + Error + }; + + explicit SettingsFile(QString path, QObject* parent = nullptr); + + [[nodiscard]] std::optional read() const; + void write(const QJsonValue& json); + + LoadResult load(bool reportErrors = true); + +signals: + void changed(); // Data changed, not file watcher event + void readFailed(const QString& error); + void writeFailed(const QString& error); + +private: + QString m_path; + QFileSystemWatcher* m_watcher; + QTimer* m_saveDebounce; + QTimer* m_loadDebounce; + int m_loadRetries; + std::optional m_lastData; + std::optional m_pendingWrite; + + void onFileChanged(); + void onDirChanged(); + + void initWatcher(); + void scheduleLoad(); + void onLoadDebounced(); + void save(); +}; + +} // namespace ZShell::settings diff --git a/Plugins/ZShell/toaster.cpp b/Plugins/ZShell/toaster.cpp index a8aa59f..9e5e7a8 100644 --- a/Plugins/ZShell/toaster.cpp +++ b/Plugins/ZShell/toaster.cpp @@ -102,6 +102,11 @@ void Toast::unlock(QObject* sender) { Toaster::Toaster(QObject* parent) : QObject(parent) {} +Toaster* Toaster::instance() { + static Toaster toaster; + return &toaster; +} + QQmlListProperty Toaster::toasts() { return QQmlListProperty(this, &m_toasts); } diff --git a/Plugins/ZShell/toaster.hpp b/Plugins/ZShell/toaster.hpp index c2e546a..667ffab 100644 --- a/Plugins/ZShell/toaster.hpp +++ b/Plugins/ZShell/toaster.hpp @@ -67,6 +67,7 @@ class Toaster : public QObject { public: explicit Toaster(QObject* parent = nullptr); + static Toaster* instance(); [[nodiscard]] QQmlListProperty toasts(); From 3373f11193198c07ee733f332f2c6abf95ad62b4 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 27 Sep 2026 22:47:31 +0200 Subject: [PATCH 17/90] fix: column index desync after reorder --- Components/StateLayer.qml | 1 - Modules/Dashboard/Content.qml | 53 +-- Modules/Dashboard/Dash.qml | 2 +- Modules/Dashboard/Dash/User.qml | 8 +- .../Dashboard/Processes/ProcessesColumns.qml | 15 +- .../Dashboard/Processes/ProcessesHeader.qml | 153 +++------ .../Dashboard/Processes}/ResourceUsage.qml | 0 Modules/Dashboard/Processes/SystemInfo.qml | 1 - Modules/Dashboard/ProcessesTab.qml | 13 - Modules/Resources/Cards/NetworkCard.qml | 16 +- .../Settings/Pages/Panels/DashboardPanel.qml | 40 +++ Plugins/ZShell/CMakeLists.txt | 1 + Plugins/ZShell/Config/anim.cpp | 4 - Plugins/ZShell/Config/anim.hpp | 2 +- Plugins/ZShell/Config/appearance.cpp | 10 - Plugins/ZShell/Config/appearance.hpp | 1 - Plugins/ZShell/Config/dashboard.hpp | 23 +- Plugins/ZShell/Config/tokens.hpp | 13 + Plugins/ZShell/Services/CMakeLists.txt | 6 + Plugins/ZShell/Services/networkusage.cpp | 153 +++++++++ Plugins/ZShell/Services/networkusage.hpp | 66 ++++ Plugins/ZShell/Services/processes.cpp | 318 ++++++++++++++---- Plugins/ZShell/Services/processes.hpp | 29 ++ Plugins/ZShell/Services/tickingservice.cpp | 64 +--- Plugins/ZShell/Services/tickingservice.hpp | 3 +- Plugins/ZShell/Services/usagefmt.cpp | 16 + Plugins/ZShell/Services/usagefmt.hpp | 1 + Plugins/ZShell/Settings/macros.hpp | 311 +++++++++-------- 28 files changed, 859 insertions(+), 464 deletions(-) rename {Helpers => Modules/Dashboard/Processes}/ResourceUsage.qml (100%) create mode 100644 Plugins/ZShell/Services/networkusage.cpp create mode 100644 Plugins/ZShell/Services/networkusage.hpp diff --git a/Components/StateLayer.qml b/Components/StateLayer.qml index 8857eb0..aeda8ed 100644 --- a/Components/StateLayer.qml +++ b/Components/StateLayer.qml @@ -3,7 +3,6 @@ import QtQuick.Shapes import ZShell import ZShell.Config import qs.Services -import qs.Services MouseArea { id: root diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index d34b536..b4173e7 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -12,7 +12,7 @@ Item { id: root readonly property real nonAnimWidth: view.implicitWidth + clipWrapper.anchors.margins * 2 - readonly property real nonAnimHeight: tabs.implicitHeight + clipWrapper.anchors.topMargin + view.implicitHeight + clipWrapper.anchors.margins * 2 + readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + clipWrapper.anchors.margins + clipWrapper.anchors.topMargin required property ScreenState screenState @@ -22,25 +22,25 @@ Item { component: dashComponent, iconName: "dashboard", text: qsTr("Dashboard"), - enabled: true + enabled: Config.dashboard.showDashboard }, { component: weatherComponent, iconName: "cloud", text: qsTr("Weather"), - enabled: true + enabled: Config.dashboard.showWeather }, { component: mediaComponent, iconName: "queue_music", text: qsTr("Media"), - enabled: true + enabled: Config.dashboard.showMedia }, { component: procsComponent, iconName: "speed", text: qsTr("Processes"), - enabled: true + enabled: Config.dashboard.showProcesses }, ]; return allTabs.filter(tab => tab.enabled); @@ -60,33 +60,34 @@ Item { } } - Item { - id: clipWrapper - anchors.fill: parent - anchors.margins: Tokens.padding.small - // radius: Tokens.rounding.extraLarge - anchors.margins + Tabs { + id: tabs + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + nonAnimWidth: view.implicitWidth + screenState: root.screenState + tabs: root.dashboardTabs anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins) + anchors.margins: Tokens.padding.small + } - Tabs { - id: tabs - - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - - nonAnimWidth: view.implicitWidth - screenState: root.screenState - tabs: root.dashboardTabs - } + CustomClippingRect { + id: clipWrapper + anchors.top: tabs.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.topMargin: Tokens.spacing.small + anchors.margins: Tokens.padding.small + radius: Tokens.rounding.extraLarge - anchors.margins Item { id: viewWrapper - anchors.top: tabs.bottom - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.topMargin: Tokens.spacing.small + anchors.fill: parent Flickable { id: view diff --git a/Modules/Dashboard/Dash.qml b/Modules/Dashboard/Dash.qml index 54d6e51..31a78b6 100644 --- a/Modules/Dashboard/Dash.qml +++ b/Modules/Dashboard/Dash.qml @@ -67,7 +67,7 @@ GridLayout { Rect { Layout.columnSpan: 2 Layout.fillHeight: true - Layout.preferredWidth: Config.dashboard.sizes.weatherWidth + Layout.preferredWidth: Tokens.sizes.dashboard.weatherWidth Layout.row: 0 radius: root.radius diff --git a/Modules/Dashboard/Dash/User.qml b/Modules/Dashboard/Dash/User.qml index 264ae5f..34da84c 100644 --- a/Modules/Dashboard/Dash/User.qml +++ b/Modules/Dashboard/Dash/User.qml @@ -51,7 +51,7 @@ Row { id: icon anchors.left: parent.left - anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2 + anchors.leftMargin: (Tokens.sizes.dashboard.infoIconSize - implicitWidth) / 2 color: Colors.palette.m3primary implicitSize: Math.floor(13 * 1.34) source: SystemInfo.osLogo @@ -65,7 +65,7 @@ Row { anchors.verticalCenter: icon.verticalCenter elide: Text.ElideRight text: `: ${SystemInfo.osPrettyName || SystemInfo.osName}` - width: Config.dashboard.sizes.infoWidth + width: Tokens.sizes.dashboard.infoWidth } } @@ -98,7 +98,7 @@ Row { id: icon anchors.left: parent.left - anchors.leftMargin: (Config.dashboard.sizes.infoIconSize - implicitWidth) / 2 + anchors.leftMargin: (Tokens.sizes.dashboard.infoIconSize - implicitWidth) / 2 color: line.colour fill: 1 fontStyle: Tokens.font.icon.small @@ -113,7 +113,7 @@ Row { anchors.verticalCenter: icon.verticalCenter elide: Text.ElideNone text: `: ${line.text}` - width: Config.dashboard.sizes.infoWidth + width: Tokens.sizes.dashboard.infoWidth } } } diff --git a/Modules/Dashboard/Processes/ProcessesColumns.qml b/Modules/Dashboard/Processes/ProcessesColumns.qml index 40b9694..0dec3d4 100644 --- a/Modules/Dashboard/Processes/ProcessesColumns.qml +++ b/Modules/Dashboard/Processes/ProcessesColumns.qml @@ -3,6 +3,7 @@ pragma Singleton import QtQuick import Quickshell import ZShell.Config +import ZShell.Services Singleton { id: root @@ -88,6 +89,18 @@ Singleton { } ] + readonly property var desktopEntries: DesktopEntries.applications.values.map(e => ({ + icon: e.icon, + command: e.command + })) + + Component.onCompleted: { + Processes.setDesktopEntries(root.desktopEntries); + Qt.callLater(ensurePinnedFirst); + } + + onDesktopEntriesChanged: Processes.setDesktopEntries(root.desktopEntries) + readonly property string pinnedColumnId: "command" readonly property var hiddenColumns: { @@ -113,8 +126,6 @@ Singleton { }); } - Component.onCompleted: Qt.callLater(ensurePinnedFirst) - function defFor(id: string): var { for (const def of root.columnDefs) if (def.id === id) diff --git a/Modules/Dashboard/Processes/ProcessesHeader.qml b/Modules/Dashboard/Processes/ProcessesHeader.qml index 83225dd..307d4da 100644 --- a/Modules/Dashboard/Processes/ProcessesHeader.qml +++ b/Modules/Dashboard/Processes/ProcessesHeader.qml @@ -64,109 +64,83 @@ Item { function onWidthChanged() { root.fillMissingWidths(); } + + function onCountChanged() { + resizeOverlay.geometryTick++; + root.fillMissingWidths(); + } + + function onContentWidthChanged() { + resizeOverlay.geometryTick++; + } } Connections { target: columnsData function onValuesChanged() { root.fillMissingWidths(); + resizeOverlay.geometryTick++; } } Component.onCompleted: Qt.callLater(fillMissingWidths) Component.onDestruction: Cursor.clearOverride() - RowLayout { - id: rowLayout + CustomListView { + id: columnList anchors.fill: parent - + orientation: ListView.Horizontal + interactive: false spacing: Tokens.spacing.extraSmall / 2 - CustomListView { - id: columnList - - Layout.fillWidth: true - orientation: ListView.Horizontal - interactive: false - spacing: parent.spacing - implicitHeight: root.headerHeight - - add: Transition { - Anim { - from: 0 - properties: "opacity" - to: 1 - type: Anim.DefaultEffects - } + add: Transition { + Anim { + from: 0 + properties: "opacity" + to: 1 + type: Anim.DefaultEffects } - displaced: Transition { - Anim { - properties: "opacity" - to: 1 - type: Anim.DefaultEffects - } - Anim { - properties: "x" - } + } + displaced: Transition { + Anim { + properties: "opacity" + to: 1 + type: Anim.DefaultEffects } - move: Transition { - Anim { - properties: "opacity" - to: 1 - type: Anim.DefaultEffects - } - Anim { - properties: "x" - } + Anim { + properties: "x" } - remove: Transition { - Anim { - to: 0 - properties: "opacity" - type: Anim.DefaultEffects - } + } + move: Transition { + Anim { + properties: "opacity" + to: 1 + type: Anim.DefaultEffects } - - model: DelegateModel { - id: columnsVisual - - delegate: ColumnHeaderCell { - parentModel: columnsVisual - } - - model: ScriptModel { - id: columnsData - values: Config.dashboard.processColumns.values - } + Anim { + properties: "x" + } + } + remove: Transition { + Anim { + to: 0 + properties: "opacity" + type: Anim.DefaultEffects } } - IconButton { - id: addColumnBtn + model: DelegateModel { + id: columnsVisual - type: IconButton.Text - icon: "add" - font: Tokens.font.icon.medium - Layout.alignment: Qt.AlignVCenter - visible: ProcessesColumns.hiddenColumns.length > 0 - onClicked: addMenu.expanded = true - } + delegate: ColumnHeaderCell { + parentModel: columnsVisual + } - Variants { - id: hiddenColumnList - - model: ProcessesColumns.hiddenColumns - - HiddenColumnItem {} - } - - Menu { - id: addMenu - - attachTo: addColumnBtn - items: hiddenColumnList.instances - onItemSelected: item => ProcessesColumns.addColumn(item.modelData.id) + model: ScriptModel { + id: columnsData + values: Config.dashboard.processColumns.values + } } } @@ -178,26 +152,9 @@ Item { property int geometryTick: 0 property int activeGapIndex: -1 - anchors.fill: rowLayout + anchors.fill: columnList z: 10 - Connections { - target: columnsData - function onValuesChanged() { - resizeOverlay.geometryTick++; - } - } - - Connections { - target: columnList - function onCountChanged() { - resizeOverlay.geometryTick++; - } - function onContentWidthChanged() { - resizeOverlay.geometryTick++; - } - } - Component.onCompleted: Qt.callLater(() => geometryTick++) Repeater { @@ -571,8 +528,6 @@ Item { const rightMid = rightItem.x + rightItem.width / 2; if (refX > rightMid) { columnsVisual.items.move(myIndex, myIndex + 1); - resizeOverlay.geometryTick++; - Qt.callLater(() => resizeOverlay.geometryTick++); return; } } @@ -583,8 +538,6 @@ Item { const pinnedAtZero = columnList.itemAtIndex(0)?.modelData?.id === ProcessesColumns.pinnedColumnId; if (refX < leftMid && !(myIndex === 1 && pinnedAtZero)) { columnsVisual.items.move(myIndex, myIndex - 1); - resizeOverlay.geometryTick++; - Qt.callLater(() => resizeOverlay.geometryTick++); } } } diff --git a/Helpers/ResourceUsage.qml b/Modules/Dashboard/Processes/ResourceUsage.qml similarity index 100% rename from Helpers/ResourceUsage.qml rename to Modules/Dashboard/Processes/ResourceUsage.qml diff --git a/Modules/Dashboard/Processes/SystemInfo.qml b/Modules/Dashboard/Processes/SystemInfo.qml index 3346b56..086e90b 100644 --- a/Modules/Dashboard/Processes/SystemInfo.qml +++ b/Modules/Dashboard/Processes/SystemInfo.qml @@ -7,7 +7,6 @@ import ZShell.Components import ZShell.Services import qs.Components import qs.Services -import qs.Helpers Item { id: root diff --git a/Modules/Dashboard/ProcessesTab.qml b/Modules/Dashboard/ProcessesTab.qml index 76bc140..35687fc 100644 --- a/Modules/Dashboard/ProcessesTab.qml +++ b/Modules/Dashboard/ProcessesTab.qml @@ -9,11 +9,6 @@ import qs.Services Item { id: root - readonly property var desktopEntries: DesktopEntries.applications.values.map(e => ({ - icon: e.icon, - command: e.command - })) - implicitWidth: Tokens.sizes.dashboard.processesWidth implicitHeight: Tokens.sizes.dashboard.processesHeight @@ -21,14 +16,6 @@ Item { service: Processes } - Connections { - target: DesktopEntries.applications - function onValuesChanged(): void { - Processes.setDesktopEntries(root.desktopEntries); - } - } - Component.onCompleted: Processes.setDesktopEntries(root.desktopEntries) - ProcessesHeader { id: header diff --git a/Modules/Resources/Cards/NetworkCard.qml b/Modules/Resources/Cards/NetworkCard.qml index 2184b53..445ccf6 100644 --- a/Modules/Resources/Cards/NetworkCard.qml +++ b/Modules/Resources/Cards/NetworkCard.qml @@ -1,8 +1,8 @@ import QtQuick import QtQuick.Layouts import ZShell.Components +import ZShell.Services import ZShell.Config -import qs.Helpers import qs.Components import qs.Services @@ -14,7 +14,7 @@ CustomRect { implicitWidth: 300 radius: Tokens.rounding.largeIncreased - Ref { + ServiceRef { service: NetworkUsage } @@ -129,8 +129,8 @@ CustomRect { CustomText { color: Colors.palette.m3tertiary text: { - const fmt = NetworkUsage.formatBytes(NetworkUsage.downloadSpeed ?? 0); - return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; + const fmt = UsageFmt.formatBytes(NetworkUsage.downloadSpeed ?? 0); + return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}/s` : "0.0 B/s"; } } } @@ -157,8 +157,8 @@ CustomRect { CustomText { color: Colors.palette.m3secondary text: { - const fmt = NetworkUsage.formatBytes(NetworkUsage.uploadSpeed ?? 0); - return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; + const fmt = UsageFmt.formatBytes(NetworkUsage.uploadSpeed ?? 0); + return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}/s` : "0.0 B/s"; } } } @@ -185,8 +185,8 @@ CustomRect { CustomText { color: Colors.palette.m3onSurfaceVariant text: { - const down = NetworkUsage.formatBytesTotal(NetworkUsage.downloadTotal ?? 0); - const up = NetworkUsage.formatBytesTotal(NetworkUsage.uploadTotal ?? 0); + const down = UsageFmt.formatBytes(NetworkUsage.downloadTotal ?? 0); + const up = UsageFmt.formatBytes(NetworkUsage.uploadTotal ?? 0); return (down && up) ? `↓${down.value.toFixed(1)}${down.unit} ↑${up.value.toFixed(1)}${up.unit}` : "↓0.0B ↑0.0B"; } } diff --git a/Modules/Settings/Pages/Panels/DashboardPanel.qml b/Modules/Settings/Pages/Panels/DashboardPanel.qml index af8beb8..12d7d3e 100644 --- a/Modules/Settings/Pages/Panels/DashboardPanel.qml +++ b/Modules/Settings/Pages/Panels/DashboardPanel.qml @@ -34,6 +34,45 @@ PageBase { onToggled: Config.dashboard.enabled = checked } + // Tabs + SectionHeader { + text: qsTr("Tabs") + } + + ToggleRow { + first: true + checked: Config.dashboard.showDashboard + settingAnchor: "dashboard-show-dashboard" + text: qsTr("Dashboard") + + onToggled: Config.dashboard.showDashboard = checked + } + + ToggleRow { + checked: Config.dashboard.showWeather + settingAnchor: "dashboard-show-weather" + text: qsTr("Weather") + + onToggled: Config.dashboard.showWeather = checked + } + + ToggleRow { + checked: Config.dashboard.showMedia + settingAnchor: "dashboard-show-media" + text: qsTr("Media") + + onToggled: Config.dashboard.showMedia = checked + } + + ToggleRow { + checked: Config.dashboard.showProcesses + settingAnchor: "dashboard-show-processes" + text: qsTr("Processes") + last: true + + onToggled: Config.dashboard.showProcesses = checked + } + // Behavior SectionHeader { text: qsTr("Behavior") @@ -58,6 +97,7 @@ PageBase { text: qsTr("Drag threshold") to: 100 value: Config.dashboard.dragThreshold + last: true onMoved: v => Config.dashboard.dragThreshold = v } diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index 0a6d627..9822b65 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -52,6 +52,7 @@ set_source_files_properties("${SETTINGS_INDEX_JSON}" PROPERTIES QT_RESOURCE_ALIA qml_module(ZShell URI ZShell + INCLUDE_PREFIX core SOURCES writefile.hpp writefile.cpp appdb.hpp appdb.cpp diff --git a/Plugins/ZShell/Config/anim.cpp b/Plugins/ZShell/Config/anim.cpp index f5b97ff..0f89423 100644 --- a/Plugins/ZShell/Config/anim.cpp +++ b/Plugins/ZShell/Config/anim.cpp @@ -64,9 +64,6 @@ AnimDurations* AnimTokens::durations() const { QEasingCurve AnimTokens::buildCurve(const QList& points) { QEasingCurve curve(QEasingCurve::BezierSpline); - // Points come in pairs of (x, y) forming cubic bezier segments. - // Each segment needs 3 control points: c1, c2, endPoint. - // So 6 values per segment: c1x, c1y, c2x, c2y, endX, endY. for (int i = 0; i + 5 < points.size(); i += 6) { const QPointF c1(points[i], points[i + 1]); const QPointF c2(points[i + 2], points[i + 3]); @@ -101,7 +98,6 @@ void AnimTokens::rebuildCurves() { void AnimTokens::bindCurves(AnimCurves* curves) { m_curves = curves; - // Rebuild when any curve control points change connect( curves, &AnimCurves::optionChanged, this, &AnimTokens::rebuildCurves); diff --git a/Plugins/ZShell/Config/anim.hpp b/Plugins/ZShell/Config/anim.hpp index 2a76c9d..8ff9694 100644 --- a/Plugins/ZShell/Config/anim.hpp +++ b/Plugins/ZShell/Config/anim.hpp @@ -11,7 +11,7 @@ class AnimDurations; class AnimTokens : public QObject { Q_OBJECT - Q_MOC_INCLUDE("tokens.hpp") // AnimCurves + Q_MOC_INCLUDE("tokens.hpp") Q_MOC_INCLUDE("appearance.hpp") QML_ANONYMOUS diff --git a/Plugins/ZShell/Config/appearance.cpp b/Plugins/ZShell/Config/appearance.cpp index 1d125ec..3ba878d 100644 --- a/Plugins/ZShell/Config/appearance.cpp +++ b/Plugins/ZShell/Config/appearance.cpp @@ -6,8 +6,6 @@ namespace ZShell::config { namespace { -// Helper: connect all changed signals from a token object to a single valuesChanged signal, -// plus connect the local scaleChanged signal. template void connectTokenSignals(Source* source, Target* target) { const auto* meta = source->metaObject(); @@ -30,8 +28,6 @@ void connectTokenSignals(Source* source, Target* target) { } // namespace -// AppearanceRounding - void AppearanceRounding::bindTokens(RoundingTokens* tokens) { m_tokens = tokens; connectTokenSignals(tokens, this); @@ -77,8 +73,6 @@ int AppearanceRounding::full() const { return m_tokens ? m_tokens->full() : 0; } -// AppearanceSpacing - void AppearanceSpacing::bindTokens(SpacingTokens* tokens) { m_tokens = tokens; connectTokenSignals(tokens, this); @@ -120,8 +114,6 @@ int AppearanceSpacing::extraExtraLarge() const { : 0; } -// AppearancePadding - void AppearancePadding::bindTokens(PaddingTokens* tokens) { m_tokens = tokens; connectTokenSignals(tokens, this); @@ -167,8 +159,6 @@ int AppearancePadding::extraExtraLarge() const { : 0; } -// AnimDurations - void AnimDurations::bindTokens(AnimDurationTokens* tokens) { m_tokens = tokens; connectTokenSignals(tokens, this); diff --git a/Plugins/ZShell/Config/appearance.hpp b/Plugins/ZShell/Config/appearance.hpp index 4166253..b774969 100644 --- a/Plugins/ZShell/Config/appearance.hpp +++ b/Plugins/ZShell/Config/appearance.hpp @@ -11,7 +11,6 @@ namespace ZShell::config { using Qt::StringLiterals::operator""_s; -// Forward declare token types from tokens.hpp class RoundingTokens; class SpacingTokens; class PaddingTokens; diff --git a/Plugins/ZShell/Config/dashboard.hpp b/Plugins/ZShell/Config/dashboard.hpp index 3160a48..e9fd3d0 100644 --- a/Plugins/ZShell/Config/dashboard.hpp +++ b/Plugins/ZShell/Config/dashboard.hpp @@ -17,24 +17,6 @@ class Performance : public settings::ObjectNode { CONFIG_PROPERTY(bool, showVram, true) }; -class DashboardSizes : public settings::ObjectNode { - CONFIG_NODE(DashboardSizes, settings::ObjectNode) - - CONFIG_PROPERTY(int, dateTimeWidth, 110) - CONFIG_PROPERTY(int, infoIconSize, 25) - CONFIG_PROPERTY(int, infoWidth, 200) - CONFIG_PROPERTY(int, mediaCoverArtSize, 150) - CONFIG_PROPERTY(int, mediaProgressSweep, 180) - CONFIG_PROPERTY(int, mediaProgressThickness, 8) - CONFIG_PROPERTY(int, mediaVisualizerSize, 200) - CONFIG_PROPERTY(int, mediaWidth, 200) - CONFIG_PROPERTY(int, resourceProgessThickness, 10) - CONFIG_PROPERTY(int, resourceSize, 200) - CONFIG_PROPERTY(int, tabIndicatorHeight, 3) - CONFIG_PROPERTY(int, tabIndicatorSpacing, 5) - CONFIG_PROPERTY(int, weatherWidth, 250) -}; - class ProcessColumnEntry : public settings::ObjectNode { CONFIG_NODE(ProcessColumnEntry, settings::ObjectNode) @@ -51,9 +33,12 @@ class Dashboard : public settings::ObjectNode { CONFIG_PROPERTY(bool, showOnHover, true) CONFIG_PROPERTY(int, dragThreshold, 50) CONFIG_PROPERTY(int, mediaUpdateInterval, 2000) + CONFIG_PROPERTY(bool, showWeather, true) + CONFIG_PROPERTY(bool, showDashboard, true) + CONFIG_PROPERTY(bool, showMedia, true) + CONFIG_PROPERTY(bool, showProcesses, false) CONFIG_SUBOBJECT(Performance, performance) CONFIG_PROPERTY(int, resourceUpdateInterval, 1000) - CONFIG_SUBOBJECT(DashboardSizes, sizes) CONFIG_LIST( ProcessColumnList, processColumns, diff --git a/Plugins/ZShell/Config/tokens.hpp b/Plugins/ZShell/Config/tokens.hpp index 25c8388..c19848f 100644 --- a/Plugins/ZShell/Config/tokens.hpp +++ b/Plugins/ZShell/Config/tokens.hpp @@ -149,6 +149,19 @@ class DashboardTokens : public settings::ObjectNode { CONFIG_PROPERTY(int, processesHeight, 800) CONFIG_PROPERTY(int, processesHeaderHeight, 40) CONFIG_PROPERTY(int, processesRowHeight, 40) + CONFIG_PROPERTY(int, dateTimeWidth, 110) + CONFIG_PROPERTY(int, infoIconSize, 25) + CONFIG_PROPERTY(int, infoWidth, 200) + CONFIG_PROPERTY(int, mediaCoverArtSize, 150) + CONFIG_PROPERTY(int, mediaProgressSweep, 180) + CONFIG_PROPERTY(int, mediaProgressThickness, 8) + CONFIG_PROPERTY(int, mediaVisualizerSize, 200) + CONFIG_PROPERTY(int, mediaWidth, 200) + CONFIG_PROPERTY(int, resourceProgessThickness, 10) + CONFIG_PROPERTY(int, resourceSize, 200) + CONFIG_PROPERTY(int, tabIndicatorHeight, 3) + CONFIG_PROPERTY(int, tabIndicatorSpacing, 5) + CONFIG_PROPERTY(int, weatherWidth, 250) }; class SizeTokens : public settings::ObjectNode { diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index e6c413c..2e99e62 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -19,6 +19,7 @@ qml_module(ZShell-services diskinfo.hpp diskinfo.cpp storage.hpp storage.cpp processes.hpp processes.cpp + networkusage.hpp networkusage.cpp lyriccandidate.hpp lyriccandidate.cpp lyrics.hpp lyrics.cpp LIBRARIES @@ -30,6 +31,11 @@ qml_module(ZShell-services Sensors::Sensors ZShell-config ZShell-settings + ZShell-internal + ZShell + zshell-util DEPENDENCIES + ZShell ZShell.Config + ZShell.Internal ) diff --git a/Plugins/ZShell/Services/networkusage.cpp b/Plugins/ZShell/Services/networkusage.cpp new file mode 100644 index 0000000..493b03f --- /dev/null +++ b/Plugins/ZShell/Services/networkusage.cpp @@ -0,0 +1,153 @@ +#include "networkusage.hpp" + +#include +#include +#include + +#include +#include +#include + +#include "config/rootnodes.hpp" +#include "config/dashboard.hpp" + +namespace ZShell::services { + +using Qt::StringLiterals::operator""_s; + +NetworkUsage::NetworkUsage(QObject* parent) + : TickingService(parent) + , m_downloadBuffer(new internal::CircularBuffer(this)) + , m_uploadBuffer(new internal::CircularBuffer(this)) { + m_downloadBuffer->setSmoothingRadius(2); + m_uploadBuffer->setSmoothingRadius(2); + + auto* resources = + config::ConfigSingleton::instance()->dashboard()->performance(); + setHistoryLength(resources->showVram() ? 70 : 30); + QObject::connect( + resources, + &ZShell::config::Performance::showVramChanged, + this, + [this, resources] { + setHistoryLength(resources->showVram() ? 70 : 30); + }); +} + +qreal NetworkUsage::downloadSpeed() const { + return m_downloadSpeed; +} + +qreal NetworkUsage::uploadSpeed() const { + return m_uploadSpeed; +} + +qreal NetworkUsage::downloadTotal() const { + return m_downloadTotal; +} + +qreal NetworkUsage::uploadTotal() const { + return m_uploadTotal; +} + +int NetworkUsage::historyLength() const { + return m_historyLength; +} + +void NetworkUsage::setHistoryLength(int length) { + if (length <= 0 || length == m_historyLength) { + return; + } + m_historyLength = length; + m_downloadBuffer->setCapacity(m_historyLength + 1); + m_uploadBuffer->setCapacity(m_historyLength + 1); + Q_EMIT historyLengthChanged(); +} + +internal::CircularBuffer* NetworkUsage::downloadBuffer() const { + return m_downloadBuffer; +} + +internal::CircularBuffer* NetworkUsage::uploadBuffer() const { + return m_uploadBuffer; +} + +void NetworkUsage::tick() { + QFile f(u"/proc/net/dev"_s); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return; + } + // Skip headers + f.readLine(); + f.readLine(); + + quint64 totalRx = 0; + quint64 totalTx = 0; + + while (!f.atEnd()) { + const QByteArray line = f.readLine(); + const qsizetype splitIdx = line.indexOf(':'); + if (splitIdx == -1) { + continue; + } + const QByteArray iface = line.left(splitIdx).trimmed(); + if (iface == QByteArrayView("lo")) { + continue; // Skip loopback interface + } + + // Parse every counter through tx bytes to validate the row + const char* pos = line.constData() + splitIdx + 1; + const char* const end = line.constData() + line.size(); + + std::array fields{}; + bool valid = true; + for (unsigned long long& field : fields) { + while (pos < end && (*pos == ' ' || *pos == '\t')) + ++pos; + + const auto [next, ec] = std::from_chars(pos, end, field); + if (ec != std::errc{}) { + valid = false; + break; + } + pos = next; + } + + if (!valid) continue; + + totalRx += static_cast(fields[0]); + totalTx += static_cast(fields[8]); + } + f.close(); + + if (!m_initialized) { + m_prevRx = totalRx; + m_prevTx = totalTx; + m_timer.start(); + m_initialized = true; + return; + } + + const qreal elapsed = static_cast(m_timer.restart()) / 1000.0; + const quint64 rxDelta = totalRx >= m_prevRx ? totalRx - m_prevRx : 0; + const quint64 txDelta = totalTx >= m_prevTx ? totalTx - m_prevTx : 0; + + m_downloadTotal += static_cast(rxDelta); + m_uploadTotal += static_cast(txDelta); + + if (elapsed > 0.0) { + // Calculate speeds + m_downloadSpeed = static_cast(rxDelta) / elapsed; + m_uploadSpeed = static_cast(txDelta) / elapsed; + + m_downloadBuffer->push(m_downloadSpeed); + m_uploadBuffer->push(m_uploadSpeed); + } + + m_prevRx = totalRx; + m_prevTx = totalTx; + + emit changed(); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/networkusage.hpp b/Plugins/ZShell/Services/networkusage.hpp new file mode 100644 index 0000000..7a6555b --- /dev/null +++ b/Plugins/ZShell/Services/networkusage.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include +#include + +#include "internal/circularbuffer.hpp" +#include "tickingservice.hpp" + +namespace ZShell::services { + +class NetworkUsage : public TickingService { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(qreal downloadSpeed READ downloadSpeed NOTIFY changed) + Q_PROPERTY(qreal uploadSpeed READ uploadSpeed NOTIFY changed) + Q_PROPERTY(qreal downloadTotal READ downloadTotal NOTIFY changed) + Q_PROPERTY(qreal uploadTotal READ uploadTotal NOTIFY changed) + Q_PROPERTY(int historyLength READ historyLength NOTIFY historyLengthChanged) + + Q_PROPERTY( + ZShell::internal::CircularBuffer* downloadBuffer READ downloadBuffer + CONSTANT) + Q_PROPERTY( + ZShell::internal::CircularBuffer* uploadBuffer READ uploadBuffer + CONSTANT) + + public: + explicit NetworkUsage(QObject* parent = nullptr); + + [[nodiscard]] qreal downloadSpeed() const; + [[nodiscard]] qreal uploadSpeed() const; + [[nodiscard]] qreal downloadTotal() const; + [[nodiscard]] qreal uploadTotal() const; + [[nodiscard]] int historyLength() const; + + [[nodiscard]] internal::CircularBuffer* downloadBuffer() const; + [[nodiscard]] internal::CircularBuffer* uploadBuffer() const; + + signals: + void changed(); + void historyLengthChanged(); + + protected: + void tick() override; + + private: + void setHistoryLength(int length); + + qreal m_downloadSpeed = 0.0; + qreal m_uploadSpeed = 0.0; + qreal m_downloadTotal = 0.0; + qreal m_uploadTotal = 0.0; + int m_historyLength = 30; + + internal::CircularBuffer* m_downloadBuffer = nullptr; + internal::CircularBuffer* m_uploadBuffer = nullptr; + + quint64 m_prevRx = 0; + quint64 m_prevTx = 0; + bool m_initialized = false; + QElapsedTimer m_timer; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/processes.cpp b/Plugins/ZShell/Services/processes.cpp index 6175736..96cef22 100644 --- a/Plugins/ZShell/Services/processes.cpp +++ b/Plugins/ZShell/Services/processes.cpp @@ -2,8 +2,13 @@ #include #include +#include +#include +#include +#include #include #include +#include #include #include @@ -12,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -53,6 +59,82 @@ template int cmpVals(const T& a, const T& b) { return (a < b) - (a > b); } +// glibc hides getdents64 behind __USE_MISC; declare directly (no _GNU_SOURCE +// in this project). +extern "C" { +struct ProcDirEnt64 { + unsigned long d_ino; + off_t d_off; + unsigned short d_reclen; + unsigned char d_type; + char d_name[1]; +}; + +ssize_t getdents64(int fd, void* dirp, size_t count); +} + +// Raw open/read/close instead of QFile: QFSFileEngine issues an extra +// statx per open, and the per-file object churn adds up across ~500 +// processes per refresh. +QByteArray readProcFile(const char* path) { + const int fd = ::open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + return {}; + } + QByteArray data; + char buf[4096]; + for (;;) { + const ssize_t n = ::read(fd, buf, sizeof(buf)); + if (n < 0) { + if (errno == EINTR) { + continue; + } + break; + } + if (n == 0) { + break; + } + data.append(buf, static_cast(n)); + } + ::close(fd); + return data; +} + +void procPath(char* out, size_t size, pid_t pid, const char* what) { + ::snprintf(out, size, "/proc/%d/%s", static_cast(pid), what); +} + +QVector listProcPids() { + QVector pids; + const int fd = ::openat(AT_FDCWD, "/proc", O_RDONLY | O_DIRECTORY); + if (fd < 0) { + return pids; + } + + std::array buf; + for (;;) { + const ssize_t n = ::getdents64( + fd, reinterpret_cast(buf.data()), buf.size()); + if (n <= 0) { + break; + } + for (ssize_t off = 0; off < n;) { + const auto* d = + reinterpret_cast(buf.data() + off); + if (d->d_type == DT_DIR) { + bool ok = false; + const long v = QByteArray(d->d_name).toLong(&ok); + if (ok && v > 0) { + pids.push_back(static_cast(v)); + } + } + off += static_cast(d->d_reclen); + } + } + ::close(fd); + return pids; +} + struct FuzzyText { QString chars; @@ -460,19 +542,24 @@ void ProcessModel::setDesktopEntryIcons( } QString ProcessModel::resolveProcessExe(pid_t pid) { - const QString path = QStringLiteral("/proc/%1/exe").arg(pid); - const QString target = QFile::symLinkTarget(path); - - return target; + char path[40]; + procPath(path, sizeof(path), pid, "exe"); + char buf[4096]; + const ssize_t n = ::readlink(path, buf, sizeof(buf) - 1); + if (n < 0) { + return {}; + } + buf[n] = '\0'; + return QString::fromLocal8Bit(buf, static_cast(n)); } std::optional ProcessModel::readStat(pid_t pid) { - QFile f(QStringLiteral("/proc/%1/stat").arg(pid)); - if (!f.open(QIODevice::ReadOnly)) { + char path[40]; + procPath(path, sizeof(path), pid, "stat"); + const QByteArray data = readProcFile(path); + if (data.isEmpty()) { return std::nullopt; } - const QByteArray data = f.readAll(); - f.close(); const qsizetype openParen = data.indexOf('('); const qsizetype closeParen = data.lastIndexOf(')'); @@ -500,12 +587,9 @@ std::optional ProcessModel::readStat(pid_t pid) { } QString ProcessModel::readCommandLine(pid_t pid, const QString& fallbackComm) { - QFile f(QStringLiteral("/proc/%1/cmdline").arg(pid)); - if (!f.open(QIODevice::ReadOnly)) { - return QStringLiteral("[%1]").arg(fallbackComm); - } - QByteArray data = f.readAll(); - f.close(); + char path[40]; + procPath(path, sizeof(path), pid, "cmdline"); + QByteArray data = readProcFile(path); if (data.isEmpty()) { return QStringLiteral("[%1]").arg(fallbackComm); @@ -523,12 +607,9 @@ QString ProcessModel::readCommandLine(pid_t pid, const QString& fallbackComm) { } ProcessModel::StatusInfo ProcessModel::readStatus(pid_t pid) { - QFile f(QStringLiteral("/proc/%1/status").arg(pid)); - if (!f.open(QIODevice::ReadOnly)) { - return {}; - } - const QByteArray data = f.readAll(); - f.close(); + char path[40]; + procPath(path, sizeof(path), pid, "status"); + const QByteArray data = readProcFile(path); StatusInfo out; qsizetype pos = 0; @@ -554,12 +635,9 @@ ProcessModel::StatusInfo ProcessModel::readStatus(pid_t pid) { } ProcessModel::IoInfo ProcessModel::readIo(pid_t pid) { - QFile f(QStringLiteral("/proc/%1/io").arg(pid)); - if (!f.open(QIODevice::ReadOnly)) { - return {}; - } - const QByteArray data = f.readAll(); - f.close(); + char path[40]; + procPath(path, sizeof(path), pid, "io"); + const QByteArray data = readProcFile(path); IoInfo out; qsizetype pos = 0; @@ -606,12 +684,10 @@ QString ProcessModel::resolveUsername(uid_t uid, QHash& cache) { } std::optional ProcessModel::readUptimeSeconds() { - QFile f(QStringLiteral("/proc/uptime")); - if (!f.open(QIODevice::ReadOnly)) { + const QByteArray data = readProcFile("/proc/uptime"); + if (data.isEmpty()) { return std::nullopt; } - const QByteArray data = f.readAll(); - f.close(); const auto fields = splitWhitespace(QByteArrayView(data)); if (fields.isEmpty()) { @@ -623,35 +699,17 @@ std::optional ProcessModel::readUptimeSeconds() { return ok ? std::make_optional(uptime) : std::nullopt; } -ProcessModel::ScanResult ProcessModel::scanProcesses( - double uptime, - double deltaSecs, - QHash prevPidToAll, - QVector prevAll, - QHash uidCache, - long clockTicksPerSec, - QHash execPathToIcon, - QHash execNameToIcon, - QStringList pathDirs, - bool desktopEntryIconsChanged, - quint64 iconVersion) { - Q_UNUSED( - pathDirs); // kept for future cgroup fallback; sanitizeExec already ran on GUI thread - - QDir procDir(QStringLiteral("/proc")); - const auto entries = - procDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::NoSort); - - QVector fresh; - fresh.reserve(entries.size()); - - for (const QString& name : entries) { - bool isPid = false; - const pid_t pid = static_cast(name.toLong(&isPid)); - if (!isPid || pid <= 0) { - continue; - } +void ProcessModel::scanChunk( + const ScanContext& ctx, + const QVector& pids, + QVector& out, + QHash& uidCache) { + const QHash& prevPidToAll = *ctx.prevPidToAll; + const QVector& prevAll = *ctx.prevAll; + const QHash& execPathToIcon = *ctx.execPathToIcon; + const QHash& execNameToIcon = *ctx.execNameToIcon; + for (pid_t pid : pids) { const auto statOpt = readStat(pid); if (!statOpt) { continue; @@ -675,7 +733,7 @@ ProcessModel::ScanResult ProcessModel::scanProcesses( entry.commandLine = readCommandLine(pid, stat.comm); } - if (!samePidInstance || desktopEntryIconsChanged) { + if (!samePidInstance || ctx.iconsChanged) { const QString exePath = resolveProcessExe(pid); QString iconName; @@ -711,18 +769,23 @@ ProcessModel::ScanResult ProcessModel::scanProcesses( entry.uid = status.uid; entry.username = resolveUsername(status.uid, uidCache); - const IoInfo io = readIo(pid); + // /proc/PID/io is only readable by root or the process owner; skip + // the open when it is guaranteed to fail. + IoInfo io; + if (ctx.selfEuid == 0 || status.uid == ctx.selfEuid) { + io = readIo(pid); + } entry.ioReadBytes = io.readBytes; entry.ioWriteBytes = io.writeBytes; entry.elapsedSecs = std::max( 0.0, - uptime - (static_cast(stat.starttime) / - static_cast(clockTicksPerSec))); + ctx.uptime - (static_cast(stat.starttime) / + static_cast(ctx.clockTicksPerSec))); entry.lastUtime = stat.utime; entry.lastStime = stat.stime; - if (samePidInstance && deltaSecs > 0.0) { + if (samePidInstance && ctx.deltaSecs > 0.0) { const ProcessEntry& prev = prevAll.at(prevIt.value()); const quint64 prevTicks = prev.lastUtime + prev.lastStime; const quint64 nowTicks = stat.utime + stat.stime; @@ -730,13 +793,97 @@ ProcessModel::ScanResult ProcessModel::scanProcesses( nowTicks > prevTicks ? nowTicks - prevTicks : 0; entry.cpuPercent = (static_cast(deltaTicks) / - (deltaSecs * static_cast(clockTicksPerSec))) * + (ctx.deltaSecs * + static_cast(ctx.clockTicksPerSec))) * 100.0; } else { entry.cpuPercent = 0.0; } - fresh.push_back(std::move(entry)); + out.push_back(std::move(entry)); + } +} + +ProcessModel::ScanResult ProcessModel::scanProcesses( + double uptime, + double deltaSecs, + QHash prevPidToAll, + QVector prevAll, + QHash uidCache, + long clockTicksPerSec, + QHash execPathToIcon, + QHash execNameToIcon, + QStringList pathDirs, + bool desktopEntryIconsChanged, + quint64 iconVersion) { + Q_UNUSED( + pathDirs); // kept for future cgroup fallback; sanitizeExec already ran on GUI thread + + const QVector pids = listProcPids(); + + ScanContext ctx; + ctx.uptime = uptime; + ctx.deltaSecs = deltaSecs; + ctx.prevPidToAll = &prevPidToAll; + ctx.prevAll = &prevAll; + ctx.clockTicksPerSec = clockTicksPerSec; + ctx.execPathToIcon = &execPathToIcon; + ctx.execNameToIcon = &execNameToIcon; + ctx.iconsChanged = desktopEntryIconsChanged; + ctx.selfEuid = ::geteuid(); + + QVector fresh; + fresh.reserve(pids.size()); + + // Split the pid list across worker threads; a single pass over /proc is + // syscall-bound (open/read/close per file per process) and serializes + // badly on large systems. Keep one pool thread free for this function, + // which itself runs inside the global pool. + int nThreads = qMin(16, QThreadPool::globalInstance()->maxThreadCount() - 1); + if (pids.size() < 128 || nThreads < 2) { + nThreads = 1; + } + + if (nThreads <= 1) { + scanChunk(ctx, pids, fresh, uidCache); + } else { + const qsizetype per = (pids.size() + nThreads - 1) / nThreads; + QVector> chunks(nThreads); + QVector> outs(nThreads); + QVector> uidParts(nThreads); + QVector> futures; + + for (int t = 0; t < nThreads; ++t) { + const qsizetype a = static_cast(t) * per; + const qsizetype b = qMin(a + per, pids.size()); + if (a >= b) { + continue; + } + chunks[t] = pids.mid(a, b - a); + futures.append(QtConcurrent::run( + [&ctx, &chunks, &outs, &uidParts, t]() { + scanChunk(ctx, chunks[t], outs[t], uidParts[t]); + })); + } + for (QFuture& f : futures) { + f.waitForFinished(); + } + + for (qsizetype t = 0; t < nThreads; ++t) { + for (ProcessEntry& e : outs[t]) { + fresh.push_back(std::move(e)); + } + for (auto it = uidParts[t].constBegin(); + it != uidParts[t].constEnd(); + ++it) { + uidCache.insert(it.key(), it.value()); + } + } + std::sort( + fresh.begin(), fresh.end(), [](const ProcessEntry& a, + const ProcessEntry& b) { + return a.pid < b.pid; + }); } ScanResult result; @@ -810,6 +957,13 @@ void ProcessModel::onScanFinished() { for (int i = 0; i < mAll.size(); ++i) { mPidToAll.insert(mAll.at(i).pid, i); } + for (auto it = mFuzzyCache.begin(); it != mFuzzyCache.end();) { + if (mPidToAll.contains(it.key())) { + ++it; + } else { + it = mFuzzyCache.erase(it); + } + } for (auto it = result.newUidNames.constBegin(); it != result.newUidNames.constEnd(); ++it) { @@ -846,11 +1000,35 @@ void ProcessModel::applyView() { ProcessEntry entry = src; if (searching) { - int score = fuzzyQueryScore(fuzzyPrepare(src.command), terms); - if (!src.commandLine.isEmpty() && src.commandLine != src.command) { - score = qMax( - score, - fuzzyQueryScore(fuzzyPrepare(src.commandLine), terms)); + // Normalizing (NFD) + lowering per process per tick is the + // expensive part of search; commands are stable for the life of + // a process instance (pid + starttime), so cache the prepared + // text. comm can still change without an exec (prctl), so + // re-prepare when the raw command differs. + FuzzyCacheEntry& fe = mFuzzyCache[src.pid]; + if (fe.starttime != src.starttime || fe.command != src.command) { + fe.starttime = src.starttime; + fe.command = src.command; + const FuzzyText prep = fuzzyPrepare(src.command); + fe.commandChars = std::move(prep.chars); + fe.commandClasses = std::move(prep.classes); + fe.hasCommandLine = + !src.commandLine.isEmpty() && src.commandLine != src.command; + if (fe.hasCommandLine) { + const FuzzyText prepCl = fuzzyPrepare(src.commandLine); + fe.commandLineChars = std::move(prepCl.chars); + fe.commandLineClasses = std::move(prepCl.classes); + } else { + fe.commandLineChars = QString(); + fe.commandLineClasses = {}; + } + } + const FuzzyText cmdFuzzy{fe.commandChars, fe.commandClasses}; + int score = fuzzyQueryScore(cmdFuzzy, terms); + if (fe.hasCommandLine) { + const FuzzyText clFuzzy{ + fe.commandLineChars, fe.commandLineClasses}; + score = qMax(score, fuzzyQueryScore(clFuzzy, terms)); } if (score < 0) { continue; diff --git a/Plugins/ZShell/Services/processes.hpp b/Plugins/ZShell/Services/processes.hpp index b82cac6..ea48f4c 100644 --- a/Plugins/ZShell/Services/processes.hpp +++ b/Plugins/ZShell/Services/processes.hpp @@ -75,6 +75,16 @@ class ProcessModel : public QAbstractListModel { void sortAscendingChanged(); private: + struct FuzzyCacheEntry { + quint64 starttime = 0; + QString command; + QString commandChars; + QVector commandClasses; + QString commandLineChars; + QVector commandLineClasses; + bool hasCommandLine = false; + }; + struct RawStat { pid_t pid = 0; pid_t ppid = 0; @@ -116,6 +126,18 @@ class ProcessModel : public QAbstractListModel { quint64 writeBytes = 0; }; + struct ScanContext { + double uptime = 0.0; + double deltaSecs = -1.0; + const QHash* prevPidToAll = nullptr; + const QVector* prevAll = nullptr; + long clockTicksPerSec = 100; + const QHash* execPathToIcon = nullptr; + const QHash* execNameToIcon = nullptr; + bool iconsChanged = false; + uid_t selfEuid = 0; + }; + struct ScanResult { QVector entries; QHash newUidNames; @@ -130,6 +152,12 @@ class ProcessModel : public QAbstractListModel { static QString resolveUsername(uid_t uid, QHash& cache); static QString resolveProcessExe(pid_t pid); + static void scanChunk( + const ScanContext& ctx, + const QVector& pids, + QVector& out, + QHash& uidCache); + static ScanResult scanProcesses( double uptime, double deltaSecs, @@ -153,6 +181,7 @@ class ProcessModel : public QAbstractListModel { QVector mAll; QHash mPidToAll; + QHash mFuzzyCache; QVector mProcesses; QHash mUidToName; diff --git a/Plugins/ZShell/Services/tickingservice.cpp b/Plugins/ZShell/Services/tickingservice.cpp index 05e6c83..0dc67fa 100644 --- a/Plugins/ZShell/Services/tickingservice.cpp +++ b/Plugins/ZShell/Services/tickingservice.cpp @@ -1,11 +1,7 @@ #include "tickingservice.hpp" -#include -#include -#include -#include -#include -#include -#include + +#include "config/dashboard.hpp" +#include "config/rootnodes.hpp" namespace ZShell::services { @@ -17,53 +13,13 @@ TickingService::TickingService(QObject* parent) updateTriggered(); }); - QString configPath = - QDir::homePath() + QStringLiteral("/.config/zshell/config.json"); - - auto reloadConfig = [this, configPath]() { - QFile file(configPath); - if (!file.open(QIODevice::ReadOnly)) { - return; - } - QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - if (doc.isNull()) { - return; - } - QJsonObject dashboard = doc.object().value("dashboard").toObject(); - if (dashboard.contains("resourceUpdateInterval")) { - applyInterval( - dashboard.value("resourceUpdateInterval").toInt(1000)); - } - }; - - reloadConfig(); - - static auto* watcher = new QFileSystemWatcher(); - if (!watcher->files().contains(configPath)) { - QObject::connect( - watcher, - &QFileSystemWatcher::fileChanged, - this, - [this, configPath]() { - QTimer::singleShot(100, this, [this, configPath]() { - QFile file(configPath); - if (!file.open(QIODevice::ReadOnly)) { - return; - } - QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - if (doc.isNull()) { - return; - } - QJsonObject dashboard = - doc.object().value("dashboard").toObject(); - if (dashboard.contains("resourceUpdateInterval")) { - applyInterval(dashboard.value("resourceUpdateInterval") - .toInt(1000)); - } - }); - }); - watcher->addPath(configPath); - } + auto* dash = ZShell::config::ConfigSingleton::instance()->dashboard(); + applyInterval(dash->resourceUpdateInterval()); + QObject::connect( + dash, + &ZShell::config::Dashboard::resourceUpdateIntervalChanged, + this, + [this, dash] { applyInterval(dash->resourceUpdateInterval()); }); } int TickingService::updateInterval() const { diff --git a/Plugins/ZShell/Services/tickingservice.hpp b/Plugins/ZShell/Services/tickingservice.hpp index e73a911..5cd7fe1 100644 --- a/Plugins/ZShell/Services/tickingservice.hpp +++ b/Plugins/ZShell/Services/tickingservice.hpp @@ -1,8 +1,9 @@ #pragma once -#include "service.hpp" #include +#include "service.hpp" + namespace ZShell::services { class TickingService : public Service { diff --git a/Plugins/ZShell/Services/usagefmt.cpp b/Plugins/ZShell/Services/usagefmt.cpp index 1d4cf32..28d1f5b 100644 --- a/Plugins/ZShell/Services/usagefmt.cpp +++ b/Plugins/ZShell/Services/usagefmt.cpp @@ -48,4 +48,20 @@ FormatResult UsageFmt::formatKib(qreal kib, qreal total) const { return {kib, total, "KiB"}; } +FormatResult UsageFmt::formatBytes(qreal bytes) const { + static constexpr std::array units = { + "B", "KB", "MB", "GB", "TB"}; + + qreal value = finitePositive(bytes) ? bytes : 0.0; + const qreal k = 1024.0; + std::size_t i = 0; + + while (value >= k && i < units.size() - 1) { + value /= k; + i++; + } + + return {value, 0.0, units[i]}; +} + } // namespace ZShell::services::usagefmt diff --git a/Plugins/ZShell/Services/usagefmt.hpp b/Plugins/ZShell/Services/usagefmt.hpp index 38a06fb..288ce2e 100644 --- a/Plugins/ZShell/Services/usagefmt.hpp +++ b/Plugins/ZShell/Services/usagefmt.hpp @@ -31,6 +31,7 @@ class UsageFmt : public QObject { Q_INVOKABLE [[nodiscard]] FormatResult formatKib(qreal kib) const; Q_INVOKABLE [[nodiscard]] FormatResult formatKib( qreal kib, qreal total) const; + Q_INVOKABLE [[nodiscard]] FormatResult formatBytes(qreal bytes) const; }; } // namespace ZShell::services::usagefmt diff --git a/Plugins/ZShell/Settings/macros.hpp b/Plugins/ZShell/Settings/macros.hpp index 6d065fe..b3b47fb 100644 --- a/Plugins/ZShell/Settings/macros.hpp +++ b/Plugins/ZShell/Settings/macros.hpp @@ -4,190 +4,205 @@ #include #include -#include "listnode.hpp" // IWYU pragma: keep -#include "schema.hpp" // IWYU pragma: keep +#include "listnode.hpp" +#include "schema.hpp" namespace ZShell::settings { -inline QVariantMap vmap(std::initializer_list> entries) { - QVariantMap map; - for (const auto& [key, value] : entries) - map.insert(key, value); - return map; +inline QVariantMap vmap( + std::initializer_list> entries) { + QVariantMap map; + for (const auto& [key, value] : entries) + map.insert(key, value); + return map; } template inline QList unionTypes() { - static_assert(sizeof...(Ts) >= 2, "A union needs at least two types"); - // If the max size is changed, common.cpp `mismatchStr` must be updated - static_assert(sizeof...(Ts) <= 4, "A union cannot have more than 4 types"); - static_assert((!std::is_same_v && ...), "A union cannot contain QVariant"); + static_assert(sizeof...(Ts) >= 2, "A union needs at least two types"); + static_assert(sizeof...(Ts) <= 4, "A union cannot have more than 4 types"); + static_assert( + (!std::is_same_v && ...), + "A union cannot contain QVariant"); - return { QMetaType::fromType()... }; + return {QMetaType::fromType()...}; } namespace detail { template inline bool compare(const T& a, const T& b) { - return a == b; + return a == b; } template bool compare(const T& a, const T& b) { - return qFuzzyCompare(a + T(1), b + T(1)); + return qFuzzyCompare(a + T(1), b + T(1)); } -template bool compare(const QList& a, const QList& b) { - if (a.size() != b.size()) - return false; +template +bool compare(const QList& a, const QList& b) { + if (a.size() != b.size()) return false; - for (qsizetype i = 0; i < a.size(); ++i) - if (!compare(a.at(i), b.at(i))) - return false; - return true; + for (qsizetype i = 0; i < a.size(); ++i) + if (!compare(a.at(i), b.at(i))) return false; + return true; } } // namespace detail } // namespace ZShell::settings -// Helper macro to prevent splitting initialiser lists #define DEFAULT_ARG(...) __VA_ARGS__ -// Declares a class to be a node class. This replaces the Q_OBJECT call at the top of the class. -#define SETTINGS_NODE_NO_CTOR(Class, Base) \ - Q_OBJECT \ - \ -public: \ - [[nodiscard]] const ZShell::settings::Schema& schema() const override { \ - static const auto schema = \ - ZShell::settings::Schema::build(&staticMetaObject, Base::staticMetaObject.propertyCount()); \ - return schema; \ - } \ - \ -private: \ - using Self = Class; // For use in the below macros +#define SETTINGS_NODE_NO_CTOR(Class, Base) \ + Q_OBJECT \ + \ + public: \ + [[nodiscard]] const ZShell::settings::Schema& schema() const override { \ + static const auto schema = ZShell::settings::Schema::build( \ + &staticMetaObject, Base::staticMetaObject.propertyCount()); \ + return schema; \ + } \ + \ + private: \ + using Self = Class; // For use in the below macros -#define SETTINGS_NODE(Class, Base) \ - SETTINGS_NODE_NO_CTOR(Class, Base) \ - QML_ANONYMOUS \ - \ -public: \ - explicit Class(Class* fallback = nullptr, QObject* parent = nullptr, bool globalOnly = false) \ - : Base(fallback, parent, globalOnly) {} \ - \ -private: +#define SETTINGS_NODE(Class, Base) \ + SETTINGS_NODE_NO_CTOR(Class, Base) \ + QML_ANONYMOUS \ + \ + public: \ + explicit Class( \ + Class* fallback = nullptr, \ + QObject* parent = nullptr, \ + bool globalOnly = false) \ + : Base(fallback, parent, globalOnly) {} \ + \ + private: -// Defines a property on a node. -#define SETTINGS_PROPERTY_IMPL(Type, name, global, defaultVal, ...) \ - Q_PROPERTY(Type name READ name WRITE set_##name NOTIFY name##Changed) \ - \ -public: \ - [[nodiscard]] Type name() const { \ - if (global || m_globalOnly) \ - warnGlobalRead(QStringLiteral(#name)); \ - return m_##name; \ - } \ - \ - void set_##name(const Type& value) { \ - if (!true /* TODO: validation */) \ - return; \ - \ - if (rejectInvalidWrite(QStringLiteral(#name), value)) \ - return; /* Skip writes of the wrong type */ \ - \ - if (rejectGlobalWrite(QStringLiteral(#name))) \ - return; /* Skip writes to global only keys, they should be sent to the global layer */ \ - \ - const auto needsNotify = !ZShell::settings::detail::compare(value, m_##name); \ - m_##name = value; \ - if (recordWrite(QStringLiteral(#name), needsNotify)) \ - Q_EMIT name##Changed(); \ - } \ - \ - Q_SIGNAL void name##Changed(); \ - \ -private: \ - Type m_##name = fallbackValue(&Self::m_##name, defaultVal); \ - inline static const bool s_register_##name = \ - (ZShell::settings::Schema::annotate(&staticMetaObject, QStringLiteral(#name), \ - { .defaultValue = QVariant::fromValue(defaultVal), .globalOnly = global, __VA_ARGS__ }), \ - true); +#define SETTINGS_PROPERTY_IMPL(Type, name, global, defaultVal, ...) \ + Q_PROPERTY(Type name READ name WRITE set_##name NOTIFY name##Changed) \ + \ + public: \ + [[nodiscard]] Type name() const { \ + if (global || m_globalOnly) warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ + void set_##name(const Type& value) { \ + if (!true /* TODO: validation */) return; \ + \ + if (rejectInvalidWrite(QStringLiteral(#name), value)) \ + return; /* Skip writes of the wrong type */ \ + \ + if (rejectGlobalWrite(QStringLiteral(#name))) \ + return; /* Skip writes to global only keys */ \ + \ + const auto needsNotify = \ + !ZShell::settings::detail::compare(value, m_##name); \ + m_##name = value; \ + if (recordWrite(QStringLiteral(#name), needsNotify)) \ + Q_EMIT name##Changed(); \ + } \ + \ + Q_SIGNAL void name##Changed(); \ + \ + private: \ + Type m_##name = fallbackValue(&Self::m_##name, defaultVal); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate( \ + &staticMetaObject, \ + QStringLiteral(#name), \ + {.defaultValue = QVariant::fromValue(defaultVal), \ + .globalOnly = global, \ + __VA_ARGS__}), \ + true); -#define SETTINGS_PROPERTY(Type, name, defaultVal, ...) \ - SETTINGS_PROPERTY_IMPL(Type, name, false, DEFAULT_ARG(defaultVal), __VA_ARGS__) +#define SETTINGS_PROPERTY(Type, name, defaultVal, ...) \ + SETTINGS_PROPERTY_IMPL( \ + Type, name, false, DEFAULT_ARG(defaultVal), __VA_ARGS__) // Defines a global property on a node. Shorthand for .globalOnly = true. -#define SETTINGS_GLOBAL_PROPERTY(Type, name, defaultVal, ...) \ - SETTINGS_PROPERTY_IMPL(Type, name, true, DEFAULT_ARG(defaultVal), __VA_ARGS__) +#define SETTINGS_GLOBAL_PROPERTY(Type, name, defaultVal, ...) \ + SETTINGS_PROPERTY_IMPL( \ + Type, name, true, DEFAULT_ARG(defaultVal), __VA_ARGS__) // Defines a subobject property on a node. Subobject properties are CONSTANT. -#define SETTINGS_SUBOBJECT_IMPL(Type, name, global) \ - Q_PROPERTY(Type* name READ name CONSTANT) \ - \ -public: \ - [[nodiscard]] Type* name() const { \ - if (global || m_globalOnly) \ - warnGlobalRead(QStringLiteral(#name)); \ - return m_##name; \ - } \ - \ -private: \ - Type* m_##name = new Type(fallbackValue(&Self::m_##name, nullptr), this, global); \ - inline static const bool s_register_##name = \ - (ZShell::settings::Schema::annotate( \ - &staticMetaObject, QStringLiteral(#name), { .defaultValue = {}, .globalOnly = global }), \ - true); +#define SETTINGS_SUBOBJECT_IMPL(Type, name, global) \ + Q_PROPERTY(Type* name READ name CONSTANT) \ + \ + public: \ + [[nodiscard]] Type* name() const { \ + if (global || m_globalOnly) warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ + private: \ + Type* m_##name = \ + new Type(fallbackValue(&Self::m_##name, nullptr), this, global); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate( \ + &staticMetaObject, \ + QStringLiteral(#name), \ + {.defaultValue = {}, .globalOnly = global}), \ + true); -#define SETTINGS_SUBOBJECT(Type, name) SETTINGS_SUBOBJECT_IMPL(Type, name, false) +#define SETTINGS_SUBOBJECT(Type, name) \ + SETTINGS_SUBOBJECT_IMPL(Type, name, false) -// Defines a global subobject on a node. Everything inside it is global only. -#define SETTINGS_GLOBAL_SUBOBJECT(Type, name) SETTINGS_SUBOBJECT_IMPL(Type, name, true) +#define SETTINGS_GLOBAL_SUBOBJECT(Type, name) \ + SETTINGS_SUBOBJECT_IMPL(Type, name, true) -// Defines a list type for use with SETTINGS_LIST. -#define SETTINGS_LIST_TYPE(Element, Name) \ - class Name : public ZShell::settings::ListNode { \ - Q_OBJECT \ - QML_ANONYMOUS \ - \ - public: \ - explicit Name(Name* fallback = nullptr, QObject* parent = nullptr, bool globalOnly = false) \ - : ZShell::settings::ListNode(fallback, parent, globalOnly) {} \ - \ - [[nodiscard]] Q_INVOKABLE Element* at(qsizetype index) const { /* Format ugh */ \ - return static_cast(elementAt(index)); \ - } \ - [[nodiscard]] Q_INVOKABLE Element* insert(const QVariantMap& props, qsizetype index = -1) { \ - return static_cast(insertElement(props, index)); \ - } \ - \ - protected: \ - [[nodiscard]] ZShell::settings::Node* createElement(ZShell::settings::Node* fallback) override { \ - return new Element(static_cast(fallback), this); \ - } \ - }; +#define SETTINGS_LIST_TYPE(Element, Name) \ + class Name : public ZShell::settings::ListNode { \ + Q_OBJECT \ + QML_ANONYMOUS \ + \ + public: \ + explicit Name( \ + Name* fallback = nullptr, \ + QObject* parent = nullptr, \ + bool globalOnly = false) \ + : ZShell::settings::ListNode(fallback, parent, globalOnly) {} \ + \ + [[nodiscard]] Q_INVOKABLE Element* at( \ + qsizetype index) const { /* Format ugh */ \ + return static_cast(elementAt(index)); \ + } \ + [[nodiscard]] Q_INVOKABLE Element* insert( \ + const QVariantMap& props, qsizetype index = -1) { \ + return static_cast(insertElement(props, index)); \ + } \ + \ + protected: \ + [[nodiscard]] ZShell::settings::Node* createElement( \ + ZShell::settings::Node* fallback) override { \ + return new Element(static_cast(fallback), this); \ + } \ + }; -// Defines a list property on a node. List properties are CONSTANT. -#define SETTINGS_LIST_IMPL(Type, name, global, defaultVal, ...) \ - Q_PROPERTY(Type* name READ name CONSTANT) \ - \ -public: \ - [[nodiscard]] Type* name() const { \ - if (global || m_globalOnly) \ - warnGlobalRead(QStringLiteral(#name)); \ - return m_##name; \ - } \ - \ -private: \ - Type* m_##name = new Type(fallbackValue(&Self::m_##name, nullptr), this, global); \ - inline static const bool s_register_##name = \ - (ZShell::settings::Schema::annotate(&staticMetaObject, QStringLiteral(#name), \ - { .defaultValue = QVariant::fromValue>(defaultVal), \ - .globalOnly = global, \ - __VA_ARGS__ }), \ - true); +#define SETTINGS_LIST_IMPL(Type, name, global, defaultVal, ...) \ + Q_PROPERTY(Type* name READ name CONSTANT) \ + \ + public: \ + [[nodiscard]] Type* name() const { \ + if (global || m_globalOnly) warnGlobalRead(QStringLiteral(#name)); \ + return m_##name; \ + } \ + \ + private: \ + Type* m_##name = \ + new Type(fallbackValue(&Self::m_##name, nullptr), this, global); \ + inline static const bool s_register_##name = \ + (ZShell::settings::Schema::annotate( \ + &staticMetaObject, \ + QStringLiteral(#name), \ + {.defaultValue = \ + QVariant::fromValue>(defaultVal), \ + .globalOnly = global, \ + __VA_ARGS__}), \ + true); -#define SETTINGS_LIST(Type, name, defaultVal, ...) \ - SETTINGS_LIST_IMPL(Type, name, false, DEFAULT_ARG(defaultVal), __VA_ARGS__) +#define SETTINGS_LIST(Type, name, defaultVal, ...) \ + SETTINGS_LIST_IMPL(Type, name, false, DEFAULT_ARG(defaultVal), __VA_ARGS__) -// Defines a global list on a node. Everything inside it is global only. -#define SETTINGS_GLOBAL_LIST(Type, name, defaultVal, ...) \ - SETTINGS_LIST_IMPL(Type, name, true, DEFAULT_ARG(defaultVal), __VA_ARGS__) +#define SETTINGS_GLOBAL_LIST(Type, name, defaultVal, ...) \ + SETTINGS_LIST_IMPL(Type, name, true, DEFAULT_ARG(defaultVal), __VA_ARGS__) From 133d453f6fd6e752b391e7c33a0e249b5afab5ad Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 28 Sep 2026 12:32:36 +0200 Subject: [PATCH 18/90] fix: fix nvidia update interval on config change + more robust read path for generic --- Helpers/NetworkUsage.qml | 242 --------- Modules/Resources/Cards/NetworkCard.qml | 4 +- Plugins/ZShell/Config/services.hpp | 3 +- Plugins/ZShell/Services/gpu.cpp | 590 ++++++++++++--------- Plugins/ZShell/Services/gpu.hpp | 70 ++- Plugins/ZShell/Services/tickingservice.cpp | 1 + Plugins/ZShell/Services/tickingservice.hpp | 1 + 7 files changed, 390 insertions(+), 521 deletions(-) delete mode 100644 Helpers/NetworkUsage.qml diff --git a/Helpers/NetworkUsage.qml b/Helpers/NetworkUsage.qml deleted file mode 100644 index b78c540..0000000 --- a/Helpers/NetworkUsage.qml +++ /dev/null @@ -1,242 +0,0 @@ -pragma Singleton - -import QtQuick -import Quickshell -import Quickshell.Io -import ZShell.Internal -import ZShell.Config - -Singleton { - id: root - - property real _downloadSpeed: 0 - property real _downloadTotal: 0 - - property real _initialRxBytes: 0 - property real _initialTxBytes: 0 - property bool _initialized: false - - property real _prevRxBytes: 0 - property real _prevTimestamp: 0 - property real _prevTxBytes: 0 - property real _uploadSpeed: 0 - property real _uploadTotal: 0 - - readonly property CircularBuffer downloadBuffer: _downloadBuffer - - readonly property real downloadSpeed: _downloadSpeed - - readonly property real downloadTotal: _downloadTotal - readonly property int historyLength: Config.dashboard.performance.showVram ? 70 : 30 - property int refCount: 0 - readonly property CircularBuffer uploadBuffer: _uploadBuffer - readonly property real uploadSpeed: _uploadSpeed - readonly property real uploadTotal: _uploadTotal - -function resetSampling(): void { - netDevFile.reload(); - - const content = netDevFile.text(); - if (!content) - return; - - const data = root.parseNetDev(content); - const now = Date.now(); - - root._prevRxBytes = data.rx; - root._prevTxBytes = data.tx; - root._prevTimestamp = now; -} - - function formatBytes(bytes: real): var { - // Handle negative or invalid values - if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) { - return { - value: 0, - unit: "B/s" - }; - } - - if (bytes < 1024) { - return { - value: bytes, - unit: "B/s" - }; - } else if (bytes < 1024 * 1024) { - return { - value: bytes / 1024, - unit: "KB/s" - }; - } else if (bytes < 1024 * 1024 * 1024) { - return { - value: bytes / (1024 * 1024), - unit: "MB/s" - }; - } else { - return { - value: bytes / (1024 * 1024 * 1024), - unit: "GB/s" - }; - } - } - - function formatBytesTotal(bytes: real): var { - // Handle negative or invalid values - if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) { - return { - value: 0, - unit: "B" - }; - } - - if (bytes < 1024) { - return { - value: bytes, - unit: "B" - }; - } else if (bytes < 1024 * 1024) { - return { - value: bytes / 1024, - unit: "KB" - }; - } else if (bytes < 1024 * 1024 * 1024) { - return { - value: bytes / (1024 * 1024), - unit: "MB" - }; - } else { - return { - value: bytes / (1024 * 1024 * 1024), - unit: "GB" - }; - } - } - - function parseNetDev(content: string): var { - const lines = content.split("\n"); - let totalRx = 0; - let totalTx = 0; - - for (let i = 2; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) - continue; - - const parts = line.split(/\s+/); - if (parts.length < 10) - continue; - - const iface = parts[0].replace(":", ""); - // Skip loopback interface - if (iface === "lo") - continue; - - const rxBytes = parseFloat(parts[1]) || 0; - const txBytes = parseFloat(parts[9]) || 0; - - totalRx += rxBytes; - totalTx += txBytes; - } - - return { - rx: totalRx, - tx: totalTx - }; - } - - CircularBuffer { - id: _downloadBuffer - - capacity: root.historyLength + 1 - } - - CircularBuffer { - id: _uploadBuffer - - capacity: root.historyLength + 1 - } - - FileView { - id: netDevFile - - path: "/proc/net/dev" - } - - Timer { - interval: Config.dashboard.resourceUpdateInterval - repeat: true - running: true - triggeredOnStart: true - - onTriggered: { - if (root.refCount <= 0) { - root.resetSampling(); - return; - } - - netDevFile.reload(); - const content = netDevFile.text(); - if (!content) - return; - - const data = root.parseNetDev(content); - const now = Date.now(); - - if (!root._initialized) { - root._initialRxBytes = data.rx; - root._initialTxBytes = data.tx; - root._prevRxBytes = data.rx; - root._prevTxBytes = data.tx; - root._prevTimestamp = now; - root._initialized = true; - return; - } - - const timeDelta = (now - root._prevTimestamp) / 1000; // seconds - if (timeDelta > 0) { - // Calculate byte deltas - let rxDelta = data.rx - root._prevRxBytes; - let txDelta = data.tx - root._prevTxBytes; - - // Handle counter overflow (when counters wrap around from max to 0) - // This happens when counters exceed 32-bit or 64-bit limits - if (rxDelta < 0) { - // Counter wrapped around - assume 64-bit counter - rxDelta += Math.pow(2, 64); - } - if (txDelta < 0) { - txDelta += Math.pow(2, 64); - } - - // Calculate speeds - root._downloadSpeed = rxDelta / timeDelta; - root._uploadSpeed = txDelta / timeDelta; - - if (root._downloadSpeed >= 0 && isFinite(root._downloadSpeed)) - _downloadBuffer.push(root._downloadSpeed); - - if (root._uploadSpeed >= 0 && isFinite(root._uploadSpeed)) - _uploadBuffer.push(root._uploadSpeed); - } - - // Calculate totals with overflow handling - let downTotal = data.rx - root._initialRxBytes; - let upTotal = data.tx - root._initialTxBytes; - - // Handle counter overflow for totals - if (downTotal < 0) { - downTotal += Math.pow(2, 64); - } - if (upTotal < 0) { - upTotal += Math.pow(2, 64); - } - - root._downloadTotal = downTotal; - root._uploadTotal = upTotal; - - root._prevRxBytes = data.rx; - root._prevTxBytes = data.tx; - root._prevTimestamp = now; - } - } -} diff --git a/Modules/Resources/Cards/NetworkCard.qml b/Modules/Resources/Cards/NetworkCard.qml index 445ccf6..ea6474c 100644 --- a/Modules/Resources/Cards/NetworkCard.qml +++ b/Modules/Resources/Cards/NetworkCard.qml @@ -78,12 +78,12 @@ CustomRect { } Connections { - function onValuesChanged(): void { + function onUpdateTriggered(): void { sparkline.targetMax = Math.max(NetworkUsage.downloadBuffer.maximum, NetworkUsage.uploadBuffer.maximum, 1024); slideAnim.restart(); } - target: NetworkUsage.downloadBuffer + target: NetworkUsage } NumberAnimation { diff --git a/Plugins/ZShell/Config/services.hpp b/Plugins/ZShell/Config/services.hpp index 1f341f8..8e6891c 100644 --- a/Plugins/ZShell/Config/services.hpp +++ b/Plugins/ZShell/Config/services.hpp @@ -1,4 +1,5 @@ #pragma once + #include "common.hpp" #include "enums.hpp" @@ -20,7 +21,7 @@ class Services : public settings::ObjectNode { CONFIG_PROPERTY(qreal, brightnessIncrement, 0.1) CONFIG_PROPERTY(bool, ddcutilService, true) CONFIG_PROPERTY(QString, defaultPlayer, QStringLiteral("Spotify")) - CONFIG_PROPERTY(QString, gpuType, QStringLiteral("NVIDIA")) + CONFIG_ENUM_PROPERTY(GpuType, gpuType, GpuType::Auto) CONFIG_PROPERTY(LyricsBackend::Enum, lyricsBackend, LyricsBackend::Auto) CONFIG_PROPERTY(qreal, maxVolume, 1) CONFIG_PROPERTY(qreal, minBrightness, 0.01) diff --git a/Plugins/ZShell/Services/gpu.cpp b/Plugins/ZShell/Services/gpu.cpp index c12d896..81235cb 100644 --- a/Plugins/ZShell/Services/gpu.cpp +++ b/Plugins/ZShell/Services/gpu.cpp @@ -2,97 +2,187 @@ #include "sensorslib.hpp" -#include -#include +#include "config/rootnodes.hpp" +#include "config/services.hpp" + #include #include -#include +#include #include -#include -#include -#include -#include -#include namespace ZShell::services { +using Qt::StringLiterals::operator""_s; + namespace { -constexpr const char* kTypeDetectScript = - "if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi -L >/dev/null " - "2>&1; then echo NVIDIA;" - " elif ls /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | grep " - "-q .; then echo GENERIC;" - " else echo NONE; fi"; +QStringList cardFiles(const QString& relative) { + static const QRegularExpression k_cardRe(u"^card\\d+$"_s); -constexpr const char* kNameDetectScript = - "nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null" - " || glxinfo -B 2>/dev/null | grep 'Device:' | cut -d':' -f2 | cut -d'(' " - "-f1" - " || lspci 2>/dev/null | grep -i 'vga\\|3d controller\\|display' | head -1"; + QStringList files; + QDirIterator it(u"/sys/class/drm"_s, QDir::Dirs | QDir::NoDotAndDotDot); + while (it.hasNext()) { + const QString path = it.next(); + if (!k_cardRe.match(it.fileName()).hasMatch()) continue; + const QString file = path + relative; + if (QFile::exists(file)) files << file; + } + return files; +} + +std::optional readNumber(const QString& path) { + QFile f(path); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return std::nullopt; + bool ok = false; + const qreal v = f.readAll().trimmed().toDouble(&ok); + if (!ok) return std::nullopt; + return v; +} + +QString cleanName(QString s) { + static const QRegularExpression k_noise( + u"\\(R\\)|\\(TM\\)|Graphics"_s, + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression k_spaces(u"\\s+"_s); + s.replace(k_noise, {}); + s.replace(k_spaces, u" "_s); + return s.trimmed(); +} + +QString parseNvidiaName(const QByteArray& out) { + const QString first = + QString::fromUtf8(out).split(u'\n').value(0).trimmed(); + return first.isEmpty() ? QString() : cleanName(first); +} + +QString parseGlxinfoName(const QByteArray& out) { + const QStringList lines = QString::fromUtf8(out).split(u'\n'); + for (const QString& line : lines) { + const qsizetype idx = line.indexOf(u"Device:"_s); + if (idx < 0) { + continue; + } + + QString rest = line.mid(idx + 7); + const qsizetype paren = rest.indexOf(u'('); + if (paren >= 0) { + rest = rest.left(paren); + } + + const QString cleaned = cleanName(rest); + if (!cleaned.isEmpty()) { + return cleaned; + } + } + + return {}; +} + +QString parseLspciName(const QByteArray& out) { + static const QRegularExpression k_lineRe( + u"vga|3d controller|display"_s, + QRegularExpression::CaseInsensitiveOption); + + const QStringList lines = QString::fromUtf8(out).split(u'\n'); + QString match; + for (const QString& line : lines) { + if (k_lineRe.match(line).hasMatch()) { + match = line; + break; + } + } + + if (match.isEmpty()) { + return {}; + } + + static const QRegularExpression k_bracketRe(u"\\[([^\\]]+)\\][^\\[]*$"_s); + const auto bracket = k_bracketRe.match(match); + if (bracket.hasMatch()) { + return cleanName(bracket.captured(1)); + } + + static const QRegularExpression k_colonRe(u":\\s+(.+)"_s); + const auto colon = k_colonRe.match(match); + if (colon.hasMatch()) { + return cleanName(colon.captured(1)); + } + + return {}; +} + +struct NameSource { + QString program; + QStringList args; + QString (*parse)(const QByteArray&); +}; + +const std::array& nameSources() { + static const std::array k_sources = {{ + { + .program = u"nvidia-smi"_s, + .args = {u"--query-gpu=name"_s, u"--format=csv,noheader"_s}, + .parse = &parseNvidiaName, + }, + { + .program = u"glxinfo"_s, + .args = {u"-B"_s}, + .parse = &parseGlxinfoName, + }, + { + .program = u"lspci"_s, + .args = {}, + .parse = &parseLspciName, + }, + }}; + return k_sources; +} + +constexpr int k_nvidiaSource = 0; +constexpr int k_firstGenericSource = 1; } // namespace Gpu::Gpu(QObject* parent) : TickingService(parent) { - QString configPath = - QDir::homePath() + QStringLiteral("/.config/zshell/config.json"); + auto* svc = config::ConfigSingleton::instance()->services(); + m_userType = svc->gpuType(); + QObject::connect(svc, &config::Services::gpuTypeChanged, this, [this, svc] { + const GpuType value = svc->gpuType(); + if (value == m_userType) return; + m_userType = value; + resolveGpu(); + }); - auto reloadConfig = [this, configPath]() { - QFile file(configPath); - if (file.open(QIODevice::ReadOnly)) { - QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); - if (!doc.isNull()) { - QJsonObject services = - doc.object().value("services").toObject(); - setUserType(parseType(services.value("gpuType").toString())); - } - } - }; + resolveGpu(); - reloadConfig(); + m_restartTimer = new QTimer(this); + m_restartTimer->setSingleShot(true); + m_restartTimer->setInterval(500); + QObject::connect(m_restartTimer, &QTimer::timeout, this, [this] { + if (!m_nvidiaProc || m_type != GpuType::Nvidia) return; - static QFileSystemWatcher* watcher = new QFileSystemWatcher(); - if (!watcher->files().contains(configPath)) { - QObject::connect( - watcher, - &QFileSystemWatcher::fileChanged, - this, - [this, configPath]() { - QTimer::singleShot(100, this, [this, configPath]() { - QFile file(configPath); - if (file.open(QIODevice::ReadOnly)) { - QJsonDocument doc = - QJsonDocument::fromJson(file.readAll()); - if (!doc.isNull()) { - QJsonObject services = - doc.object().value("services").toObject(); - setUserType(parseType( - services.value("gpuType").toString())); - } - } - }); - }); - watcher->addPath(configPath); + stopNvidiaUsage(); + startNvidiaUsage(); + }); + + QObject::connect(this, &TickingService::updateIntervalChanged, this, [this] { + if (!m_nvidiaProc) return; + m_restartTimer->start(); + }); +} + +Gpu::~Gpu() { + if (!m_nvidiaProc) return; + m_nvidiaProc->disconnect(this); + m_nvidiaProc->terminate(); + if (!m_nvidiaProc->waitForFinished(500)) { + m_nvidiaProc->kill(); + m_nvidiaProc->waitForFinished(200); } - - // Detection must run before any ServiceRef appears: callers may gate the ref on - // `type !== Gpu.None`, which would otherwise deadlock the detection. - if (m_userType == Auto) { - detectTypeOnce(); - } - detectNameOnce(); } -Gpu::Type Gpu::type() const { - return m_userType == Auto ? m_autoType : m_userType; -} - -Gpu::Type Gpu::userType() const { - return m_userType; -} - -Gpu::Type Gpu::autoType() const { - return m_autoType; +GpuType Gpu::type() const { + return m_type; } QString Gpu::name() const { @@ -115,28 +205,13 @@ qreal Gpu::memoryTotal() const { return m_memoryTotal; } -void Gpu::setUserType(Type value) { - if (value == m_userType) { - return; - } - const Type prevDerived = type(); - m_userType = value; - Q_EMIT userTypeChanged(); - if (type() != prevDerived) { - Q_EMIT typeChanged(); - } -} +void Gpu::setType(GpuType value) { + if (value == m_type) return; -void Gpu::setAutoType(Type value) { - if (value == m_userType) { - return; - } - const Type prevDerived = type(); - m_autoType = value; - Q_EMIT autoTypeChanged(); - if (type() != prevDerived) { - Q_EMIT typeChanged(); - } + m_type = value; + if (m_type != GpuType::Nvidia) stopNvidiaUsage(); + if (m_type == GpuType::None) resetUsage(); + emit typeChanged(); } void Gpu::setName(QString value) { @@ -147,6 +222,12 @@ void Gpu::setName(QString value) { Q_EMIT nameChanged(); } +void Gpu::setDetecting(bool value) { + if (value == m_detecting) return; + m_detecting = value; + emit detectingChanged(); +} + void Gpu::setMemoryUsed(qreal value) { if (std::abs(m_memoryUsed - value) < 0.001) return; m_memoryUsed = value; @@ -160,168 +241,192 @@ void Gpu::setMemoryTotal(qreal value) { } void Gpu::tick() { - const Type t = type(); - if (t == Generic) { + if (m_type == GpuType::Generic) { readGenericUsage(); readGenericMemory(); readGpuTemperature(); - } else if (t == Nvidia) { + } else if (m_type == GpuType::Nvidia) { startNvidiaUsage(); } else { - if (std::abs(m_percentage) > 0.0001) { - m_percentage = 0.0; - Q_EMIT percentageChanged(); - } - if (std::abs(m_temperature) > 0.05) { - m_temperature = 0.0; - Q_EMIT temperatureChanged(); - } + resetUsage(); } } -void Gpu::detectTypeOnce() { - if (m_typeProc) { - return; +void Gpu::refreshSysfsCache() { + m_busyFiles = cardFiles(u"/device/gpu_busy_percent"_s); + + m_vramFiles.clear(); + const QStringList totals = cardFiles(u"/device/mem_info_vram_total"_s); + for (const QString& total : totals) { + QString used = total; + used.replace(u"mem_info_vram_total"_s, u"mem_info_vram_used"_s); + if (QFile::exists(used)) m_vramFiles.append({used, total}); } - m_typeProc = new QProcess(this); - QObject::connect( - m_typeProc, - &QProcess::finished, - this, - [this](int, QProcess::ExitStatus) { - const QByteArray out = - m_typeProc->readAllStandardOutput().trimmed(); - if (!out.isEmpty()) { - setAutoType(parseType(QString::fromLatin1(out))); - } - m_typeProc->deleteLater(); - m_typeProc = nullptr; - }); - m_typeProc->start( - QStringLiteral("sh"), - {QStringLiteral("-c"), QString::fromLatin1(kTypeDetectScript)}); } -void Gpu::detectNameOnce() { - if (m_nameProc) { +void Gpu::resolveGpu() { + refreshSysfsCache(); + const int generation = ++m_generation; + + if (m_userType != GpuType::Auto) setType(m_userType); + + if (m_userType == GpuType::None) { + setName({}); + setDetecting(false); return; } - m_nameProc = new QProcess(this); + + setName({}); + setDetecting(true); + tryNameSource( + m_userType == GpuType::Generic ? k_firstGenericSource : k_nvidiaSource, + generation); +} + +int Gpu::probeEnd() const { + return m_userType == GpuType::Nvidia + ? k_firstGenericSource + : static_cast(nameSources().size()); +} + +void Gpu::tryNameSource(int index, int generation) { + const NameSource& src = nameSources().at(static_cast(index)); + runProcess( + src.program, + src.args, + [this, index, generation, parse = src.parse](const QByteArray& out) { + finishNameSource(index, generation, parse(out)); + }); +} + +void Gpu::finishNameSource(int index, int generation, QString name) { + if (generation != m_generation) { + return; + } + + if (m_userType == GpuType::Auto && index == k_nvidiaSource) { + if (!name.isEmpty()) + setType(GpuType::Nvidia); + else + setType(m_busyFiles.isEmpty() ? GpuType::None : GpuType::Generic); + + if (m_type == GpuType::None) { + setName({}); + setDetecting(false); + return; + } + } + + if (!name.isEmpty()) { + setName(std::move(name)); + setDetecting(false); + return; + } + + const int next = index + 1; + if (next < probeEnd()) { + tryNameSource(next, generation); + } else { + setName({}); + setDetecting(false); + } +} + +void Gpu::runProcess( + const QString& program, + const QStringList& args, + std::function callback) { + auto* proc = new QProcess(this); + proc->setStandardErrorFile(QProcess::nullDevice()); + + const auto finish = + [proc, callback = std::move(callback)](const QByteArray& out) { + callback(out); + proc->deleteLater(); + }; + QObject::connect( - m_nameProc, + proc, &QProcess::finished, this, - [this](int, QProcess::ExitStatus) { - const QString output = - QString::fromUtf8(m_nameProc->readAllStandardOutput()).trimmed(); - if (!output.isEmpty()) { - const QString lower = output.toLower(); - if (lower.contains(QStringLiteral("nvidia")) || - lower.contains(QStringLiteral("geforce")) || - lower.contains(QStringLiteral("rtx")) || - lower.contains(QStringLiteral("gtx")) || - lower.contains(QStringLiteral("rx"))) { - setName(cleanName(output)); - } else { - static const QRegularExpression bracketRe( - QStringLiteral("\\[([^\\]]+)\\][^\\[]*$")); - const auto bracket = bracketRe.match(output); - if (bracket.hasMatch()) { - setName(cleanName(bracket.captured(1))); - } else { - static const QRegularExpression colonRe( - QStringLiteral(":\\s*(.+)")); - const auto colon = colonRe.match(output); - if (colon.hasMatch()) { - setName(cleanName(colon.captured(1))); - } - } - } - } - m_nameProc->deleteLater(); - m_nameProc = nullptr; + [finish, proc](int code, QProcess::ExitStatus status) { + const bool ok = status == QProcess::NormalExit && code == 0; + finish(ok ? proc->readAllStandardOutput() : QByteArray()); }); - m_nameProc->start( - QStringLiteral("sh"), - {QStringLiteral("-c"), QString::fromLatin1(kNameDetectScript)}); + QObject::connect( + proc, + &QProcess::errorOccurred, + this, + [finish](QProcess::ProcessError err) { + if (err == QProcess::FailedToStart) { + finish(QByteArray()); + } + }); + + proc->start(program, args); +} + +void Gpu::onStopped() { + stopNvidiaUsage(); } void Gpu::readGenericMemory() { - const QStringList paths = QDir(QStringLiteral("/sys/class/drm")) - .entryList( - QStringList() << QStringLiteral("card*"), - QDir::Dirs | QDir::NoDotAndDotDot); - - qreal totalMem = 0.0; - qreal usedMem = 0.0; - for (const QString& card : paths) { - QFile total( - QStringLiteral("/sys/class/drm/%1/device/mem_info_vram_total") - .arg(card)); - if (!total.open(QIODevice::ReadOnly | QIODevice::Text)) { - continue; - } - bool ok = false; - const qreal v = total.readAll().trimmed().toDouble(&ok); - total.close(); - if (ok) { - totalMem += v; - } - - QFile used(QStringLiteral("/sys/class/drm/%1/device/mem_info_vram_used") - .arg(card)); - if (!used.open(QIODevice::ReadOnly | QIODevice::Text)) { - continue; - } - bool ok1 = false; - const qreal v1 = used.readAll().trimmed().toDouble(&ok1); - used.close(); - if (ok1) { - usedMem += v1; - } + qreal total = 0.0; + qreal used = 0.0; + for (const VramFiles& v : std::as_const(m_vramFiles)) { + const auto t = readNumber(v.total); + const auto u = readNumber(v.used); + if (!t || !u) continue; + total += *t; + used += *u; } - - setMemoryTotal(totalMem); - setMemoryUsed(usedMem); + setMemoryTotal(total); + setMemoryUsed(used); } void Gpu::readGenericUsage() { - const QStringList paths = QDir(QStringLiteral("/sys/class/drm")) - .entryList( - QStringList() << QStringLiteral("card*"), - QDir::Dirs | QDir::NoDotAndDotDot); qreal sum = 0.0; int count = 0; - for (const QString& card : paths) { - QFile f(QStringLiteral("/sys/class/drm/%1/device/gpu_busy_percent") - .arg(card)); - if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { - continue; - } - bool ok = false; - const qreal v = f.readAll().trimmed().toDouble(&ok); - f.close(); - if (ok) { - sum += v; + for (const QString& path : std::as_const(m_busyFiles)) { + if (const auto v = readNumber(path)) { + sum += *v; ++count; } } const qreal newPerc = count > 0 ? sum / count / 100.0 : 0.0; if (std::abs(newPerc - m_percentage) > 0.0001) { m_percentage = newPerc; - Q_EMIT percentageChanged(); + emit percentageChanged(); } } -void Gpu::startNvidiaUsage() { - if (m_nvidiaProc) { +void Gpu::stopNvidiaUsage() { + m_restartTimer->stop(); + + if (!m_nvidiaProc) return; + + QProcess* proc = m_nvidiaProc; + m_nvidiaProc = nullptr; + + proc->disconnect(this); + if (proc->state() == QProcess::NotRunning) { + proc->deleteLater(); return; } - m_nvidiaProc = new QProcess(this); + QObject::connect(proc, &QProcess::finished, proc, &QObject::deleteLater); + proc->terminate(); +} + +void Gpu::startNvidiaUsage() { + if (m_nvidiaProc) return; + + auto* proc = new QProcess(this); + m_nvidiaProc = proc; + QObject::connect( - m_nvidiaProc, &QProcess::readyReadStandardOutput, this, [this]() { - while (m_nvidiaProc->canReadLine()) { - const QByteArray out = m_nvidiaProc->readLine(); + proc, &QProcess::readyReadStandardOutput, this, [this, proc]() { + while (proc->canReadLine()) { + const QByteArray out = proc->readLine(); const QString output = QString::fromUtf8(out).trimmed(); if (output.isEmpty()) continue; @@ -357,21 +462,21 @@ void Gpu::startNvidiaUsage() { } }); - QObject::connect(m_nvidiaProc, &QProcess::finished, this, [this]() { - m_nvidiaProc->deleteLater(); - m_nvidiaProc = nullptr; - }); + auto cleanup = [this, proc]() { + if (m_nvidiaProc == proc) m_nvidiaProc = nullptr; + + proc->deleteLater(); + }; + + QObject::connect(proc, &QProcess::finished, this, cleanup); QObject::connect( - m_nvidiaProc, + proc, &QProcess::errorOccurred, this, - [this](QProcess::ProcessError) { - m_nvidiaProc->deleteLater(); - m_nvidiaProc = nullptr; - }); + [cleanup](QProcess::ProcessError) { cleanup(); }); - m_nvidiaProc->start( + proc->start( QStringLiteral("nvidia-smi"), {QStringLiteral( "--query-gpu=utilization.gpu,temperature.gpu," @@ -386,32 +491,21 @@ void Gpu::readGpuTemperature() { const qreal newTemp = t.value_or(0.0); if (std::abs(newTemp - m_temperature) > 0.05) { m_temperature = newTemp; - Q_EMIT temperatureChanged(); + emit temperatureChanged(); } } -Gpu::Type Gpu::parseType(const QString& s) { - const QString u = s.trimmed().toUpper(); - if (u.isEmpty()) { - return Auto; +void Gpu::resetUsage() { + if (std::abs(m_percentage) > 0.0001) { + m_percentage = 0.0; + emit percentageChanged(); } - if (u == QStringLiteral("NVIDIA")) { - return Nvidia; + if (std::abs(m_temperature) > 0.05) { + m_temperature = 0.0; + emit temperatureChanged(); } - if (u == QStringLiteral("GENERIC")) { - return Generic; - } - return None; -} - -QString Gpu::cleanName(QString s) { - static const QRegularExpression noise( - QStringLiteral("\\(R\\)|\\(TM\\)|Graphics"), - QRegularExpression::CaseInsensitiveOption); - static const QRegularExpression spaces(QStringLiteral("\\s+")); - s.replace(noise, QString()); - s.replace(spaces, QStringLiteral(" ")); - return s.trimmed(); + setMemoryUsed(0.0); + setMemoryTotal(0.0); } } // namespace ZShell::services diff --git a/Plugins/ZShell/Services/gpu.hpp b/Plugins/ZShell/Services/gpu.hpp index dbe567c..e082d1b 100644 --- a/Plugins/ZShell/Services/gpu.hpp +++ b/Plugins/ZShell/Services/gpu.hpp @@ -1,31 +1,29 @@ #pragma once #include "tickingservice.hpp" +#include "config/enums.hpp" #include #include +#include namespace ZShell::services { +using GpuType = config::GpuType::Enum; + +struct VramFiles { + QString used; + QString total; +}; + class Gpu : public TickingService { Q_OBJECT QML_ELEMENT QML_SINGLETON - public: - enum Type { - Auto, // user override is empty (config "") — defer to detected autoType - None, // no usable GPU - Nvidia, // queried via nvidia-smi - Generic, // queried via /sys/class/drm/card*/device/gpu_busy_percent - }; - Q_ENUM(Type) - - private: - Q_PROPERTY(Type type READ type NOTIFY typeChanged) - Q_PROPERTY(Type userType READ userType NOTIFY userTypeChanged) - Q_PROPERTY(Type autoType READ autoType NOTIFY autoTypeChanged) + Q_PROPERTY(GpuType type READ type NOTIFY typeChanged) Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(bool detecting READ detecting NOTIFY detectingChanged) Q_PROPERTY(qreal percentage READ percentage NOTIFY percentageChanged) Q_PROPERTY(qreal temperature READ temperature NOTIFY temperatureChanged) Q_PROPERTY(qreal memoryUsed READ memoryUsed NOTIFY memoryUsedChanged) @@ -33,11 +31,11 @@ class Gpu : public TickingService { public: explicit Gpu(QObject* parent = nullptr); + ~Gpu() override; - [[nodiscard]] Type type() const; - [[nodiscard]] Type userType() const; - [[nodiscard]] Type autoType() const; + [[nodiscard]] GpuType type() const; [[nodiscard]] QString name() const; + [[nodiscard]] bool detecting() const; [[nodiscard]] qreal percentage() const; [[nodiscard]] qreal temperature() const; [[nodiscard]] qreal memoryUsed() const; @@ -45,9 +43,8 @@ class Gpu : public TickingService { signals: void typeChanged(); - void userTypeChanged(); - void autoTypeChanged(); void nameChanged(); + void detectingChanged(); void percentageChanged(); void temperatureChanged(); void memoryUsedChanged(); @@ -55,35 +52,52 @@ class Gpu : public TickingService { protected: void tick() override; + void onStopped() override; private: - void detectTypeOnce(); - void detectNameOnce(); + void resolveGpu(); + + [[nodiscard]] int probeEnd() const; + + void tryNameSource(int index, int generation); + void finishNameSource(int index, int generation, QString name); + void readGenericUsage(); + void stopNvidiaUsage(); void startNvidiaUsage(); void readGpuTemperature(); void readGenericMemory(); + void resetUsage(); - void setUserType(Type value); - void setAutoType(Type value); + void runProcess( + const QString& program, + const QStringList& args, + std::function callback); + + void setType(GpuType value); void setName(QString value); + void setDetecting(bool value); void setMemoryUsed(qreal value); void setMemoryTotal(qreal value); - [[nodiscard]] static Type parseType(const QString& s); - [[nodiscard]] static QString cleanName(QString s); + void refreshSysfsCache(); - Type m_userType = Auto; - Type m_autoType = None; + GpuType m_userType = GpuType::Auto; + GpuType m_type = GpuType::None; QString m_name; + bool m_detecting = false; qreal m_percentage = 0.0; qreal m_temperature = 0.0; qreal m_memoryUsed = 0.0; qreal m_memoryTotal = 0.0; - QProcess* m_typeProc = nullptr; - QProcess* m_nameProc = nullptr; + QStringList m_busyFiles; + QList m_vramFiles; + int m_generation = 0; + bool m_nvidiaQuerying = false; + QProcess* m_nvidiaProc = nullptr; + QTimer* m_restartTimer = nullptr; }; } // namespace ZShell::services diff --git a/Plugins/ZShell/Services/tickingservice.cpp b/Plugins/ZShell/Services/tickingservice.cpp index 0dc67fa..d6f585e 100644 --- a/Plugins/ZShell/Services/tickingservice.cpp +++ b/Plugins/ZShell/Services/tickingservice.cpp @@ -37,6 +37,7 @@ void TickingService::start() { void TickingService::stop() { m_running = false; m_timer->stop(); + onStopped(); } void TickingService::applyInterval(int ms) { diff --git a/Plugins/ZShell/Services/tickingservice.hpp b/Plugins/ZShell/Services/tickingservice.hpp index 5cd7fe1..87f5676 100644 --- a/Plugins/ZShell/Services/tickingservice.hpp +++ b/Plugins/ZShell/Services/tickingservice.hpp @@ -26,6 +26,7 @@ class TickingService : public Service { void stop() final; virtual void tick() = 0; + virtual void onStopped() {} private: void applyInterval(int ms); From 10d763cfe553f41d76cc567ad23e6b90fc1f8bbe Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 28 Sep 2026 13:11:31 +0200 Subject: [PATCH 19/90] fix: define declared method + insert new bar entries to config file list + rename Hyprsunset -> Nightlight --- Helpers/{Hyprsunset.qml => Nightlight.qml} | 0 Modules/Bar/Bar.qml | 7 +- ...rsunsetWidget.qml => NightlightWidget.qml} | 6 +- Modules/Settings/Pages/WallpaperPage.qml | 6 +- Plugins/ZShell/Config/bar.hpp | 5 +- Plugins/ZShell/Services/gpu.cpp | 4 + Plugins/ZShell/Settings/changebatcher.cpp | 17 +- Plugins/ZShell/Settings/changebatcher.hpp | 18 +- Plugins/ZShell/Settings/codecs.cpp | 471 +++++++------ Plugins/ZShell/Settings/codecs.hpp | 102 +-- Plugins/ZShell/Settings/layerregistry.hpp | 76 ++- Plugins/ZShell/Settings/listnode.cpp | 630 ++++++++++-------- Plugins/ZShell/Settings/listnode.hpp | 103 +-- Plugins/ZShell/Settings/node.cpp | 354 +++++----- Plugins/ZShell/Settings/node.hpp | 142 ++-- Plugins/ZShell/Settings/quarantine.cpp | 16 +- Plugins/ZShell/Settings/quarantine.hpp | 30 +- Plugins/ZShell/Settings/rootnode.cpp | 43 +- Plugins/ZShell/Settings/rootnode.hpp | 35 +- Plugins/ZShell/Settings/schema.cpp | 144 ++-- Plugins/ZShell/Settings/schema.hpp | 88 +-- Plugins/ZShell/Settings/settingsfile.cpp | 277 ++++---- Plugins/ZShell/Settings/settingsfile.hpp | 55 +- 23 files changed, 1445 insertions(+), 1184 deletions(-) rename Helpers/{Hyprsunset.qml => Nightlight.qml} (100%) rename Modules/Bar/Components/{HyprsunsetWidget.qml => NightlightWidget.qml} (84%) diff --git a/Helpers/Hyprsunset.qml b/Helpers/Nightlight.qml similarity index 100% rename from Helpers/Hyprsunset.qml rename to Helpers/Nightlight.qml diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index f0f5fb0..bd4df6f 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -89,10 +89,10 @@ GridLayout { role: "id" DelegateChoice { - roleValue: "hyprsunset" + roleValue: "nightlight" delegate: EntryWrapper { - HyprsunsetWidget { + NightlightWidget { horizontal: root.horizontal visible: !root.fullscreen || root.isHovered } @@ -210,8 +210,7 @@ GridLayout { roleValue: "network" delegate: EntryWrapper { - NetworkWidget { - } + NetworkWidget {} } } diff --git a/Modules/Bar/Components/HyprsunsetWidget.qml b/Modules/Bar/Components/NightlightWidget.qml similarity index 84% rename from Modules/Bar/Components/HyprsunsetWidget.qml rename to Modules/Bar/Components/NightlightWidget.qml index f409a29..f875d5b 100644 --- a/Modules/Bar/Components/HyprsunsetWidget.qml +++ b/Modules/Bar/Components/NightlightWidget.qml @@ -8,15 +8,15 @@ import qs.Services WidgetBase { id: root - property bool tempEnabled: Hyprsunset.enabled + property bool tempEnabled: Nightlight.enabled color: root.tempEnabled ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer radius: Tokens.rounding.full StateLayer { onClicked: { - Hyprsunset.toggleNightLight(); - Hyprsunset.manualToggle = true; + Nightlight.toggleNightLight(); + Nightlight.manualToggle = true; } } diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml index 5e7035c..ee78ddc 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -158,9 +158,9 @@ PageBase { } onAccepted: { - Config.general.color.scheduleHyprsunsetStart = startCurrent; - Config.general.color.scheduleHyprsunsetEnd = endCurrent; - Hyprsunset.checkStartup(); + Config.display.nightlight.scheduleStart = startCurrent; + Config.display.nightlight.scheduleEnd = endCurrent; + Nightlight.checkStartup(); } } } diff --git a/Plugins/ZShell/Config/bar.hpp b/Plugins/ZShell/Config/bar.hpp index 25f4c66..eb08a50 100644 --- a/Plugins/ZShell/Config/bar.hpp +++ b/Plugins/ZShell/Config/bar.hpp @@ -64,14 +64,15 @@ class Bar : public settings::ObjectNode { LIST_ENTRY(spacer, true), LIST_ENTRY(activeWindow, true), LIST_ENTRY(spacer, true), - LIST_ENTRY(hyprsunset, true), + LIST_ENTRY(nightlight, true), LIST_ENTRY(tray, true), LIST_ENTRY(statusIcons, true), LIST_ENTRY(network, false), LIST_ENTRY(upower, true), LIST_ENTRY(clock, true), LIST_ENTRY(notifBell, true), - })) + }), + .identityKey = QStringLiteral("id")) }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Services/gpu.cpp b/Plugins/ZShell/Services/gpu.cpp index 81235cb..25993b7 100644 --- a/Plugins/ZShell/Services/gpu.cpp +++ b/Plugins/ZShell/Services/gpu.cpp @@ -205,6 +205,10 @@ qreal Gpu::memoryTotal() const { return m_memoryTotal; } +bool Gpu::detecting() const { + return m_detecting; +} + void Gpu::setType(GpuType value) { if (value == m_type) return; diff --git a/Plugins/ZShell/Settings/changebatcher.cpp b/Plugins/ZShell/Settings/changebatcher.cpp index ac4b215..30489a0 100644 --- a/Plugins/ZShell/Settings/changebatcher.cpp +++ b/Plugins/ZShell/Settings/changebatcher.cpp @@ -3,23 +3,20 @@ namespace ZShell::settings { ChangeBatcher::ChangeBatcher(QObject* parent) - : QObject(parent) - , m_dirty(false) {} + : QObject(parent), m_dirty(false) {} void ChangeBatcher::dirty() { - if (m_dirty) - return; + if (m_dirty) return; - m_dirty = true; - QMetaObject::invokeMethod(this, &ChangeBatcher::flush, Qt::QueuedConnection); + m_dirty = true; + QMetaObject::invokeMethod(this, &ChangeBatcher::flush, Qt::QueuedConnection); } void ChangeBatcher::flush() { - if (!m_dirty) - return; + if (!m_dirty) return; - m_dirty = false; - emit dirtied(); + m_dirty = false; + emit dirtied(); } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/changebatcher.hpp b/Plugins/ZShell/Settings/changebatcher.hpp index 17ee2b3..ef60e08 100644 --- a/Plugins/ZShell/Settings/changebatcher.hpp +++ b/Plugins/ZShell/Settings/changebatcher.hpp @@ -5,20 +5,20 @@ namespace ZShell::settings { class ChangeBatcher : public QObject { - Q_OBJECT + Q_OBJECT -public: - explicit ChangeBatcher(QObject* parent = nullptr); + public: + explicit ChangeBatcher(QObject* parent = nullptr); - void dirty(); + void dirty(); -signals: - void dirtied(); + signals: + void dirtied(); -private: - bool m_dirty; + private: + bool m_dirty; - void flush(); + void flush(); }; } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/codecs.cpp b/Plugins/ZShell/Settings/codecs.cpp index 478d34c..6e031ff 100644 --- a/Plugins/ZShell/Settings/codecs.cpp +++ b/Plugins/ZShell/Settings/codecs.cpp @@ -16,352 +16,401 @@ namespace { // We don't know the option here so it isn't set, it should be set by the caller DecodeResult error(DiagnosticType::Type type, const QString& message) { - Diagnostic error; - error.type = type; - error.message = message; - return { .value = QVariant(), .error = error, .indexPath = {} }; + Diagnostic error; + error.type = type; + error.message = message; + return {.value = QVariant(), .error = error, .indexPath = {}}; } DecodeResult mismatch(ExpectedType expected, const QJsonValue& value) { - return { .value = QVariant(), .error = Diagnostic::mismatch(expected, value), .indexPath = {} }; + return { + .value = QVariant(), + .error = Diagnostic::mismatch(expected, value), + .indexPath = {}}; } -DecodeResult mismatch(const QList& expected, const QJsonValue& value) { - return { .value = QVariant(), .error = Diagnostic::mismatch(expected, value), .indexPath = {} }; +DecodeResult mismatch( + const QList& expected, const QJsonValue& value) { + return { + .value = QVariant(), + .error = Diagnostic::mismatch(expected, value), + .indexPath = {}}; } template ValueCodec* makeListCodec(const QMetaType& type) { - const auto* elementCodec = ValueCodec::codecFor(QMetaType::fromType()); - return elementCodec ? new ListCodec(type, elementCodec) : nullptr; + const auto* elementCodec = ValueCodec::codecFor( + QMetaType::fromType()); + return elementCodec ? new ListCodec(type, elementCodec) + : nullptr; } // Unions are keyed by their alternatives in order, since order decides which one wins QString unionKey(const QList& types) { - QStringList ids; - ids.reserve(types.size()); - for (const auto& type : types) - ids << QString::number(type.id()); - return ids.join(u","_s); + QStringList ids; + ids.reserve(types.size()); + for (const auto& type : types) + ids << QString::number(type.id()); + return ids.join(u","_s); } -QList expectedTypesOf(const QList& alternatives) { - QList types; - types.reserve(alternatives.size()); - for (const auto* codec : alternatives) - types << codec->expected(); - return types; +QList expectedTypesOf( + const QList& alternatives) { + QList types; + types.reserve(alternatives.size()); + for (const auto* codec : alternatives) + types << codec->expected(); + return types; } using ListFactory = ValueCodec* (*)(const QMetaType&); const QHash& listFactories() { - static const QHash k_factories{ - { QMetaType::fromType().id(), &makeListCodec }, - { QMetaType::fromType>().id(), &makeListCodec> }, - }; - return k_factories; + static const QHash k_factories{ + {QMetaType::fromType().id(), &makeListCodec}, + {QMetaType::fromType>().id(), + &makeListCodec>}, + }; + return k_factories; } } // namespace ValueCodec::ValueCodec(const QMetaType& type, ExpectedType expected) - : m_type(type) - , m_expected(expected) {} + : m_type(type), m_expected(expected) {} QMetaType ValueCodec::type() const { - return m_type; + return m_type; } ExpectedType ValueCodec::expected() const { - return m_expected; + return m_expected; } ValueCodec* ValueCodec::codecFor(const QMetaType& type) { - // Cache for codecs, keyed by type id - static QHash s_registry; + // Cache for codecs, keyed by type id + static QHash s_registry; - // Cached lookup - if (const auto it = s_registry.constFind(type.id()); it != s_registry.constEnd()) - return *it; + // Cached lookup + if (const auto it = s_registry.constFind(type.id()); + it != s_registry.constEnd()) + return *it; - ValueCodec* codec = nullptr; - switch (type.id()) { - case QMetaType::Bool: - codec = new BoolCodec(type); - break; - case QMetaType::Int: - codec = new IntCodec(type); - break; - case QMetaType::Double: - codec = new RealCodec(type); - break; - case QMetaType::QString: - codec = new StringCodec(type); - break; - case QMetaType::QVariantList: - codec = new VariantListCodec(type); - break; - case QMetaType::QVariantMap: - codec = new VariantMapCodec(type); - break; - default: - if (const auto factory = listFactories().constFind(type.id()); factory != listFactories().constEnd()) - codec = (*factory)(type); - else if (util::isSupportedEnum(type)) - codec = new EnumCodec(type, util::metaEnumFor(type)); - break; - } + ValueCodec* codec = nullptr; + switch (type.id()) { + case QMetaType::Bool: + codec = new BoolCodec(type); + break; + case QMetaType::Int: + codec = new IntCodec(type); + break; + case QMetaType::Double: + codec = new RealCodec(type); + break; + case QMetaType::QString: + codec = new StringCodec(type); + break; + case QMetaType::QVariantList: + codec = new VariantListCodec(type); + break; + case QMetaType::QVariantMap: + codec = new VariantMapCodec(type); + break; + default: + if (const auto factory = listFactories().constFind(type.id()); + factory != listFactories().constEnd()) + codec = (*factory)(type); + else if (util::isSupportedEnum(type)) + codec = new EnumCodec(type, util::metaEnumFor(type)); + break; + } - // Cache codec - if (codec) - s_registry.insert(type.id(), codec); + // Cache codec + if (codec) s_registry.insert(type.id(), codec); - return codec; + return codec; } ValueCodec* ValueCodec::unionFor(const QList& types) { - // Cache for union codecs, keyed by their alternatives - static QHash s_registry; + // Cache for union codecs, keyed by their alternatives + static QHash s_registry; - if (types.size() < 2) { - qCCritical(lcSettings, "A union needs at least two types, got %lld", types.size()); - return nullptr; - } + if (types.size() < 2) { + qCCritical( + lcSettings, + "A union needs at least two types, got %lld", + types.size()); + return nullptr; + } - const auto key = unionKey(types); + const auto key = unionKey(types); - // Cached lookup - if (const auto it = s_registry.constFind(key); it != s_registry.constEnd()) - return *it; + // Cached lookup + if (const auto it = s_registry.constFind(key); it != s_registry.constEnd()) + return *it; - QList alternatives; - alternatives.reserve(types.size()); + QList alternatives; + alternatives.reserve(types.size()); - for (const auto& type : types) { - const auto* codec = codecFor(type); - if (!codec) { - qCCritical(lcSettings, "No codec found for type %s, cannot build union", type.name()); - return nullptr; - } - alternatives << codec; - } + for (const auto& type : types) { + const auto* codec = codecFor(type); + if (!codec) { + qCCritical( + lcSettings, + "No codec found for type %s, cannot build union", + type.name()); + return nullptr; + } + alternatives << codec; + } - auto* const codec = new UnionCodec(alternatives); - s_registry.insert(key, codec); + auto* const codec = new UnionCodec(alternatives); + s_registry.insert(key, codec); - return codec; + return codec; } QJsonValue BoolCodec::encode(const QVariant& value) const { - return value.toBool(); + return value.toBool(); } DecodeResult BoolCodec::decode(const QJsonValue& value) const { - // 1 and "true" are not booleans - if (!value.isBool()) - return mismatch(ExpectedType::Bool, value); + // 1 and "true" are not booleans + if (!value.isBool()) return mismatch(ExpectedType::Bool, value); - return { .value = value.toBool(), .error = std::nullopt, .indexPath = {} }; + return {.value = value.toBool(), .error = std::nullopt, .indexPath = {}}; } QJsonValue IntCodec::encode(const QVariant& value) const { - return value.toInt(); + return value.toInt(); } DecodeResult IntCodec::decode(const QJsonValue& value) const { - if (!value.isDouble()) - return mismatch(ExpectedType::Int, value); + if (!value.isDouble()) return mismatch(ExpectedType::Int, value); - const auto num = value.toDouble(); + const auto num = value.toDouble(); - double integral; - if (std::fpclassify(std::modf(num, &integral)) != FP_ZERO) - return error( - DiagnosticType::InvalidValue, QStringLiteral("Expected an integer, got the real %1").arg(QString::number(num))); + double integral; + if (std::fpclassify(std::modf(num, &integral)) != FP_ZERO) + return error( + DiagnosticType::InvalidValue, + QStringLiteral("Expected an integer, got the real %1") + .arg(QString::number(num))); - constexpr auto k_min = std::numeric_limits::min(); - constexpr auto k_max = std::numeric_limits::max(); - if (num < static_cast(k_min) || num > static_cast(k_max)) { - const auto message = QStringLiteral("Integer %1 is out of range, expected between %2 and %3").arg(QString::number(num, 'f', 0), QString::number(k_min), QString::number(k_max)); - return error(DiagnosticType::InvalidValue, message); - } + constexpr auto k_min = std::numeric_limits::min(); + constexpr auto k_max = std::numeric_limits::max(); + if (num < static_cast(k_min) || num > static_cast(k_max)) { + const auto message = + QStringLiteral( + "Integer %1 is out of range, expected between %2 and %3") + .arg( + QString::number(num, 'f', 0), + QString::number(k_min), + QString::number(k_max)); + return error(DiagnosticType::InvalidValue, message); + } - return { .value = static_cast(num), .error = std::nullopt, .indexPath = {} }; + return { + .value = static_cast(num), .error = std::nullopt, .indexPath = {}}; } QJsonValue RealCodec::encode(const QVariant& value) const { - return value.toDouble(); + return value.toDouble(); } DecodeResult RealCodec::decode(const QJsonValue& value) const { - if (!value.isDouble()) - return mismatch(ExpectedType::Real, value); + if (!value.isDouble()) return mismatch(ExpectedType::Real, value); - return { .value = QVariant::fromValue(value.toDouble()), .error = std::nullopt, .indexPath = {} }; + return { + .value = QVariant::fromValue(value.toDouble()), + .error = std::nullopt, + .indexPath = {}}; } QJsonValue StringCodec::encode(const QVariant& value) const { - return value.toString(); + return value.toString(); } DecodeResult StringCodec::decode(const QJsonValue& value) const { - if (!value.isString()) - return mismatch(ExpectedType::String, value); + if (!value.isString()) return mismatch(ExpectedType::String, value); - return { .value = value.toString(), .error = std::nullopt, .indexPath = {} }; + return {.value = value.toString(), .error = std::nullopt, .indexPath = {}}; } QJsonValue VariantListCodec::encode(const QVariant& value) const { - return QJsonArray::fromVariantList(value.toList()); + return QJsonArray::fromVariantList(value.toList()); } DecodeResult VariantListCodec::decode(const QJsonValue& value) const { - if (!value.isArray()) - return mismatch(ExpectedType::Array, value); + if (!value.isArray()) return mismatch(ExpectedType::Array, value); - return { .value = value.toArray().toVariantList(), .error = std::nullopt, .indexPath = {} }; + return { + .value = value.toArray().toVariantList(), + .error = std::nullopt, + .indexPath = {}}; } QJsonValue VariantMapCodec::encode(const QVariant& value) const { - return QJsonObject::fromVariantMap(value.toMap()); + return QJsonObject::fromVariantMap(value.toMap()); } DecodeResult VariantMapCodec::decode(const QJsonValue& value) const { - if (!value.isObject()) - return mismatch(ExpectedType::Object, value); + if (!value.isObject()) return mismatch(ExpectedType::Object, value); - return { .value = value.toObject().toVariantMap(), .error = std::nullopt, .indexPath = {} }; + return { + .value = value.toObject().toVariantMap(), + .error = std::nullopt, + .indexPath = {}}; } EnumCodec::EnumCodec(const QMetaType& type, const QMetaEnum& metaEnum) - : ValueCodec(type, ExpectedType::String) - , m_metaEnum(metaEnum) {} + : ValueCodec(type, ExpectedType::String), m_metaEnum(metaEnum) {} QJsonValue EnumCodec::encode(const QVariant& value) const { - const auto* key = util::enumKeyFor(m_metaEnum, value); - if (!key) { - qCWarning( - lcSettings, "Cannot encode value %lld of enum %s, no such enumerator", value.toLongLong(), m_type.name()); - return {}; - } + const auto* key = util::enumKeyFor(m_metaEnum, value); + if (!key) { + qCWarning( + lcSettings, + "Cannot encode value %lld of enum %s, no such enumerator", + value.toLongLong(), + m_type.name()); + return {}; + } - return QString::fromUtf8(key); + return QString::fromUtf8(key); } DecodeResult EnumCodec::decode(const QJsonValue& value) const { - if (!value.isString()) - return mismatch(ExpectedType::String, value); + if (!value.isString()) return mismatch(ExpectedType::String, value); - const auto key = value.toString(); + const auto key = value.toString(); - for (int i = 0; i < m_metaEnum.keyCount(); ++i) { - if (QString::compare(QString::fromUtf8(m_metaEnum.key(i)), key, Qt::CaseInsensitive) != 0) - continue; + for (int i = 0; i < m_metaEnum.keyCount(); ++i) { + if (QString::compare( + QString::fromUtf8(m_metaEnum.key(i)), + key, + Qt::CaseInsensitive) != 0) + continue; - const auto raw = m_metaEnum.value(i); - QVariant decoded(m_type); - if (!QMetaType::convert(QMetaType::fromType(), &raw, m_type, decoded.data())) { - qCCritical(lcSettings, "Failed to convert value %d to enum %s", raw, m_type.name()); - return error(DiagnosticType::InvalidValue, - QStringLiteral("Could not convert %1 to %2").arg(key, QString::fromUtf8(m_type.name()))); - } + const auto raw = m_metaEnum.value(i); + QVariant decoded(m_type); + if (!QMetaType::convert( + QMetaType::fromType(), &raw, m_type, decoded.data())) { + qCCritical( + lcSettings, + "Failed to convert value %d to enum %s", + raw, + m_type.name()); + return error( + DiagnosticType::InvalidValue, + QStringLiteral("Could not convert %1 to %2") + .arg(key, QString::fromUtf8(m_type.name()))); + } - return { .value = decoded, .error = std::nullopt, .indexPath = {} }; - } + return {.value = decoded, .error = std::nullopt, .indexPath = {}}; + } - QStringList options; - options.reserve(m_metaEnum.keyCount()); - for (int i = 0; i < m_metaEnum.keyCount(); ++i) - options << QString::fromUtf8(m_metaEnum.key(i)); + QStringList options; + options.reserve(m_metaEnum.keyCount()); + for (int i = 0; i < m_metaEnum.keyCount(); ++i) + options << QString::fromUtf8(m_metaEnum.key(i)); - return error(DiagnosticType::InvalidValue, - QStringLiteral("Invalid enum value %1. Expected one of %2").arg(key, options.join(u", "_s))); + return error( + DiagnosticType::InvalidValue, + QStringLiteral("Invalid enum value %1. Expected one of %2") + .arg(key, options.join(u", "_s))); } template -ListCodec::ListCodec(const QMetaType& type, const ValueCodec* elementCodec) - : ValueCodec(type, ExpectedType::Array) - , m_elementCodec(elementCodec) {} +ListCodec::ListCodec( + const QMetaType& type, const ValueCodec* elementCodec) + : ValueCodec(type, ExpectedType::Array), m_elementCodec(elementCodec) {} -template QJsonValue ListCodec::encode(const QVariant& value) const { - QJsonArray array; - const auto list = value.value(); - for (const auto& element : list) - array.append(m_elementCodec->encode(QVariant::fromValue(element))); - return array; +template +QJsonValue ListCodec::encode(const QVariant& value) const { + QJsonArray array; + const auto list = value.value(); + for (const auto& element : list) + array.append(m_elementCodec->encode(QVariant::fromValue(element))); + return array; } -template DecodeResult ListCodec::decode(const QJsonValue& value) const { - if (!value.isArray()) - return mismatch(ExpectedType::Array, value); +template +DecodeResult ListCodec::decode(const QJsonValue& value) const { + if (!value.isArray()) return mismatch(ExpectedType::Array, value); - const auto array = value.toArray(); - Container list; - list.reserve(array.size()); + const auto array = value.toArray(); + Container list; + list.reserve(array.size()); - for (qsizetype i = 0; i < array.size(); ++i) { - auto result = m_elementCodec->decode(array.at(i)); + for (qsizetype i = 0; i < array.size(); ++i) { + auto result = m_elementCodec->decode(array.at(i)); - // Reject the entire list if any element is invalid - if (result.error) { - result.indexPath.prepend(i); - return result; - } + // Reject the entire list if any element is invalid + if (result.error) { + result.indexPath.prepend(i); + return result; + } - list.append(result.value.value()); - } + list.append(result.value.value()); + } - return { .value = QVariant::fromValue(list), .error = std::nullopt, .indexPath = {} }; + return { + .value = QVariant::fromValue(list), + .error = std::nullopt, + .indexPath = {}}; } UnionCodec::UnionCodec(const QList& alternatives) - : ValueCodec(QMetaType::fromType(), alternatives.first()->expected()) - , m_alternatives(alternatives) - , m_expectedTypes(expectedTypesOf(alternatives)) { - m_byType.reserve(alternatives.size()); + : ValueCodec( + QMetaType::fromType(), alternatives.first()->expected()) + , m_alternatives(alternatives) + , m_expectedTypes(expectedTypesOf(alternatives)) { + m_byType.reserve(alternatives.size()); - for (const auto* codec : alternatives) - m_byType.insert(codec->type().id(), codec); + for (const auto* codec : alternatives) + m_byType.insert(codec->type().id(), codec); } QJsonValue UnionCodec::encode(const QVariant& value) const { - // An unset union is simply absent from the file - if (!value.isValid()) - return QJsonValue::Undefined; + // An unset union is simply absent from the file + if (!value.isValid()) return QJsonValue::Undefined; - const auto* codec = m_byType.value(value.metaType().id()); - if (!codec) { - qCWarning(lcSettings, "Cannot encode value of type %s, it is not one of the allowed types", value.typeName()); - return QJsonValue::Undefined; - } + const auto* codec = m_byType.value(value.metaType().id()); + if (!codec) { + qCWarning( + lcSettings, + "Cannot encode value of type %s, it is not one of the allowed " + "types", + value.typeName()); + return QJsonValue::Undefined; + } - return codec->encode(value); + return codec->encode(value); } DecodeResult UnionCodec::decode(const QJsonValue& value) const { - std::optional best; + std::optional best; - for (const auto* codec : m_alternatives) { - auto result = codec->decode(value); + for (const auto* codec : m_alternatives) { + auto result = codec->decode(value); - // The first alternative to accept the value wins - if (!result.error) - return result; + // The first alternative to accept the value wins + if (!result.error) return result; - // Non-nested type mismatch just means try another alternative - if (result.indexPath.isEmpty() && result.error->type == DiagnosticType::TypeMismatch) - continue; + // Non-nested type mismatch just means try another alternative + if (result.indexPath.isEmpty() && + result.error->type == DiagnosticType::TypeMismatch) + continue; - // Report the deepest error that isn't a type mismatch - if (!best || result.indexPath.size() > best->indexPath.size()) - best = std::move(result); - } + // Report the deepest error that isn't a type mismatch + if (!best || result.indexPath.size() > best->indexPath.size()) + best = std::move(result); + } - if (best) - return *best; + if (best) return *best; - // Nothing matched the shape, so report every alternative - return mismatch(m_expectedTypes, value); + // Nothing matched the shape, so report every alternative + return mismatch(m_expectedTypes, value); } // Instantiated for types as needed diff --git a/Plugins/ZShell/Settings/codecs.hpp b/Plugins/ZShell/Settings/codecs.hpp index 2bbf2b2..f86cfe3 100644 --- a/Plugins/ZShell/Settings/codecs.hpp +++ b/Plugins/ZShell/Settings/codecs.hpp @@ -13,43 +13,46 @@ namespace ZShell::settings { struct DecodeResult { - QVariant value; - std::optional error; - QList indexPath; // Index path of the failing element, outermost list first + QVariant value; + std::optional error; + QList + indexPath; // Index path of the failing element, outermost list first }; class ValueCodec { -public: - explicit ValueCodec(const QMetaType& type, ExpectedType expected); - virtual ~ValueCodec() = default; + public: + explicit ValueCodec(const QMetaType& type, ExpectedType expected); + virtual ~ValueCodec() = default; - // Returns the shared codec for a type, or nullptr if the type is unsupported - static ValueCodec* codecFor(const QMetaType& type); + // Returns the shared codec for a type, or nullptr if the type is unsupported + static ValueCodec* codecFor(const QMetaType& type); - // Returns the shared codec for a union of types, or nullptr if any of them is unsupported - static ValueCodec* unionFor(const QList& types); + // Returns the shared codec for a union of types, or nullptr if any of them is unsupported + static ValueCodec* unionFor(const QList& types); - [[nodiscard]] QMetaType type() const; - [[nodiscard]] ExpectedType expected() const; // The JSON type this decodes from - [[nodiscard]] virtual QJsonValue encode(const QVariant& value) const = 0; - [[nodiscard]] virtual DecodeResult decode(const QJsonValue& value) const = 0; + [[nodiscard]] QMetaType type() const; + [[nodiscard]] ExpectedType expected() + const; // The JSON type this decodes from + [[nodiscard]] virtual QJsonValue encode(const QVariant& value) const = 0; + [[nodiscard]] virtual DecodeResult decode(const QJsonValue& value) const = 0; -protected: - const QMetaType m_type; - const ExpectedType m_expected; + protected: + const QMetaType m_type; + const ExpectedType m_expected; - Q_DISABLE_COPY_MOVE(ValueCodec) + Q_DISABLE_COPY_MOVE(ValueCodec) }; -#define CODEC(Type, Expected) \ - class Type##Codec : public ValueCodec { \ - public: \ - explicit Type##Codec(const QMetaType& type) \ - : ValueCodec(type, ExpectedType::Expected) {} \ - \ - [[nodiscard]] QJsonValue encode(const QVariant& value) const override; \ - [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; \ - }; +#define CODEC(Type, Expected) \ + class Type##Codec : public ValueCodec { \ + public: \ + explicit Type##Codec(const QMetaType& type) \ + : ValueCodec(type, ExpectedType::Expected) {} \ + \ + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; \ + [[nodiscard]] DecodeResult decode( \ + const QJsonValue& value) const override; \ + }; CODEC(Bool, Bool) CODEC(Int, Int) @@ -61,41 +64,44 @@ CODEC(VariantMap, Object) #undef CODEC class EnumCodec : public ValueCodec { -public: - explicit EnumCodec(const QMetaType& type, const QMetaEnum& metaEnum); + public: + explicit EnumCodec(const QMetaType& type, const QMetaEnum& metaEnum); - [[nodiscard]] QJsonValue encode(const QVariant& value) const override; - [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; -private: - const QMetaEnum m_metaEnum; + private: + const QMetaEnum m_metaEnum; }; template class ListCodec : public ValueCodec { - using Value = Container::value_type; + using Value = Container::value_type; -public: - explicit ListCodec(const QMetaType& type, const ValueCodec* elementCodec); + public: + explicit ListCodec(const QMetaType& type, const ValueCodec* elementCodec); - [[nodiscard]] QJsonValue encode(const QVariant& value) const override; - [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; -private: - const ValueCodec* m_elementCodec; + private: + const ValueCodec* m_elementCodec; }; // Decodes any one of several types, for options that accept more than one shape class UnionCodec : public ValueCodec { -public: - explicit UnionCodec(const QList& alternatives); + public: + explicit UnionCodec(const QList& alternatives); - [[nodiscard]] QJsonValue encode(const QVariant& value) const override; - [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; + [[nodiscard]] QJsonValue encode(const QVariant& value) const override; + [[nodiscard]] DecodeResult decode(const QJsonValue& value) const override; -private: - const QList m_alternatives; // Tried in order, so the first to accept a value wins - const QList m_expectedTypes; // Types of the alternatives, for diagnostics - QHash m_byType; // Type id to alternative, for encoding + private: + const QList + m_alternatives; // Tried in order, so the first to accept a value wins + const QList + m_expectedTypes; // Types of the alternatives, for diagnostics + QHash + m_byType; // Type id to alternative, for encoding }; } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/layerregistry.hpp b/Plugins/ZShell/Settings/layerregistry.hpp index a41d6b1..4a7cd44 100644 --- a/Plugins/ZShell/Settings/layerregistry.hpp +++ b/Plugins/ZShell/Settings/layerregistry.hpp @@ -10,66 +10,72 @@ namespace ZShell::settings { // Derived from Node and has ctor(path, fallback, parent) template -concept LayerType = std::derived_from && std::constructible_from; +concept LayerType = std::derived_from && + std::constructible_from; template class LayerRegistry { -public: - explicit LayerRegistry(const QString& prefix, const QString& suffix, QObject* parent); + public: + explicit LayerRegistry( + const QString& prefix, const QString& suffix, QObject* parent); - [[nodiscard]] QString pathFor(const QString& name) const; - [[nodiscard]] QString nameFor(T* layer) const; - [[nodiscard]] T* get(const QString& name, T* fallback, bool* created = nullptr); // Created on demand + [[nodiscard]] QString pathFor(const QString& name) const; + [[nodiscard]] QString nameFor(T* layer) const; + [[nodiscard]] T* get( + const QString& name, + T* fallback, + bool* created = nullptr); // Created on demand -private: - const QString m_prefix; - const QString m_suffix; - QObject* const m_parent; - QHash m_layers; + private: + const QString m_prefix; + const QString m_suffix; + QObject* const m_parent; + QHash m_layers; }; namespace detail { inline QString stripTrailingSlashes(QStringView str) { - while (str.endsWith(u'/')) - str = str.left(str.length() - 1); - return str.toString(); + while (str.endsWith(u'/')) + str = str.left(str.length() - 1); + return str.toString(); } inline QString stripLeadingSlashes(QStringView str) { - while (str.startsWith(u'/')) - str = str.mid(1); - return str.toString(); + while (str.startsWith(u'/')) + str = str.mid(1); + return str.toString(); } } // namespace detail template -LayerRegistry::LayerRegistry(const QString& prefix, const QString& suffix, QObject* parent) - : m_prefix(detail::stripTrailingSlashes(prefix)) - , m_suffix(detail::stripLeadingSlashes(suffix)) - , m_parent(parent) {} +LayerRegistry::LayerRegistry( + const QString& prefix, const QString& suffix, QObject* parent) + : m_prefix(detail::stripTrailingSlashes(prefix)) + , m_suffix(detail::stripLeadingSlashes(suffix)) + , m_parent(parent) {} -template QString LayerRegistry::pathFor(const QString& name) const { - return m_prefix + u'/' + name + u'/' + m_suffix; +template +QString LayerRegistry::pathFor(const QString& name) const { + return m_prefix + u'/' + name + u'/' + m_suffix; } template QString LayerRegistry::nameFor(T* layer) const { - return m_layers.key(layer); + return m_layers.key(layer); } -template T* LayerRegistry::get(const QString& name, T* fallback, bool* created) { - if (auto* const layer = m_layers.value(name)) { - if (created) - *created = false; - return layer; - } +template +T* LayerRegistry::get(const QString& name, T* fallback, bool* created) { + if (auto* const layer = m_layers.value(name)) { + if (created) *created = false; + return layer; + } - if (created) - *created = true; + if (created) *created = true; - auto* const layer = new T(pathFor(name), fallback, m_parent); - m_layers.insert(name, layer); - return layer; + auto* const layer = new T(pathFor(name), fallback, m_parent); + m_layers.insert(name, layer); + return layer; } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/listnode.cpp b/Plugins/ZShell/Settings/listnode.cpp index f1cd4ea..cf27302 100644 --- a/Plugins/ZShell/Settings/listnode.cpp +++ b/Plugins/ZShell/Settings/listnode.cpp @@ -11,435 +11,525 @@ using Qt::StringLiterals::operator""_s; namespace { QString valuesKey() { - return u"values"_s; + return u"values"_s; } void deleteNode(Node* node) { - node->detachFallback(); - node->setParent(nullptr); - node->deleteLater(); + node->detachFallback(); + node->setParent(nullptr); + node->deleteLater(); } bool isVariantListType(const QVariant& value) { - return value.typeId() == QMetaType::QVariantList || value.typeId() == qMetaTypeId>(); + return value.typeId() == QMetaType::QVariantList || + value.typeId() == qMetaTypeId>(); } QList toMaps(const QVariant& value) { - if (value.typeId() == qMetaTypeId>()) - return value.value>(); + if (value.typeId() == qMetaTypeId>()) + return value.value>(); - const auto list = value.toList(); - QList maps; - maps.reserve(list.count()); + const auto list = value.toList(); + QList maps; + maps.reserve(list.count()); - for (const auto& el : list) { - if (el.typeId() != QMetaType::QVariantMap) { - qCCritical(lcSettings, "Expected a map in list properties, got %s.", el.typeName()); - continue; - } + for (const auto& el : list) { + if (el.typeId() != QMetaType::QVariantMap) { + qCCritical( + lcSettings, + "Expected a map in list properties, got %s.", + el.typeName()); + continue; + } - maps << el.toMap(); - } + maps << el.toMap(); + } - return maps; + return maps; } void syncNodeRecursive(Node* node, const QVariantMap& props) { - const auto& schema = node->schema(); + const auto& schema = node->schema(); - for (const auto& [key, val] : props.asKeyValueRange()) { - const auto* desc = schema.get(key); + for (const auto& [key, val] : props.asKeyValueRange()) { + const auto* desc = schema.get(key); - if (!desc) { - qCCritical(lcSettings, "Unknown key %s in props for %s, something is wrong.", qUtf8Printable(key), - qUtf8Printable(node->path())); - continue; - } + if (!desc) { + qCCritical( + lcSettings, + "Unknown key %s in props for %s, something is wrong.", + qUtf8Printable(key), + qUtf8Printable(node->path())); + continue; + } - // `setValue` warns on type mismatches itself - if (!desc->isNode) { - node->setValue(key, val); - continue; - } + // `setValue` warns on type mismatches itself + if (!desc->isNode) { + node->setValue(key, val); + continue; + } - auto* const child = node->value(key).value(); + auto* const child = node->value(key).value(); - // Nested lists take their elements straight from props - if (auto* const list = qobject_cast(child)) { - if (!isVariantListType(val)) { - qCCritical(lcSettings, "Expected a list for node key %s on %s, got %s.", qUtf8Printable(key), - qUtf8Printable(node->path()), val.typeName()); - continue; - } + // Nested lists take their elements straight from props + if (auto* const list = qobject_cast(child)) { + if (!isVariantListType(val)) { + qCCritical( + lcSettings, + "Expected a list for node key %s on %s, got %s.", + qUtf8Printable(key), + qUtf8Printable(node->path()), + val.typeName()); + continue; + } - list->setValue(valuesKey(), val); - continue; - } + list->setValue(valuesKey(), val); + continue; + } - if (val.typeId() != QMetaType::QVariantMap) { - qCCritical(lcSettings, "Expected a map for node key %s on %s, got %s.", qUtf8Printable(key), - qUtf8Printable(node->path()), val.typeName()); - continue; - } + if (val.typeId() != QMetaType::QVariantMap) { + qCCritical( + lcSettings, + "Expected a map for node key %s on %s, got %s.", + qUtf8Printable(key), + qUtf8Printable(node->path()), + val.typeName()); + continue; + } - syncNodeRecursive(child, val.toMap()); - } + syncNodeRecursive(child, val.toMap()); + } } } // namespace ListNode::ListNode(ListNode* fallback, QObject* parent, bool globalOnly) - : Node(fallback, parent, globalOnly) { - if (fallback) { - // Disconnect generic fallback notify, lists use a custom one - QObject::disconnect(fallback, &ListNode::optionChanged, this, nullptr); + : Node(fallback, parent, globalOnly) { + if (fallback) { + // Disconnect generic fallback notify, lists use a custom one + QObject::disconnect(fallback, &ListNode::optionChanged, this, nullptr); - if (!m_globalOnly) - QObject::connect(fallback, &ListNode::elementsChanged, this, &ListNode::onFallbackListNotify); - } + if (!m_globalOnly) + QObject::connect( + fallback, + &ListNode::elementsChanged, + this, + &ListNode::onFallbackListNotify); + } } qsizetype ListNode::count() const { - return m_elements.count(); + return m_elements.count(); } QVariantList ListNode::values() const { - QVariantList list; - list.reserve(m_elements.count()); - for (auto* const element : m_elements) - list << QVariant::fromValue(element); - return list; + QVariantList list; + list.reserve(m_elements.count()); + for (auto* const element : m_elements) + list << QVariant::fromValue(element); + return list; } void ListNode::remove(qsizetype index) { - if (rejectGlobalMutation()) - return; + if (rejectGlobalMutation()) return; - const WriteScope scope(this, WriteOrigin::Qml); + const WriteScope scope(this, WriteOrigin::Qml); - if (!validIndex(index)) { - qCWarning(lcSettings, "Attempted to remove invalid index %lld from list node %s, ignoring.", index, - qUtf8Printable(path())); + if (!validIndex(index)) { + qCWarning( + lcSettings, + "Attempted to remove invalid index %lld from list node %s, " + "ignoring.", + index, + qUtf8Printable(path())); - recordWrite(valuesKey(), false); - return; - } + recordWrite(valuesKey(), false); + return; + } - deleteNode(m_elements.takeAt(index)); + deleteNode(m_elements.takeAt(index)); - recordWrite(valuesKey(), true); - emit countChanged(); - emit elementsChanged({}, { index }, {}); + recordWrite(valuesKey(), true); + emit countChanged(); + emit elementsChanged({}, {index}, {}); } void ListNode::move(qsizetype from, qsizetype to) { - if (rejectGlobalMutation()) - return; + if (rejectGlobalMutation()) return; - const WriteScope scope(this, WriteOrigin::Qml); + const WriteScope scope(this, WriteOrigin::Qml); - const auto fromInvalid = !validIndex(from); - if (fromInvalid || !validIndex(to)) { - if (fromInvalid) - qCWarning(lcSettings, "Attempted to move invalid index %lld to index %lld on list node %s, ignoring.", from, - to, qUtf8Printable(path())); - else - qCWarning(lcSettings, "Attempted to move index %lld to invalid index %lld on list node %s, ignoring.", from, - to, qUtf8Printable(path())); + const auto fromInvalid = !validIndex(from); + if (fromInvalid || !validIndex(to)) { + if (fromInvalid) + qCWarning( + lcSettings, + "Attempted to move invalid index %lld to index %lld on list " + "node %s, ignoring.", + from, + to, + qUtf8Printable(path())); + else + qCWarning( + lcSettings, + "Attempted to move index %lld to invalid index %lld on list " + "node %s, ignoring.", + from, + to, + qUtf8Printable(path())); - recordWrite(valuesKey(), false); - return; - } + recordWrite(valuesKey(), false); + return; + } - m_elements.move(from, to); + m_elements.move(from, to); - recordWrite(valuesKey(), true); - emit elementsChanged({}, {}, { { from, to } }); + recordWrite(valuesKey(), true); + emit elementsChanged({}, {}, {{from, to}}); } void ListNode::clear() { - if (rejectGlobalMutation()) - return; + if (rejectGlobalMutation()) return; - const WriteScope scope(this, WriteOrigin::Qml); + const WriteScope scope(this, WriteOrigin::Qml); - if (m_elements.isEmpty()) { - recordWrite(valuesKey(), false); - return; - } + if (m_elements.isEmpty()) { + recordWrite(valuesKey(), false); + return; + } - const auto len = m_elements.count(); + const auto len = m_elements.count(); - for (auto* const element : std::as_const(m_elements)) - deleteNode(element); - m_elements.clear(); + for (auto* const element : std::as_const(m_elements)) + deleteNode(element); + m_elements.clear(); - recordWrite(valuesKey(), true); - emit countChanged(); + recordWrite(valuesKey(), true); + emit countChanged(); - NodeChanges removed(len); - std::iota(removed.begin(), removed.end(), 0); - emit elementsChanged({}, removed, {}); + NodeChanges removed(len); + std::iota(removed.begin(), removed.end(), 0); + emit elementsChanged({}, removed, {}); } QString ListNode::pathFor(const QString& key) const { - return elementPath(path(), key); + return elementPath(path(), key); } const Schema& ListNode::schema() const { - // Offset +1 so count is not registered (allow read only cause values is read only) - static const auto k_schema = Schema::build(&staticMetaObject, Node::staticMetaObject.propertyCount() + 1, true); - return k_schema; + // Offset +1 so count is not registered (allow read only cause values is read only) + static const auto k_schema = Schema::build( + &staticMetaObject, Node::staticMetaObject.propertyCount() + 1, true); + return k_schema; } QVariant ListNode::value(const QString& key) const { - if (key != valuesKey()) { - qCCritical(lcSettings, - "Attempted to read %s on list node %s. List nodes only have a 'values' key, something is wrong.", - qUtf8Printable(key), qUtf8Printable(path())); - return {}; - } + if (key != valuesKey()) { + qCCritical( + lcSettings, + "Attempted to read %s on list node %s. List nodes only have a " + "'values' key, something is wrong.", + qUtf8Printable(key), + qUtf8Printable(path())); + return {}; + } - return QVariant::fromValue(m_elements); + return QVariant::fromValue(m_elements); } bool ListNode::setValue(const QString& key, const QVariant& value) { - return setValue(key, value, nullptr); + return setValue(key, value, nullptr); } -bool ListNode::setValue(const QString& key, const QVariant& value, QList* diagnostics) { - if (key != valuesKey()) { - qCCritical(lcSettings, - "Attempted to write %s on list node %s. List nodes only have a 'values' key, something is wrong.", - qUtf8Printable(key), qUtf8Printable(path())); - return false; - } +bool ListNode::setValue( + const QString& key, const QVariant& value, QList* diagnostics) { + if (key != valuesKey()) { + qCCritical( + lcSettings, + "Attempted to write %s on list node %s. List nodes only have a " + "'values' key, something is wrong.", + qUtf8Printable(key), + qUtf8Printable(path())); + return false; + } - const auto len = m_elements.count(); + const auto len = m_elements.count(); - for (auto* const element : std::as_const(m_elements)) - deleteNode(element); - m_elements.clear(); + for (auto* const element : std::as_const(m_elements)) + deleteNode(element); + m_elements.clear(); - if (value.typeId() == qMetaTypeId>()) { - // On reset to fallback - const auto nodes = value.value>(); - for (auto* const node : nodes) - insertNode(m_elements.count(), node); - } else if (isVariantListType(value)) { - // From defaults or QML - const auto list = toMaps(value); - for (qsizetype i = 0; i < list.count(); ++i) - insertNode(i, list.at(i), fallbackFor(i)); - } else if (value.typeId() == QMetaType::QJsonArray) { - // From syncJson - const auto array = value.toJsonArray(); - for (const auto& json : array) - insertNode(m_elements.count(), json.toObject(), *diagnostics); - } else { - qCCritical(lcSettings, "Unexpected type %s for list node %s, something is wrong.", value.typeName(), - qUtf8Printable(path())); - return false; - } + if (value.typeId() == qMetaTypeId>()) { + // On reset to fallback + const auto nodes = value.value>(); + for (auto* const node : nodes) + insertNode(m_elements.count(), node); + } else if (isVariantListType(value)) { + // From defaults or QML + const auto list = toMaps(value); + for (qsizetype i = 0; i < list.count(); ++i) + insertNode(i, list.at(i), fallbackFor(i)); + } else if (value.typeId() == QMetaType::QJsonArray) { + // From syncJson + const auto array = value.toJsonArray(); + for (const auto& json : array) + insertNode(m_elements.count(), json.toObject(), *diagnostics); + insertMissingDefaults(); + } else { + qCCritical( + lcSettings, + "Unexpected type %s for list node %s, something is wrong.", + value.typeName(), + qUtf8Printable(path())); + return false; + } - if (!recordWrite(valuesKey(), true)) - return false; + if (!recordWrite(valuesKey(), true)) return false; - if (len != m_elements.count()) - emit countChanged(); + if (len != m_elements.count()) emit countChanged(); - NodeChanges added(m_elements.count()); - NodeChanges removed(len); - std::iota(added.begin(), added.end(), 0); - std::iota(removed.begin(), removed.end(), 0); - emit elementsChanged(added, removed, {}); + NodeChanges added(m_elements.count()); + NodeChanges removed(len); + std::iota(added.begin(), added.end(), 0); + std::iota(removed.begin(), removed.end(), 0); + emit elementsChanged(added, removed, {}); - return true; + return true; +} + +void ListNode::insertMissingDefaults() { + if (fallbackNode()) return; + + const auto* desc = getDescriptor(); + if (!desc) return; + + const auto idKey = desc->identityKey(); + if (idKey.isEmpty()) return; + + if (!m_elements.isEmpty() && !m_elements.first()->schema().get(idKey)) { + qCCritical( + lcSettings, + "List %s has identityKey '%s' which its elements don't define.", + qUtf8Printable(path()), + qUtf8Printable(idKey)); + return; + } + + QHash> unmatched; + for (auto* const element : std::as_const(m_elements)) + unmatched[element->value(idKey).toString()] << element; + + Node* anchor = nullptr; + for (const auto& props : defaultValue()) { + const auto id = props.value(idKey).toString(); + if (id.isEmpty()) continue; + + auto& candidates = unmatched[id]; + if (!candidates.isEmpty()) { + anchor = candidates.takeFirst(); + continue; + } + + const auto index = anchor ? m_elements.indexOf(anchor) + 1 : 0; + anchor = insertNode(index, props, nullptr); + } } void ListNode::resetToDefaults() { - if (isNested()) { - qCCritical(lcSettings, "List node %s has a parent list node, resetToDefaults should never be called on it.", - qUtf8Printable(path())); - return; - } + if (isNested()) { + qCCritical( + lcSettings, + "List node %s has a parent list node, resetToDefaults should never " + "be called on it.", + qUtf8Printable(path())); + return; + } - // Don't reset global only list nodes on overlays - if (fallbackNode() && m_globalOnly) - return; + // Don't reset global only list nodes on overlays + if (fallbackNode() && m_globalOnly) return; - const WriteScope scope(this, WriteOrigin::FileReset); - setValue(valuesKey(), fallbackNode() ? fallbackNode()->value(valuesKey()) : QVariant::fromValue(defaultValue())); + const WriteScope scope(this, WriteOrigin::FileReset); + setValue( + valuesKey(), + fallbackNode() ? fallbackNode()->value(valuesKey()) + : QVariant::fromValue(defaultValue())); } QJsonValue ListNode::toJson(bool sparse) const { - Q_UNUSED(sparse) // Lists can't be sparse + Q_UNUSED(sparse) // Lists can't be sparse - QJsonArray array; - for (auto* const element : m_elements) - array << element->toJson(false); - return array; + QJsonArray array; + for (auto* const element : m_elements) + array << element->toJson(false); + return array; } bool ListNode::syncJson(const QJsonValue& json, QList& diagnostics) { - if (!json.isArray()) { - const auto d = Diagnostic::mismatch(ExpectedType::Array, json, path()); - qCWarning(lcSettings, "Error decoding option %s: %s", qUtf8Printable(d.option), - qUtf8Printable(d.message)); - diagnostics << d; - return false; - } + if (!json.isArray()) { + const auto d = Diagnostic::mismatch(ExpectedType::Array, json, path()); + qCWarning( + lcSettings, + "Error decoding option %s: %s", + qUtf8Printable(d.option), + qUtf8Printable(d.message)); + diagnostics << d; + return false; + } - // Refuse syncs to global only list nodes on overlays - if (rejectGlobalSync(diagnostics)) - return false; + // Refuse syncs to global only list nodes on overlays + if (rejectGlobalSync(diagnostics)) return false; - const WriteScope scope(this, WriteOrigin::File); - setValue(valuesKey(), json.toArray(), &diagnostics); + const WriteScope scope(this, WriteOrigin::File); + setValue(valuesKey(), json.toArray(), &diagnostics); - return true; + return true; } bool ListNode::recordWrite(const QString& key, bool changed) { - const auto wasOverride = isOverride(key); - const auto notify = Node::recordWrite(key, changed); + const auto wasOverride = isOverride(key); + const auto notify = Node::recordWrite(key, changed); - // Elements inside overridden lists have no fallbacks, so detach here - if (!wasOverride && isOverride(key)) - for (auto* const element : std::as_const(m_elements)) - element->detachFallback(); + // Elements inside overridden lists have no fallbacks, so detach here + if (!wasOverride && isOverride(key)) + for (auto* const element : std::as_const(m_elements)) + element->detachFallback(); - return notify; + return notify; } QString ListNode::keyOf(const Node* child) const { - return QString::number(m_elements.indexOf(const_cast(child))); + return QString::number(m_elements.indexOf(const_cast(child))); } Node* ListNode::elementAt(qsizetype index) const { - if (!validIndex(index)) - return nullptr; - return m_elements.at(index); + if (!validIndex(index)) return nullptr; + return m_elements.at(index); } Node* ListNode::insertElement(const QVariantMap& props, qsizetype index) { - if (rejectGlobalMutation()) - return nullptr; + if (rejectGlobalMutation()) return nullptr; - const WriteScope scope(this, WriteOrigin::Qml); + const WriteScope scope(this, WriteOrigin::Qml); - if (!validIndex(index)) // Invalid index means append - index = m_elements.count(); + if (!validIndex(index)) // Invalid index means append + index = m_elements.count(); - auto* const element = insertNode(index, props, nullptr); + auto* const element = insertNode(index, props, nullptr); - recordWrite(valuesKey(), true); - emit countChanged(); - emit elementsChanged({ index }, {}, {}); + recordWrite(valuesKey(), true); + emit countChanged(); + emit elementsChanged({index}, {}, {}); - return element; + return element; } QList ListNode::defaultValue() const { - const auto* desc = getDescriptor(); - if (!desc) - return {}; - return desc->defaultValue().value>(); + const auto* desc = getDescriptor(); + if (!desc) return {}; + return desc->defaultValue().value>(); } bool ListNode::isNested() const { - return qobject_cast(parentNode()); + return qobject_cast(parentNode()); } bool ListNode::rejectGlobalMutation() const { - if (!m_globalOnly || !fallbackNode()) - return false; + if (!m_globalOnly || !fallbackNode()) return false; - qCWarning(lcSettings, - "Attempted to mutate global list %s from an overlay layer, ignoring. " - "This should not be used, mutate global lists from the global layer instead.", - qUtf8Printable(path())); + qCWarning( + lcSettings, + "Attempted to mutate global list %s from an overlay layer, ignoring. " + "This should not be used, mutate global lists from the global layer " + "instead.", + qUtf8Printable(path())); - return true; + return true; } bool ListNode::validIndex(qsizetype index) const { - return index >= 0 && index < m_elements.count(); + return index >= 0 && index < m_elements.count(); } const Descriptor* ListNode::getDescriptor() const { - if (!parentNode()) { // List nodes should not be root nodes - qCCritical(lcSettings, "List node %s has no parent, something is wrong.", qUtf8Printable(path())); - return nullptr; - } + if (!parentNode()) { // List nodes should not be root nodes + qCCritical( + lcSettings, + "List node %s has no parent, something is wrong.", + qUtf8Printable(path())); + return nullptr; + } - const auto* desc = parentNode()->schema().get(key()); + const auto* desc = parentNode()->schema().get(key()); - if (!desc) { - qCCritical(lcSettings, "List node %s is not in parent schema, something is wrong.", qUtf8Printable(path())); - return nullptr; - } + if (!desc) { + qCCritical( + lcSettings, + "List node %s is not in parent schema, something is wrong.", + qUtf8Printable(path())); + return nullptr; + } - return desc; + return desc; } Node* ListNode::fallbackFor(qsizetype index) const { - if (isOverride(valuesKey())) - return nullptr; + if (isOverride(valuesKey())) return nullptr; - const auto* fallback = static_cast(fallbackNode()); - if (!fallback || !fallback->validIndex(index)) - return nullptr; + const auto* fallback = static_cast(fallbackNode()); + if (!fallback || !fallback->validIndex(index)) return nullptr; - return fallback->m_elements.at(index); + return fallback->m_elements.at(index); } -Node* ListNode::insertNode(qsizetype index, const QVariantMap& props, Node* fallback) { - auto* const node = createElement(fallback); - m_elements.insert(index, node); +Node* ListNode::insertNode( + qsizetype index, const QVariantMap& props, Node* fallback) { + auto* const node = createElement(fallback); + m_elements.insert(index, node); - const WriteScope scope(node, WriteOrigin::Init); - syncNodeRecursive(node, props); + const WriteScope scope(node, WriteOrigin::Init); + syncNodeRecursive(node, props); - return node; + return node; } Node* ListNode::insertNode(qsizetype index, Node* node) { - auto* const copy = createElement(node); - m_elements.insert(index, copy); - return copy; + auto* const copy = createElement(node); + m_elements.insert(index, copy); + return copy; } -Node* ListNode::insertNode(qsizetype index, const QJsonObject& json, QList& diagnostics) { - // Write origin will be file/file reset, which is correct. - // This function should only be called from the JSON path of setValue. - auto* const node = createElement(nullptr); // Nodes synced from JSON get no fallback as they are overrides - m_elements.insert(index, node); +Node* ListNode::insertNode( + qsizetype index, const QJsonObject& json, QList& diagnostics) { + // Write origin will be file/file reset, which is correct. + // This function should only be called from the JSON path of setValue. + auto* const node = createElement( + nullptr); // Nodes synced from JSON get no fallback as they are overrides + m_elements.insert(index, node); - node->syncJson(json, diagnostics); + node->syncJson(json, diagnostics); - return node; + return node; } -void ListNode::onFallbackListNotify(const NodeChanges& added, const NodeChanges& removed, const MoveChanges& moved) { - if (isOverride(valuesKey())) - return; +void ListNode::onFallbackListNotify( + const NodeChanges& added, + const NodeChanges& removed, + const MoveChanges& moved) { + if (isOverride(valuesKey())) return; - const WriteScope scope(this, WriteOrigin::Layer); + const WriteScope scope(this, WriteOrigin::Layer); - for (const auto index : removed | std::views::reverse) - deleteNode(m_elements.takeAt(index)); - for (const auto index : added) - insertNode(index, fallbackFor(index)); - for (const auto& move : moved) - m_elements.move(move.first, move.second); + for (const auto index : removed | std::views::reverse) + deleteNode(m_elements.takeAt(index)); + for (const auto index : added) + insertNode(index, fallbackFor(index)); + for (const auto& move : moved) + m_elements.move(move.first, move.second); - if (!recordWrite(valuesKey(), true)) - return; + if (!recordWrite(valuesKey(), true)) return; - if (!added.isEmpty() || !removed.isEmpty()) - emit countChanged(); - emit elementsChanged(added, removed, moved); + if (!added.isEmpty() || !removed.isEmpty()) emit countChanged(); + emit elementsChanged(added, removed, moved); } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/listnode.hpp b/Plugins/ZShell/Settings/listnode.hpp index e6b51dc..2fa2260 100644 --- a/Plugins/ZShell/Settings/listnode.hpp +++ b/Plugins/ZShell/Settings/listnode.hpp @@ -5,65 +5,82 @@ namespace ZShell::settings { class ListNode : public Node { - Q_OBJECT - QML_ANONYMOUS + Q_OBJECT + QML_ANONYMOUS - Q_PROPERTY(qsizetype count READ count NOTIFY countChanged) - Q_PROPERTY(QVariantList values READ values NOTIFY elementsChanged) + Q_PROPERTY(qsizetype count READ count NOTIFY countChanged) + Q_PROPERTY(QVariantList values READ values NOTIFY elementsChanged) - using NodeChanges = QList; - using MoveChanges = QList>; + using NodeChanges = QList; + using MoveChanges = QList>; -public: - explicit ListNode(ListNode* fallback, QObject* parent = nullptr, bool globalOnly = false); + public: + explicit ListNode( + ListNode* fallback, QObject* parent = nullptr, bool globalOnly = false); - [[nodiscard]] qsizetype count() const; - [[nodiscard]] QVariantList values() const; + [[nodiscard]] qsizetype count() const; + [[nodiscard]] QVariantList values() const; - Q_INVOKABLE void remove(qsizetype index); - Q_INVOKABLE void move(qsizetype from, qsizetype to); - Q_INVOKABLE void clear(); + Q_INVOKABLE void remove(qsizetype index); + Q_INVOKABLE void move(qsizetype from, qsizetype to); + Q_INVOKABLE void clear(); - [[nodiscard]] QString pathFor(const QString& key) const override; - [[nodiscard]] const Schema& schema() const override; - [[nodiscard]] QVariant value(const QString& key) const override; - bool setValue(const QString& key, const QVariant& value) override; - bool setValue(const QString& key, const QVariant& value, QList* diagnostics); - void resetToDefaults() override; + [[nodiscard]] QString pathFor(const QString& key) const override; + [[nodiscard]] const Schema& schema() const override; + [[nodiscard]] QVariant value(const QString& key) const override; + bool setValue(const QString& key, const QVariant& value) override; + bool setValue( + const QString& key, + const QVariant& value, + QList* diagnostics); + void resetToDefaults() override; - [[nodiscard]] QJsonValue toJson(bool sparse = true) const override; - bool syncJson(const QJsonValue& json, QList& diagnostics) override; + [[nodiscard]] QJsonValue toJson(bool sparse = true) const override; + bool syncJson( + const QJsonValue& json, QList& diagnostics) override; -signals: - void countChanged(); - void elementsChanged(const NodeChanges& added, const NodeChanges& removed, const MoveChanges& moved); + signals: + void countChanged(); + void elementsChanged( + const NodeChanges& added, + const NodeChanges& removed, + const MoveChanges& moved); -protected: - bool recordWrite(const QString& key, bool changed) override; - [[nodiscard]] QString keyOf(const Node* child) const override; + protected: + bool recordWrite(const QString& key, bool changed) override; + [[nodiscard]] QString keyOf(const Node* child) const override; - [[nodiscard]] Node* elementAt(qsizetype index) const; - [[nodiscard]] Node* insertElement(const QVariantMap& props, qsizetype index = -1); - [[nodiscard]] virtual Node* createElement(Node* fallback) = 0; + [[nodiscard]] Node* elementAt(qsizetype index) const; + [[nodiscard]] Node* insertElement( + const QVariantMap& props, qsizetype index = -1); + [[nodiscard]] virtual Node* createElement(Node* fallback) = 0; -private: - QList m_elements; + private: + QList m_elements; - [[nodiscard]] QList defaultValue() const; - [[nodiscard]] bool isNested() const; + [[nodiscard]] QList defaultValue() const; + [[nodiscard]] bool isNested() const; - // Returns true if the mutation should be skipped, overlays cannot mutate global lists - [[nodiscard]] bool rejectGlobalMutation() const; + // Returns true if the mutation should be skipped, overlays cannot mutate global lists + [[nodiscard]] bool rejectGlobalMutation() const; - [[nodiscard]] bool validIndex(qsizetype index) const; - [[nodiscard]] const Descriptor* getDescriptor() const; - [[nodiscard]] Node* fallbackFor(qsizetype index) const; + [[nodiscard]] bool validIndex(qsizetype index) const; + [[nodiscard]] const Descriptor* getDescriptor() const; + [[nodiscard]] Node* fallbackFor(qsizetype index) const; - Node* insertNode(qsizetype index, const QVariantMap& props, Node* fallback); - Node* insertNode(qsizetype index, Node* node); - Node* insertNode(qsizetype index, const QJsonObject& json, QList& diagnostics); + void insertMissingDefaults(); - void onFallbackListNotify(const NodeChanges& added, const NodeChanges& removed, const MoveChanges& moved); + Node* insertNode(qsizetype index, const QVariantMap& props, Node* fallback); + Node* insertNode(qsizetype index, Node* node); + Node* insertNode( + qsizetype index, + const QJsonObject& json, + QList& diagnostics); + + void onFallbackListNotify( + const NodeChanges& added, + const NodeChanges& removed, + const MoveChanges& moved); }; } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/node.cpp b/Plugins/ZShell/Settings/node.cpp index 9bacd19..035a0f3 100644 --- a/Plugins/ZShell/Settings/node.cpp +++ b/Plugins/ZShell/Settings/node.cpp @@ -6,275 +6,303 @@ namespace ZShell::settings { using Qt::StringLiterals::operator""_s; Node::Node(Node* fallback, QObject* parent, bool globalOnly) - : QObject(parent) - , m_globalOnly(globalOnly || (parentNode() && parentNode()->m_globalOnly)) - , m_rootNode(parentNode() ? parentNode()->rootNode() : this) - , m_fallbackNode(fallback) - , m_writeOrigin(WriteOrigin::Qml) - , m_internalRead(false) - , m_batcher(m_rootNode == this ? new ChangeBatcher(this) : nullptr) { - if (fallback && !m_globalOnly) - QObject::connect(fallback, &Node::optionChanged, this, &Node::onFallbackNotify); + : QObject(parent) + , m_globalOnly(globalOnly || (parentNode() && parentNode()->m_globalOnly)) + , m_rootNode(parentNode() ? parentNode()->rootNode() : this) + , m_fallbackNode(fallback) + , m_writeOrigin(WriteOrigin::Qml) + , m_internalRead(false) + , m_batcher(m_rootNode == this ? new ChangeBatcher(this) : nullptr) { + if (fallback && !m_globalOnly) + QObject::connect( + fallback, &Node::optionChanged, this, &Node::onFallbackNotify); } QString Node::key() const { - return parentNode() ? parentNode()->keyOf(this) : QString(); + return parentNode() ? parentNode()->keyOf(this) : QString(); } QString Node::path() const { - return parentNode() ? parentNode()->pathFor(key()) : key(); + return parentNode() ? parentNode()->pathFor(key()) : key(); } QString Node::pathFor(const QString& key) const { - const auto p = path(); - return p.isEmpty() ? key : p + u'.' + key; + const auto p = path(); + return p.isEmpty() ? key : p + u'.' + key; } QString Node::elementPath(const QString& path, const QString& index) { - return path + u'[' + index + u']'; + return path + u'[' + index + u']'; } Node* Node::parentNode() const { - return qobject_cast(parent()); + return qobject_cast(parent()); } Node* Node::rootNode() const { - return m_rootNode; + return m_rootNode; } Node* Node::fallbackNode() const { - return m_fallbackNode; + return m_fallbackNode; } void Node::detachFallback() { - if (m_fallbackNode) { - QObject::disconnect(m_fallbackNode, nullptr, this, nullptr); - m_fallbackNode = nullptr; - } + if (m_fallbackNode) { + QObject::disconnect(m_fallbackNode, nullptr, this, nullptr); + m_fallbackNode = nullptr; + } - const auto childNodes = findChildren(Qt::FindDirectChildrenOnly); - for (auto* const child : childNodes) - child->detachFallback(); + const auto childNodes = findChildren(Qt::FindDirectChildrenOnly); + for (auto* const child : childNodes) + child->detachFallback(); } bool Node::isGlobalOnly() const { - return m_globalOnly; + return m_globalOnly; } bool Node::isOverride(const QString& key) const { - return m_overrides.contains(key); + return m_overrides.contains(key); } const QSet& Node::overrides() const { - return m_overrides; + return m_overrides; } bool Node::hasContent() const { - return !m_overrides.isEmpty() || m_quarantine || - std::ranges::any_of(findChildren(Qt::FindDirectChildrenOnly), &Node::hasContent); + return !m_overrides.isEmpty() || m_quarantine || + std::ranges::any_of( + findChildren(Qt::FindDirectChildrenOnly), + &Node::hasContent); } QVariant Node::value(const QString& key) const { - const auto* desc = schema().get(key); - if (!desc) { - qCCritical( - lcSettings, "Attempted to read an unknown key %s, something is wrong.", qUtf8Printable(pathFor(key))); - return {}; - } + const auto* desc = schema().get(key); + if (!desc) { + qCCritical( + lcSettings, + "Attempted to read an unknown key %s, something is wrong.", + qUtf8Printable(pathFor(key))); + return {}; + } - // Generated getters warn on global reads, this silences them as the warning is only for QML reads - const InternalRead guard(m_rootNode); - return metaObject()->property(desc->metaIndex).read(this); + // Generated getters warn on global reads, this silences them as the warning is only for QML reads + const InternalRead guard(m_rootNode); + return metaObject()->property(desc->metaIndex).read(this); } bool Node::setValue(const QString& key, const QVariant& value) { - const auto* desc = schema().get(key); - if (!desc) { - qCCritical(lcSettings, "Attempted to set an unknown key %s, something is wrong.", qUtf8Printable(pathFor(key))); - return false; - } + const auto* desc = schema().get(key); + if (!desc) { + qCCritical( + lcSettings, + "Attempted to set an unknown key %s, something is wrong.", + qUtf8Printable(pathFor(key))); + return false; + } - if (desc->isNode) { - qCCritical(lcSettings, "Attempted to set node %s directly, something is wrong.", qUtf8Printable(pathFor(key))); - return false; - } + if (desc->isNode) { + qCCritical( + lcSettings, + "Attempted to set node %s directly, something is wrong.", + qUtf8Printable(pathFor(key))); + return false; + } - // Type mismatch, conversion should happen before this function is called - if (rejectInvalidWrite(key, value)) - return false; + // Type mismatch, conversion should happen before this function is called + if (rejectInvalidWrite(key, value)) return false; - return metaObject()->property(desc->metaIndex).write(this, value); + return metaObject()->property(desc->metaIndex).write(this, value); } void Node::resetToDefaults() { - // Don't reset global only nodes on overlays, the whole subtree belongs to the global layer - if (m_globalOnly && m_fallbackNode) - return; + // Don't reset global only nodes on overlays, the whole subtree belongs to the global layer + if (m_globalOnly && m_fallbackNode) return; - m_quarantine.reset(); // Reset quarantine as well + m_quarantine.reset(); // Reset quarantine as well - // No write scope, callers should create the scope - for (const auto& desc : schema().descriptors()) { - if (desc.isNode) - value(desc.key).value()->resetToDefaults(); - else if (!desc.globalOnly() || !m_fallbackNode) // Skip resetting global options on overlays - setValue(desc.key, m_fallbackNode ? m_fallbackNode->value(desc.key) : desc.defaultValue()); - } + // No write scope, callers should create the scope + for (const auto& desc : schema().descriptors()) { + if (desc.isNode) + value(desc.key).value()->resetToDefaults(); + else if ( + !desc.globalOnly() || + !m_fallbackNode) // Skip resetting global options on overlays + setValue( + desc.key, + m_fallbackNode ? m_fallbackNode->value(desc.key) + : desc.defaultValue()); + } } const Quarantine* Node::quarantine() const { - return m_quarantine.get(); + return m_quarantine.get(); } bool Node::rejectInvalidWrite(const QString& key, const QVariant& value) const { - const auto* desc = schema().get(key); - if (!desc || desc->accepts(value.metaType())) - return false; + const auto* desc = schema().get(key); + if (!desc || desc->accepts(value.metaType())) return false; - qCWarning(lcSettings, "Type mismatch for %s, expected %s got %s", qUtf8Printable(pathFor(key)), - qUtf8Printable(desc->typeString()), value.metaType().name()); - return true; + qCWarning( + lcSettings, + "Type mismatch for %s, expected %s got %s", + qUtf8Printable(pathFor(key)), + qUtf8Printable(desc->typeString()), + value.metaType().name()); + return true; } void Node::warnGlobalRead(const QString& key) const { - if (!m_fallbackNode || m_rootNode->m_internalRead) - return; + if (!m_fallbackNode || m_rootNode->m_internalRead) return; - qCWarning(lcSettings, - "Global option %s was read from an overlay layer. " - "This should not be used, read global options from the global layer instead.", - qUtf8Printable(pathFor(key))); + qCWarning( + lcSettings, + "Global option %s was read from an overlay layer. " + "This should not be used, read global options from the global layer " + "instead.", + qUtf8Printable(pathFor(key))); } bool Node::rejectGlobalWrite(const QString& key) { - const auto* desc = schema().get(key); - if (!desc) { - qCCritical(lcSettings, "Attempted to check a write for an unknown key %s, something is seriously wrong...", - qUtf8Printable(pathFor(key))); - return false; - } + const auto* desc = schema().get(key); + if (!desc) { + qCCritical( + lcSettings, + "Attempted to check a write for an unknown key %s, something is " + "seriously wrong...", + qUtf8Printable(pathFor(key))); + return false; + } - const auto origin = m_rootNode->m_writeOrigin; - const auto fromUser = origin == WriteOrigin::Qml || origin == WriteOrigin::QmlReset; + const auto origin = m_rootNode->m_writeOrigin; + const auto fromUser = origin == WriteOrigin::Qml || + origin == WriteOrigin::QmlReset; - if ((!m_globalOnly && !desc->globalOnly()) || !fromUser || !m_fallbackNode) - return false; + if ((!m_globalOnly && !desc->globalOnly()) || !fromUser || !m_fallbackNode) + return false; - if (origin == WriteOrigin::QmlReset) - qCWarning(lcSettings, - "Attempted to reset global option %s from an overlay layer, ignoring. " - "This should not be used, reset global options from the global layer instead.", - qUtf8Printable(pathFor(key))); - else - qCWarning(lcSettings, - "Attempted to write global option %s from an overlay layer, ignoring. " - "This should not be used, write global options from the global layer instead.", - qUtf8Printable(pathFor(key))); + if (origin == WriteOrigin::QmlReset) + qCWarning( + lcSettings, + "Attempted to reset global option %s from an overlay layer, " + "ignoring. " + "This should not be used, reset global options from the global " + "layer instead.", + qUtf8Printable(pathFor(key))); + else + qCWarning( + lcSettings, + "Attempted to write global option %s from an overlay layer, " + "ignoring. " + "This should not be used, write global options from the global " + "layer instead.", + qUtf8Printable(pathFor(key))); - return true; + return true; } void Node::warnGlobalSync(QList& diagnostics, const QString& path) { - qCWarning(lcSettings, "Global option definition %s found in overlay file, ignoring.", qUtf8Printable(path)); - diagnostics << Diagnostic{ - .type = DiagnosticType::GlobalOption, - .option = path, - .message = QStringLiteral("Global options should not be defined in overlay files"), - }; + qCWarning( + lcSettings, + "Global option definition %s found in overlay file, ignoring.", + qUtf8Printable(path)); + diagnostics << Diagnostic{ + .type = DiagnosticType::GlobalOption, + .option = path, + .message = QStringLiteral( + "Global options should not be defined in overlay files"), + }; } bool Node::rejectGlobalSync(QList& diagnostics) const { - if (!m_globalOnly || !m_fallbackNode) - return false; + if (!m_globalOnly || !m_fallbackNode) return false; - warnGlobalSync(diagnostics, path()); - return true; + warnGlobalSync(diagnostics, path()); + return true; } bool Node::recordWrite(const QString& key, bool changed) { - const auto* desc = schema().get(key); - if (!desc) { - qCCritical(lcSettings, "Attempted to record a write for an unknown key %s, something is seriously wrong...", - qUtf8Printable(pathFor(key))); - return false; - } + const auto* desc = schema().get(key); + if (!desc) { + qCCritical( + lcSettings, + "Attempted to record a write for an unknown key %s, something is " + "seriously wrong...", + qUtf8Printable(pathFor(key))); + return false; + } - const auto origin = m_rootNode->m_writeOrigin; - const auto fromUser = origin == WriteOrigin::Qml || origin == WriteOrigin::QmlReset; + const auto origin = m_rootNode->m_writeOrigin; + const auto fromUser = origin == WriteOrigin::Qml || + origin == WriteOrigin::QmlReset; - bool dirty = changed; - switch (origin) { - // Init does not notify or write to file - case WriteOrigin::Init: - return false; + bool dirty = changed; + switch (origin) { + // Init does not notify or write to file + case WriteOrigin::Init: + return false; - // File and qml both count as overrides - case WriteOrigin::File: - case WriteOrigin::Qml: - dirty |= !m_overrides.contains(key); - m_overrides << key; - break; + // File and qml both count as overrides + case WriteOrigin::File: + case WriteOrigin::Qml: + dirty |= !m_overrides.contains(key); + m_overrides << key; + break; - // Layer is not an override, it is a sync with the fallback value - case WriteOrigin::Layer: - break; + // Layer is not an override, it is a sync with the fallback value + case WriteOrigin::Layer: + break; - // Both resets clear the override - case WriteOrigin::FileReset: - case WriteOrigin::QmlReset: - dirty |= m_overrides.remove(key); - break; - } + // Both resets clear the override + case WriteOrigin::FileReset: + case WriteOrigin::QmlReset: + dirty |= m_overrides.remove(key); + break; + } - // User writes/reset override quarantine - if (fromUser) - dirty |= removeQuarantined(key); + // User writes/reset override quarantine + if (fromUser) dirty |= removeQuarantined(key); - // Both qml and reset write to the file (only write if dirty) - if (fromUser && dirty) - m_rootNode->m_batcher->dirty(); + // Both qml and reset write to the file (only write if dirty) + if (fromUser && dirty) m_rootNode->m_batcher->dirty(); - if (changed) - emit optionChanged(key); + if (changed) emit optionChanged(key); - return changed; + return changed; } bool Node::removeQuarantined(const QString& key) { - if (!m_quarantine) - return false; + if (!m_quarantine) return false; - const auto removed = m_quarantine->remove(key); - if (m_quarantine->isEmpty()) - m_quarantine.reset(); + const auto removed = m_quarantine->remove(key); + if (m_quarantine->isEmpty()) m_quarantine.reset(); - return removed; + return removed; } ChangeBatcher* Node::batcher() const { - return m_rootNode->m_batcher; + return m_rootNode->m_batcher; } QString Node::keyOf(const Node* child) const { - for (const auto& desc : schema().descriptors()) { - if (desc.isNode && child == value(desc.key).value()) - return desc.key; - } + for (const auto& desc : schema().descriptors()) { + if (desc.isNode && child == value(desc.key).value()) + return desc.key; + } - return {}; + return {}; } void Node::onFallbackNotify(const QString& key) { - if (m_overrides.contains(key)) - return; + if (m_overrides.contains(key)) return; - // Don't mirror global options onto overlays - const auto* desc = schema().get(key); - if (desc && desc->globalOnly()) - return; + // Don't mirror global options onto overlays + const auto* desc = schema().get(key); + if (desc && desc->globalOnly()) return; - const WriteScope scope(this, WriteOrigin::Layer); - setValue(key, m_fallbackNode->value(key)); + const WriteScope scope(this, WriteOrigin::Layer); + setValue(key, m_fallbackNode->value(key)); } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/node.hpp b/Plugins/ZShell/Settings/node.hpp index 23237a8..9ed05b8 100644 --- a/Plugins/ZShell/Settings/node.hpp +++ b/Plugins/ZShell/Settings/node.hpp @@ -12,89 +12,105 @@ namespace ZShell::settings { class Node : public QObject { - Q_OBJECT + Q_OBJECT -public: - // Global only nodes are inherited, anything inside one is also global only - explicit Node(Node* fallback, QObject* parent = nullptr, bool globalOnly = false); + public: + // Global only nodes are inherited, anything inside one is also global only + explicit Node( + Node* fallback, QObject* parent = nullptr, bool globalOnly = false); - [[nodiscard]] QString key() const; // The key of this in the parent node - [[nodiscard]] QString path() const; - [[nodiscard]] virtual QString pathFor(const QString& key) const; - [[nodiscard]] static QString elementPath(const QString& path, const QString& index); - [[nodiscard]] Node* parentNode() const; - [[nodiscard]] Node* rootNode() const; - [[nodiscard]] Node* fallbackNode() const; - void detachFallback(); // Recursive, unlinks this and its children from the fallback layer + [[nodiscard]] QString key() const; // The key of this in the parent node + [[nodiscard]] QString path() const; + [[nodiscard]] virtual QString pathFor(const QString& key) const; + [[nodiscard]] static QString elementPath( + const QString& path, const QString& index); + [[nodiscard]] Node* parentNode() const; + [[nodiscard]] Node* rootNode() const; + [[nodiscard]] Node* fallbackNode() const; + void + detachFallback(); // Recursive, unlinks this and its children from the fallback layer - [[nodiscard]] Q_INVOKABLE bool isGlobalOnly() const; - [[nodiscard]] bool isOverride(const QString& key) const; - [[nodiscard]] const QSet& overrides() const; - [[nodiscard]] bool hasContent() const; // Recursive + [[nodiscard]] Q_INVOKABLE bool isGlobalOnly() const; + [[nodiscard]] bool isOverride(const QString& key) const; + [[nodiscard]] const QSet& overrides() const; + [[nodiscard]] bool hasContent() const; // Recursive - [[nodiscard]] virtual const Schema& schema() const = 0; + [[nodiscard]] virtual const Schema& schema() const = 0; - [[nodiscard]] virtual QVariant value(const QString& key) const; - virtual bool setValue(const QString& key, const QVariant& value); // Returns whether the write was successful or not - virtual void resetToDefaults(); // Recursive, resets to fallbacks then defaults if not overridden + [[nodiscard]] virtual QVariant value(const QString& key) const; + virtual bool setValue( + const QString& key, + const QVariant& value); // Returns whether the write was successful or not + virtual void + resetToDefaults(); // Recursive, resets to fallbacks then defaults if not overridden - [[nodiscard]] virtual QJsonValue toJson(bool sparse = true) const = 0; - // Returns false if the entire node was rejected - virtual bool syncJson(const QJsonValue& json, QList& diagnostics) = 0; - [[nodiscard]] const Quarantine* quarantine() const; + [[nodiscard]] virtual QJsonValue toJson(bool sparse = true) const = 0; + // Returns false if the entire node was rejected + virtual bool syncJson( + const QJsonValue& json, QList& diagnostics) = 0; + [[nodiscard]] const Quarantine* quarantine() const; -signals: - void optionChanged(const QString& key); + signals: + void optionChanged(const QString& key); -protected: - // Null means empty, otherwise it has content - std::unique_ptr m_quarantine; - const bool m_globalOnly; // Own flag or inherited from the parent node + protected: + // Null means empty, otherwise it has content + std::unique_ptr m_quarantine; + const bool m_globalOnly; // Own flag or inherited from the parent node - void warnGlobalRead(const QString& key) const; - // Returns true if the write should be skipped afterwards, the value is not one of the allowed types - [[nodiscard]] bool rejectInvalidWrite(const QString& key, const QVariant& value) const; - template [[nodiscard]] bool rejectInvalidWrite(const QString& key, const T& value) const; - // Returns true if the write should be skipped afterwards, overlays cannot write global options - bool rejectGlobalWrite(const QString& key); - static void warnGlobalSync(QList& diagnostics, const QString& path); - bool rejectGlobalSync(QList& diagnostics) const; // Returns true if the sync should be rejected - // Returns true if the notify signal should be emitted - virtual bool recordWrite(const QString& key, bool changed); + void warnGlobalRead(const QString& key) const; + // Returns true if the write should be skipped afterwards, the value is not one of the allowed types + [[nodiscard]] bool rejectInvalidWrite( + const QString& key, const QVariant& value) const; + template + [[nodiscard]] bool rejectInvalidWrite( + const QString& key, const T& value) const; + // Returns true if the write should be skipped afterwards, overlays cannot write global options + bool rejectGlobalWrite(const QString& key); + static void warnGlobalSync( + QList& diagnostics, const QString& path); + bool rejectGlobalSync(QList& diagnostics) + const; // Returns true if the sync should be rejected + // Returns true if the notify signal should be emitted + virtual bool recordWrite(const QString& key, bool changed); - [[nodiscard]] bool removeQuarantined(const QString& key); - [[nodiscard]] ChangeBatcher* batcher() const; + [[nodiscard]] bool removeQuarantined(const QString& key); + [[nodiscard]] ChangeBatcher* batcher() const; - [[nodiscard]] virtual QString keyOf(const Node* child) const; + [[nodiscard]] virtual QString keyOf(const Node* child) const; - template - [[nodiscard]] T fallbackValue(T C::* member, std::type_identity_t defaultValue) const; + template + [[nodiscard]] T fallbackValue( + T C::* member, std::type_identity_t defaultValue) const; -private: - QSet m_overrides; // Overridden keys from file/qml writes - Node* const m_rootNode; - Node* m_fallbackNode; // No fallback node either means global tree or inside overridden list + private: + QSet m_overrides; // Overridden keys from file/qml writes + Node* const m_rootNode; + Node* m_fallbackNode; // No fallback node either means global tree or inside overridden list - // For root node use only - WriteOrigin m_writeOrigin; - bool m_internalRead; - ChangeBatcher* const m_batcher; + // For root node use only + WriteOrigin m_writeOrigin; + bool m_internalRead; + ChangeBatcher* const m_batcher; - void onFallbackNotify(const QString& key); + void onFallbackNotify(const QString& key); - friend class WriteScope; - friend class InternalRead; + friend class WriteScope; + friend class InternalRead; }; -template bool Node::rejectInvalidWrite(const QString& key, const T& value) const { - Q_UNUSED(key) - Q_UNUSED(value) - return false; // Only QVariant unions can be given the wrong type +template +bool Node::rejectInvalidWrite(const QString& key, const T& value) const { + Q_UNUSED(key) + Q_UNUSED(value) + return false; // Only QVariant unions can be given the wrong type } -template T Node::fallbackValue(T C::* member, std::type_identity_t defaultValue) const { - const auto* fallback = static_cast(m_fallbackNode); - return fallback ? fallback->*member : defaultValue; +template +T Node::fallbackValue( + T C::* member, std::type_identity_t defaultValue) const { + const auto* fallback = static_cast(m_fallbackNode); + return fallback ? fallback->*member : defaultValue; } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/quarantine.cpp b/Plugins/ZShell/Settings/quarantine.cpp index d573c6d..889b02c 100644 --- a/Plugins/ZShell/Settings/quarantine.cpp +++ b/Plugins/ZShell/Settings/quarantine.cpp @@ -5,23 +5,23 @@ namespace ZShell::settings { void ObjectQuarantine::insert(const QString& key, const QJsonValue& value) { - m_quarantine.insert(key, value); + m_quarantine.insert(key, value); } bool ObjectQuarantine::remove(const QString& key) { - return m_quarantine.remove(key); + return m_quarantine.remove(key); } bool ObjectQuarantine::isEmpty() const { - return m_quarantine.isEmpty(); + return m_quarantine.isEmpty(); } QJsonValue ObjectQuarantine::apply(const QJsonValue& json) const { - auto result = json.toObject(); - for (const auto& [key, value] : m_quarantine.asKeyValueRange()) - if (!result.contains(key)) // Don't clobber existing values - result.insert(key, value); - return result; + auto result = json.toObject(); + for (const auto& [key, value] : m_quarantine.asKeyValueRange()) + if (!result.contains(key)) // Don't clobber existing values + result.insert(key, value); + return result; } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/quarantine.hpp b/Plugins/ZShell/Settings/quarantine.hpp index 979995d..2159278 100644 --- a/Plugins/ZShell/Settings/quarantine.hpp +++ b/Plugins/ZShell/Settings/quarantine.hpp @@ -7,27 +7,27 @@ namespace ZShell::settings { class Quarantine { -public: - explicit Quarantine() = default; - virtual ~Quarantine() = default; + public: + explicit Quarantine() = default; + virtual ~Quarantine() = default; - virtual void insert(const QString& key, const QJsonValue& value) = 0; - [[nodiscard]] virtual bool remove(const QString& key) = 0; - [[nodiscard]] virtual bool isEmpty() const = 0; - [[nodiscard]] virtual QJsonValue apply(const QJsonValue& json) const = 0; + virtual void insert(const QString& key, const QJsonValue& value) = 0; + [[nodiscard]] virtual bool remove(const QString& key) = 0; + [[nodiscard]] virtual bool isEmpty() const = 0; + [[nodiscard]] virtual QJsonValue apply(const QJsonValue& json) const = 0; - Q_DISABLE_COPY_MOVE(Quarantine) + Q_DISABLE_COPY_MOVE(Quarantine) }; class ObjectQuarantine : public Quarantine { -public: - void insert(const QString& key, const QJsonValue& value) override; - [[nodiscard]] bool remove(const QString& key) override; - [[nodiscard]] bool isEmpty() const override; - [[nodiscard]] QJsonValue apply(const QJsonValue& json) const override; + public: + void insert(const QString& key, const QJsonValue& value) override; + [[nodiscard]] bool remove(const QString& key) override; + [[nodiscard]] bool isEmpty() const override; + [[nodiscard]] QJsonValue apply(const QJsonValue& json) const override; -private: - QHash m_quarantine; + private: + QHash m_quarantine; }; } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/rootnode.cpp b/Plugins/ZShell/Settings/rootnode.cpp index b9d8868..06c10b9 100644 --- a/Plugins/ZShell/Settings/rootnode.cpp +++ b/Plugins/ZShell/Settings/rootnode.cpp @@ -5,42 +5,43 @@ namespace ZShell::settings { RootNode::RootNode(const QString& path, RootNode* fallback, QObject* parent) - : ObjectNode(fallback, parent) - , m_file(new SettingsFile(path, this)) { - QObject::connect(batcher(), &ChangeBatcher::dirtied, this, &RootNode::saveToFile); - QObject::connect(m_file, &SettingsFile::changed, this, &RootNode::reloadFromFile); - QObject::connect(m_file, &SettingsFile::readFailed, this, [this](const QString& error) { - emit treeLoadFailed(this, error); - }); - QObject::connect(m_file, &SettingsFile::writeFailed, this, [this](const QString& error) { - emit treeSaveFailed(this, error); - }); + : ObjectNode(fallback, parent), m_file(new SettingsFile(path, this)) { + QObject::connect( + batcher(), &ChangeBatcher::dirtied, this, &RootNode::saveToFile); + QObject::connect( + m_file, &SettingsFile::changed, this, &RootNode::reloadFromFile); + QObject::connect( + m_file, &SettingsFile::readFailed, this, [this](const QString& error) { + emit treeLoadFailed(this, error); + }); + QObject::connect( + m_file, &SettingsFile::writeFailed, this, [this](const QString& error) { + emit treeSaveFailed(this, error); + }); } void RootNode::load() { - if (m_file->load() != SettingsFile::LoadResult::Changed) - reloadFromFile(); + if (m_file->load() != SettingsFile::LoadResult::Changed) reloadFromFile(); } QList RootNode::diagnostics() const { - return m_diagnostics; + return m_diagnostics; } void RootNode::reloadFromFile() { - const auto oldDiagnostics = m_diagnostics; - m_diagnostics.clear(); + const auto oldDiagnostics = m_diagnostics; + m_diagnostics.clear(); - const auto data = m_file->read(); - syncJson(data ? data.value() : QJsonObject(), m_diagnostics); + const auto data = m_file->read(); + syncJson(data ? data.value() : QJsonObject(), m_diagnostics); - if (m_diagnostics != oldDiagnostics) - emit diagnosticsChanged(); + if (m_diagnostics != oldDiagnostics) emit diagnosticsChanged(); - emit treeLoaded(this); + emit treeLoaded(this); } void RootNode::saveToFile() { - m_file->write(toJson()); + m_file->write(toJson()); } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/rootnode.hpp b/Plugins/ZShell/Settings/rootnode.hpp index 71a5ead..240c0be 100644 --- a/Plugins/ZShell/Settings/rootnode.hpp +++ b/Plugins/ZShell/Settings/rootnode.hpp @@ -7,29 +7,32 @@ namespace ZShell::settings { class RootNode : public ObjectNode { - Q_OBJECT + Q_OBJECT - Q_PROPERTY(QList diagnostics READ diagnostics NOTIFY diagnosticsChanged) + Q_PROPERTY( + QList diagnostics READ diagnostics NOTIFY + diagnosticsChanged) -public: - explicit RootNode(const QString& path, RootNode* fallback, QObject* parent = nullptr); + public: + explicit RootNode( + const QString& path, RootNode* fallback, QObject* parent = nullptr); - [[nodiscard]] QList diagnostics() const; + [[nodiscard]] QList diagnostics() const; - void load(); + void load(); -signals: - void diagnosticsChanged(); - void treeLoaded(RootNode* self); - void treeLoadFailed(RootNode* self, const QString& error); - void treeSaveFailed(RootNode* self, const QString& error); + signals: + void diagnosticsChanged(); + void treeLoaded(RootNode* self); + void treeLoadFailed(RootNode* self, const QString& error); + void treeSaveFailed(RootNode* self, const QString& error); -private: - SettingsFile* const m_file; - QList m_diagnostics; + private: + SettingsFile* const m_file; + QList m_diagnostics; - void reloadFromFile(); - void saveToFile(); + void reloadFromFile(); + void saveToFile(); }; } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/schema.cpp b/Plugins/ZShell/Settings/schema.cpp index 370a695..79ba2c4 100644 --- a/Plugins/ZShell/Settings/schema.cpp +++ b/Plugins/ZShell/Settings/schema.cpp @@ -16,118 +16,122 @@ namespace { // Descriptor annotations are stored here by Schema::annotate, which is called in the CONFIG_XXX macros. // Schema::build then takes them from here. QHash>& annotationCache() { - static QHash> s_cache; - return s_cache; + static QHash> s_cache; + return s_cache; } // Union options carry their alternatives in the annotation, everything else goes by property type const ValueCodec* resolveCodec(Descriptor& desc) { - auto& allowed = desc.annotation.allowedTypes; + auto& allowed = desc.annotation.allowedTypes; - if (allowed.isEmpty()) - return ValueCodec::codecFor(desc.type); + if (allowed.isEmpty()) return ValueCodec::codecFor(desc.type); - if (desc.type.id() != QMetaType::QVariant) { - qCCritical(lcSchema, "Allowed types are only valid for QVariant properties, ignoring them for %s", - qUtf8Printable(desc.key)); - allowed.clear(); - return ValueCodec::codecFor(desc.type); - } + if (desc.type.id() != QMetaType::QVariant) { + qCCritical( + lcSchema, + "Allowed types are only valid for QVariant properties, ignoring " + "them for %s", + qUtf8Printable(desc.key)); + allowed.clear(); + return ValueCodec::codecFor(desc.type); + } - if (const auto* codec = ValueCodec::unionFor(allowed)) - return codec; + if (const auto* codec = ValueCodec::unionFor(allowed)) return codec; - allowed.clear(); - return nullptr; + allowed.clear(); + return nullptr; } bool isNodeType(const QMetaType& type) { - if (!type.flags().testFlag(QMetaType::PointerToQObject)) - return false; - const auto* meta = type.metaObject(); - return meta && meta->inherits(&Node::staticMetaObject); + if (!type.flags().testFlag(QMetaType::PointerToQObject)) return false; + const auto* meta = type.metaObject(); + return meta && meta->inherits(&Node::staticMetaObject); } } // namespace QString Descriptor::typeString() const { - if (annotation.allowedTypes.isEmpty()) - return QString::fromUtf8(type.name()); + if (annotation.allowedTypes.isEmpty()) + return QString::fromUtf8(type.name()); - QStringList names; - names.reserve(annotation.allowedTypes.size()); - for (const auto& allowed : annotation.allowedTypes) - names << QString::fromUtf8(allowed.name()); + QStringList names; + names.reserve(annotation.allowedTypes.size()); + for (const auto& allowed : annotation.allowedTypes) + names << QString::fromUtf8(allowed.name()); - return names.join(QStringLiteral(" | ")); + return names.join(QStringLiteral(" | ")); } bool Descriptor::accepts(const QMetaType& valueType) const { - // Unset is a valid state for QVariant options, they are simply absent from the file - if (type.id() == QMetaType::QVariant && !valueType.isValid()) - return true; + // Unset is a valid state for QVariant options, they are simply absent from the file + if (type.id() == QMetaType::QVariant && !valueType.isValid()) return true; - if (annotation.allowedTypes.isEmpty()) - return type == valueType; + if (annotation.allowedTypes.isEmpty()) return type == valueType; - return annotation.allowedTypes.contains(valueType); + return annotation.allowedTypes.contains(valueType); } -Schema Schema::build(const QMetaObject* meta, int baseOffset, bool includeReadOnly) { - Schema schema; - schema.m_descriptors.reserve(meta->propertyCount() - baseOffset); +Schema Schema::build( + const QMetaObject* meta, int baseOffset, bool includeReadOnly) { + Schema schema; + schema.m_descriptors.reserve(meta->propertyCount() - baseOffset); - // Descriptors are taken from cache since this should be called exactly once per class - const auto annotations = annotationCache().take(meta); + // Descriptors are taken from cache since this should be called exactly once per class + const auto annotations = annotationCache().take(meta); - qCDebug(lcSchema) << "Building schema for" << meta->className(); + qCDebug(lcSchema) << "Building schema for" << meta->className(); - for (int i = baseOffset; i < meta->propertyCount(); ++i) { - const auto prop = meta->property(i); - const auto key = QString::fromUtf8(prop.name()); - const auto isNode = isNodeType(prop.metaType()); + for (int i = baseOffset; i < meta->propertyCount(); ++i) { + const auto prop = meta->property(i); + const auto key = QString::fromUtf8(prop.name()); + const auto isNode = isNodeType(prop.metaType()); - // Skip read only properties (unless includeReadOnly) - if (!isNode && !includeReadOnly && !prop.isWritable()) { - qCDebug(lcSchema) << " Skipping computed property" << key; - continue; - } + // Skip read only properties (unless includeReadOnly) + if (!isNode && !includeReadOnly && !prop.isWritable()) { + qCDebug(lcSchema) << " Skipping computed property" << key; + continue; + } - qCDebug(lcSchema) << " Adding property" << key; + qCDebug(lcSchema) << " Adding property" << key; - Descriptor desc{ - .key = key, - .type = prop.metaType(), - .metaIndex = i, - .isNode = isNode, - .annotation = annotations.value(key), - }; + Descriptor desc{ + .key = key, + .type = prop.metaType(), + .metaIndex = i, + .isNode = isNode, + .annotation = annotations.value(key), + }; - if (!isNode) { - desc.codec = resolveCodec(desc); - if (!desc.codec) - qCCritical(lcSchema, "No codec for %s of type %s, it will not be loaded or saved", qUtf8Printable(key), - desc.type.name()); - } + if (!isNode) { + desc.codec = resolveCodec(desc); + if (!desc.codec) + qCCritical( + lcSchema, + "No codec for %s of type %s, it will not be loaded or " + "saved", + qUtf8Printable(key), + desc.type.name()); + } - schema.m_descriptors.append(std::move(desc)); - schema.m_keyToIndex.insert(key, schema.m_descriptors.size() - 1); - } + schema.m_descriptors.append(std::move(desc)); + schema.m_keyToIndex.insert(key, schema.m_descriptors.size() - 1); + } - return schema; + return schema; } -void Schema::annotate(const QMetaObject* meta, const QString& key, Annotation annotation) { - annotationCache()[meta].insert(key, std::move(annotation)); +void Schema::annotate( + const QMetaObject* meta, const QString& key, Annotation annotation) { + annotationCache()[meta].insert(key, std::move(annotation)); } const QList& Schema::descriptors() const { - return m_descriptors; + return m_descriptors; } const Descriptor* Schema::get(const QString& key) const { - const auto it = m_keyToIndex.find(key); - return it != m_keyToIndex.end() ? &m_descriptors[it.value()] : nullptr; + const auto it = m_keyToIndex.find(key); + return it != m_keyToIndex.end() ? &m_descriptors[it.value()] : nullptr; } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/schema.hpp b/Plugins/ZShell/Settings/schema.hpp index 2857530..8abe1f2 100644 --- a/Plugins/ZShell/Settings/schema.hpp +++ b/Plugins/ZShell/Settings/schema.hpp @@ -13,69 +13,77 @@ namespace ZShell::settings { class ValueCodec; struct Annotation { - QVariant defaultValue; - bool globalOnly = false; - // NOLINTNEXTLINE(readability-redundant-member-init) callers will get missing field init warnings without - QList allowedTypes = {}; // For QVariant properties + QVariant defaultValue; + bool globalOnly = false; + // NOLINTNEXTLINE(readability-redundant-member-init) callers will get missing field init warnings without + QList allowedTypes = {}; // For QVariant properties + // NOLINTNEXTLINE(readability-redundant-member-init) + QString identityKey = {}; }; namespace detail { // Return trivially copyable types (e.g. bool) by value and others (e.g. QVariant) by const ref template -using AnnotationReturn = std::conditional_t && sizeof(T) <= sizeof(void*), T, const T&>; +using AnnotationReturn = std::conditional_t< + std::is_trivially_copyable_v && sizeof(T) <= sizeof(void*), + T, + const T&>; } // namespace detail -#define ANNOTATION(Type, name) \ - Q_PROPERTY(Type name READ name) \ - \ -public: \ - [[nodiscard]] detail::AnnotationReturn name() const { \ - return annotation.name; \ - } \ - \ -private: +#define ANNOTATION(Type, name) \ + Q_PROPERTY(Type name READ name) \ + \ + public: \ + [[nodiscard]] detail::AnnotationReturn name() const { \ + return annotation.name; \ + } \ + \ + private: struct Descriptor { - Q_GADGET - QML_VALUE_TYPE(descriptor) + Q_GADGET + QML_VALUE_TYPE(descriptor) - Q_PROPERTY(QString key MEMBER key) - Q_PROPERTY(QString type READ typeString) - Q_PROPERTY(int metaIndex MEMBER metaIndex) - Q_PROPERTY(bool isNode MEMBER isNode) + Q_PROPERTY(QString key MEMBER key) + Q_PROPERTY(QString type READ typeString) + Q_PROPERTY(int metaIndex MEMBER metaIndex) + Q_PROPERTY(bool isNode MEMBER isNode) - ANNOTATION(QVariant, defaultValue) - ANNOTATION(bool, globalOnly) + ANNOTATION(QVariant, defaultValue) + ANNOTATION(bool, globalOnly) + ANNOTATION(QString, identityKey) -public: - QString key; - QMetaType type; - int metaIndex; - bool isNode; - Annotation annotation; - const ValueCodec* codec = nullptr; // Null for nodes and unsupported types + public: + QString key; + QMetaType type; + int metaIndex; + bool isNode; + Annotation annotation; + const ValueCodec* codec = nullptr; // Null for nodes and unsupported types - [[nodiscard]] QString typeString() const; - [[nodiscard]] bool accepts(const QMetaType& valueType) const; + [[nodiscard]] QString typeString() const; + [[nodiscard]] bool accepts(const QMetaType& valueType) const; }; #undef ANNOTATION class Schema { -public: - static Schema build(const QMetaObject* meta, int baseOffset, bool includeReadOnly = false); - static void annotate(const QMetaObject* meta, const QString& key, Annotation annotation); + public: + static Schema build( + const QMetaObject* meta, int baseOffset, bool includeReadOnly = false); + static void annotate( + const QMetaObject* meta, const QString& key, Annotation annotation); - [[nodiscard]] const QList& descriptors() const; - [[nodiscard]] const Descriptor* get(const QString& key) const; + [[nodiscard]] const QList& descriptors() const; + [[nodiscard]] const Descriptor* get(const QString& key) const; -private: - explicit Schema() = default; + private: + explicit Schema() = default; - QList m_descriptors; - QHash m_keyToIndex; + QList m_descriptors; + QHash m_keyToIndex; }; } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/settingsfile.cpp b/Plugins/ZShell/Settings/settingsfile.cpp index abd4883..4ae07f6 100644 --- a/Plugins/ZShell/Settings/settingsfile.cpp +++ b/Plugins/ZShell/Settings/settingsfile.cpp @@ -30,184 +30,221 @@ constexpr int k_maxLoadRetries = 3; } // namespace SettingsFile::SettingsFile(QString path, QObject* parent) - : QObject(parent) - , m_path(std::move(path)) - , m_watcher(new QFileSystemWatcher(this)) - , m_saveDebounce(new QTimer(this)) - , m_loadDebounce(new QTimer(this)) - , m_loadRetries(0) { - m_saveDebounce->setSingleShot(true); - m_saveDebounce->setInterval(500); // Save at most once every 500ms + : QObject(parent) + , m_path(std::move(path)) + , m_watcher(new QFileSystemWatcher(this)) + , m_saveDebounce(new QTimer(this)) + , m_loadDebounce(new QTimer(this)) + , m_loadRetries(0) { + m_saveDebounce->setSingleShot(true); + m_saveDebounce->setInterval(500); // Save at most once every 500ms - m_loadDebounce->setSingleShot(true); - m_loadDebounce->setInterval(50); // Coalesce watcher events within 50ms - QObject::connect(m_loadDebounce, &QTimer::timeout, this, &SettingsFile::onLoadDebounced); + m_loadDebounce->setSingleShot(true); + m_loadDebounce->setInterval(50); // Coalesce watcher events within 50ms + QObject::connect( + m_loadDebounce, &QTimer::timeout, this, &SettingsFile::onLoadDebounced); - QObject::connect(m_watcher, &QFileSystemWatcher::fileChanged, this, &SettingsFile::onFileChanged); - QObject::connect(m_watcher, &QFileSystemWatcher::directoryChanged, this, &SettingsFile::onDirChanged); + QObject::connect( + m_watcher, + &QFileSystemWatcher::fileChanged, + this, + &SettingsFile::onFileChanged); + QObject::connect( + m_watcher, + &QFileSystemWatcher::directoryChanged, + this, + &SettingsFile::onDirChanged); - initWatcher(); + initWatcher(); } std::optional SettingsFile::read() const { - return m_lastData; + return m_lastData; } void SettingsFile::write(const QJsonValue& json) { - m_pendingWrite = json; - save(); + m_pendingWrite = json; + save(); } void SettingsFile::onFileChanged() { - initWatcher(); // Re-add path in case the file was replaced - scheduleLoad(); + initWatcher(); // Re-add path in case the file was replaced + scheduleLoad(); } void SettingsFile::onDirChanged() { - // Ignore if the file is already watched - if (m_watcher->files().contains(m_path)) - return; + // Ignore if the file is already watched + if (m_watcher->files().contains(m_path)) return; - initWatcher(); + initWatcher(); - if (QFile::exists(m_path)) - scheduleLoad(); + if (QFile::exists(m_path)) scheduleLoad(); } void SettingsFile::initWatcher() { - const QFileInfo info(m_path); - const auto dir = info.absolutePath(); + const QFileInfo info(m_path); + const auto dir = info.absolutePath(); - if (QDir(dir).exists() && !m_watcher->directories().contains(dir)) - m_watcher->addPath(dir); + if (QDir(dir).exists() && !m_watcher->directories().contains(dir)) + m_watcher->addPath(dir); - if (info.exists() && !m_watcher->files().contains(m_path)) - m_watcher->addPath(m_path); + if (info.exists() && !m_watcher->files().contains(m_path)) + m_watcher->addPath(m_path); } void SettingsFile::scheduleLoad() { - // Ignore if a load is already pending - if (m_loadDebounce->isActive()) - return; + // Ignore if a load is already pending + if (m_loadDebounce->isActive()) return; - m_loadRetries = 0; - m_loadDebounce->start(); + m_loadRetries = 0; + m_loadDebounce->start(); } void SettingsFile::onLoadDebounced() { - const auto isFinalTry = m_loadRetries >= k_maxLoadRetries; + const auto isFinalTry = m_loadRetries >= k_maxLoadRetries; - if (load(isFinalTry) == LoadResult::ParseError && !isFinalTry) { - // Likely a partial write, retry after another debounce - ++m_loadRetries; - qCDebug(lcSettingsFile, "Retrying load of %s (%d/%d)", qUtf8Printable(m_path), m_loadRetries, k_maxLoadRetries); - m_loadDebounce->start(); - } + if (load(isFinalTry) == LoadResult::ParseError && !isFinalTry) { + // Likely a partial write, retry after another debounce + ++m_loadRetries; + qCDebug( + lcSettingsFile, + "Retrying load of %s (%d/%d)", + qUtf8Printable(m_path), + m_loadRetries, + k_maxLoadRetries); + m_loadDebounce->start(); + } } SettingsFile::LoadResult SettingsFile::load(bool reportErrors) { - QFile file(m_path); + QFile file(m_path); - if (!file.exists()) { - if (m_lastData) { - m_lastData = std::nullopt; - emit changed(); - return LoadResult::Changed; - } - return LoadResult::Unchanged; - } + if (!file.exists()) { + if (m_lastData) { + m_lastData = std::nullopt; + emit changed(); + return LoadResult::Changed; + } + return LoadResult::Unchanged; + } - if (!file.open(QIODevice::ReadOnly)) { - qCWarning(lcSettingsFile, "Failed to open %s for reading: %s", qUtf8Printable(m_path), - qUtf8Printable(file.errorString())); - emit readFailed(QStringLiteral("Failed to open: %1").arg(file.errorString())); - return LoadResult::Error; - } + if (!file.open(QIODevice::ReadOnly)) { + qCWarning( + lcSettingsFile, + "Failed to open %s for reading: %s", + qUtf8Printable(m_path), + qUtf8Printable(file.errorString())); + emit readFailed( + QStringLiteral("Failed to open: %1").arg(file.errorString())); + return LoadResult::Error; + } - const auto data = file.readAll(); - file.close(); + const auto data = file.readAll(); + file.close(); - QJsonParseError error; - const auto doc = QJsonDocument::fromJson(data, &error); + QJsonParseError error; + const auto doc = QJsonDocument::fromJson(data, &error); - if (error.error != QJsonParseError::NoError) { - if (reportErrors) { - qCWarning(lcSettingsFile, "Failed to parse %s as JSON: %s", qUtf8Printable(m_path), - qUtf8Printable(error.errorString())); - emit readFailed(QStringLiteral("Failed to parse: %1").arg(error.errorString())); - } else { - qCDebug(lcSettingsFile, "Failed to parse %s as JSON: %s", qUtf8Printable(m_path), - qUtf8Printable(error.errorString())); - } - return LoadResult::ParseError; - } + if (error.error != QJsonParseError::NoError) { + if (reportErrors) { + qCWarning( + lcSettingsFile, + "Failed to parse %s as JSON: %s", + qUtf8Printable(m_path), + qUtf8Printable(error.errorString())); + emit readFailed( + QStringLiteral("Failed to parse: %1").arg(error.errorString())); + } else { + qCDebug( + lcSettingsFile, + "Failed to parse %s as JSON: %s", + qUtf8Printable(m_path), + qUtf8Printable(error.errorString())); + } + return LoadResult::ParseError; + } - const auto json = doc.isObject() ? QJsonValue(doc.object()) : QJsonValue(doc.array()); + const auto json = doc.isObject() ? QJsonValue(doc.object()) + : QJsonValue(doc.array()); - if (json == m_lastData) - return LoadResult::Unchanged; + if (json == m_lastData) return LoadResult::Unchanged; - // Clear pending write, stuff loaded from file should take precedence - m_pendingWrite = std::nullopt; + // Clear pending write, stuff loaded from file should take precedence + m_pendingWrite = std::nullopt; - m_lastData = json; - emit changed(); + m_lastData = json; + emit changed(); - qCDebug(lcSettingsFile) << "Read JSON from" << m_path; - qCDebug(lcSettingsFile) << " " << json; + qCDebug(lcSettingsFile) << "Read JSON from" << m_path; + qCDebug(lcSettingsFile) << " " << json; - return LoadResult::Changed; + return LoadResult::Changed; } void SettingsFile::save() { - if (!m_pendingWrite) - return; + if (!m_pendingWrite) return; - if (m_saveDebounce->isActive()) { - // Queue save for debounce end - QObject::connect(m_saveDebounce, &QTimer::timeout, this, &SettingsFile::save, - // NOLINTNEXTLINE(clang-analyzer-optin.core.EnumCastOutOfRange) ConnectionType is OR-able by design - static_cast(Qt::UniqueConnection | Qt::SingleShotConnection)); - return; - } + if (m_saveDebounce->isActive()) { + // Queue save for debounce end + QObject::connect( + m_saveDebounce, + &QTimer::timeout, + this, + &SettingsFile::save, + // NOLINTNEXTLINE(clang-analyzer-optin.core.EnumCastOutOfRange) ConnectionType is OR-able by design + static_cast( + Qt::UniqueConnection | Qt::SingleShotConnection)); + return; + } - // Debounce regardless of whether the save actually succeeded - m_saveDebounce->start(); + // Debounce regardless of whether the save actually succeeded + m_saveDebounce->start(); - const auto json = m_pendingWrite.value(); - const auto data = (json.isObject() ? QJsonDocument(json.toObject()) : QJsonDocument(json.toArray())).toJson(); - m_pendingWrite = std::nullopt; + const auto json = m_pendingWrite.value(); + const auto data = (json.isObject() ? QJsonDocument(json.toObject()) + : QJsonDocument(json.toArray())) + .toJson(); + m_pendingWrite = std::nullopt; - const auto dir = QFileInfo(m_path).absolutePath(); + const auto dir = QFileInfo(m_path).absolutePath(); - if (!QDir().mkpath(dir)) { - qCWarning(lcSettingsFile) << "Failed to create dir" << dir; - emit writeFailed(QStringLiteral("Failed to create parent directory")); - return; - } + if (!QDir().mkpath(dir)) { + qCWarning(lcSettingsFile) << "Failed to create dir" << dir; + emit writeFailed(QStringLiteral("Failed to create parent directory")); + return; + } - // Write atomically - QSaveFile file(m_path); + // Write atomically + QSaveFile file(m_path); - if (!file.open(QIODevice::WriteOnly)) { - qCWarning(lcSettingsFile, "Failed to open %s for writing: %s", qUtf8Printable(m_path), - qUtf8Printable(file.errorString())); - emit writeFailed(QStringLiteral("Failed to open: %1").arg(file.errorString())); - return; - } + if (!file.open(QIODevice::WriteOnly)) { + qCWarning( + lcSettingsFile, + "Failed to open %s for writing: %s", + qUtf8Printable(m_path), + qUtf8Printable(file.errorString())); + emit writeFailed( + QStringLiteral("Failed to open: %1").arg(file.errorString())); + return; + } - file.write(data); + file.write(data); - if (!file.commit()) { - qCWarning(lcSettingsFile, "Failed to write %s: %s", qUtf8Printable(m_path), qUtf8Printable(file.errorString())); - emit writeFailed(QStringLiteral("Failed to write: %1").arg(file.errorString())); - return; - } + if (!file.commit()) { + qCWarning( + lcSettingsFile, + "Failed to write %s: %s", + qUtf8Printable(m_path), + qUtf8Printable(file.errorString())); + emit writeFailed( + QStringLiteral("Failed to write: %1").arg(file.errorString())); + return; + } - m_lastData = json; - qCDebug(lcSettingsFile) << "Saved" << m_path; + m_lastData = json; + qCDebug(lcSettingsFile) << "Saved" << m_path; - initWatcher(); // The file may have just been created, or replaced by the rename + initWatcher(); // The file may have just been created, or replaced by the rename } } // namespace ZShell::settings diff --git a/Plugins/ZShell/Settings/settingsfile.hpp b/Plugins/ZShell/Settings/settingsfile.hpp index d517d33..d9b82b4 100644 --- a/Plugins/ZShell/Settings/settingsfile.hpp +++ b/Plugins/ZShell/Settings/settingsfile.hpp @@ -8,44 +8,39 @@ namespace ZShell::settings { class SettingsFile : public QObject { - Q_OBJECT + Q_OBJECT -public: - enum class LoadResult : quint8 { - Unchanged, - Changed, - ParseError, - Error - }; + public: + enum class LoadResult : quint8 { Unchanged, Changed, ParseError, Error }; - explicit SettingsFile(QString path, QObject* parent = nullptr); + explicit SettingsFile(QString path, QObject* parent = nullptr); - [[nodiscard]] std::optional read() const; - void write(const QJsonValue& json); + [[nodiscard]] std::optional read() const; + void write(const QJsonValue& json); - LoadResult load(bool reportErrors = true); + LoadResult load(bool reportErrors = true); -signals: - void changed(); // Data changed, not file watcher event - void readFailed(const QString& error); - void writeFailed(const QString& error); + signals: + void changed(); // Data changed, not file watcher event + void readFailed(const QString& error); + void writeFailed(const QString& error); -private: - QString m_path; - QFileSystemWatcher* m_watcher; - QTimer* m_saveDebounce; - QTimer* m_loadDebounce; - int m_loadRetries; - std::optional m_lastData; - std::optional m_pendingWrite; + private: + QString m_path; + QFileSystemWatcher* m_watcher; + QTimer* m_saveDebounce; + QTimer* m_loadDebounce; + int m_loadRetries; + std::optional m_lastData; + std::optional m_pendingWrite; - void onFileChanged(); - void onDirChanged(); + void onFileChanged(); + void onDirChanged(); - void initWatcher(); - void scheduleLoad(); - void onLoadDebounced(); - void save(); + void initWatcher(); + void scheduleLoad(); + void onLoadDebounced(); + void save(); }; } // namespace ZShell::settings From 4a021d81ac3f5b83a83da3cdd759ecf29330ce2c Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 28 Sep 2026 13:14:48 +0200 Subject: [PATCH 20/90] chore: formatter --- Plugins/ZShell/Services/memory.cpp | 15 +++++++-------- Plugins/ZShell/Services/processes.cpp | 19 ++++++++++--------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Plugins/ZShell/Services/memory.cpp b/Plugins/ZShell/Services/memory.cpp index 1b1c2b9..07e9c55 100644 --- a/Plugins/ZShell/Services/memory.cpp +++ b/Plugins/ZShell/Services/memory.cpp @@ -45,8 +45,7 @@ void Memory::tick() { QStringLiteral("MemTotal: *(\\d+)")); static const QRegularExpression reAvail( QStringLiteral("MemAvailable: *(\\d+)")); - static const QRegularExpression reCached(QStringLiteral( - "Cached: *(\\d+)")); + static const QRegularExpression reCached(QStringLiteral("Cached: *(\\d+)")); const QString text = QString::fromLatin1(data); const auto totalMatch = reTotal.match(text); @@ -78,14 +77,14 @@ void Memory::tick() { const auto pgin = rePgin.match(vtext); const auto pgout = rePgout.match(vtext); if (pgin.hasMatch()) { - readKib = static_cast(static_cast( - pgin.captured(1).toULongLong()) * - pageKib); + readKib = static_cast( + static_cast(pgin.captured(1).toULongLong()) * + pageKib); } if (pgout.hasMatch()) { - writeKib = static_cast(static_cast( - pgout.captured(1).toULongLong()) * - pageKib); + writeKib = static_cast( + static_cast(pgout.captured(1).toULongLong()) * + pageKib); } } } diff --git a/Plugins/ZShell/Services/processes.cpp b/Plugins/ZShell/Services/processes.cpp index 96cef22..5b926a4 100644 --- a/Plugins/ZShell/Services/processes.cpp +++ b/Plugins/ZShell/Services/processes.cpp @@ -793,8 +793,7 @@ void ProcessModel::scanChunk( nowTicks > prevTicks ? nowTicks - prevTicks : 0; entry.cpuPercent = (static_cast(deltaTicks) / - (ctx.deltaSecs * - static_cast(ctx.clockTicksPerSec))) * + (ctx.deltaSecs * static_cast(ctx.clockTicksPerSec))) * 100.0; } else { entry.cpuPercent = 0.0; @@ -839,7 +838,8 @@ ProcessModel::ScanResult ProcessModel::scanProcesses( // syscall-bound (open/read/close per file per process) and serializes // badly on large systems. Keep one pool thread free for this function, // which itself runs inside the global pool. - int nThreads = qMin(16, QThreadPool::globalInstance()->maxThreadCount() - 1); + int nThreads = + qMin(16, QThreadPool::globalInstance()->maxThreadCount() - 1); if (pids.size() < 128 || nThreads < 2) { nThreads = 1; } @@ -860,8 +860,8 @@ ProcessModel::ScanResult ProcessModel::scanProcesses( continue; } chunks[t] = pids.mid(a, b - a); - futures.append(QtConcurrent::run( - [&ctx, &chunks, &outs, &uidParts, t]() { + futures.append( + QtConcurrent::run([&ctx, &chunks, &outs, &uidParts, t]() { scanChunk(ctx, chunks[t], outs[t], uidParts[t]); })); } @@ -880,8 +880,9 @@ ProcessModel::ScanResult ProcessModel::scanProcesses( } } std::sort( - fresh.begin(), fresh.end(), [](const ProcessEntry& a, - const ProcessEntry& b) { + fresh.begin(), + fresh.end(), + [](const ProcessEntry& a, const ProcessEntry& b) { return a.pid < b.pid; }); } @@ -1012,8 +1013,8 @@ void ProcessModel::applyView() { const FuzzyText prep = fuzzyPrepare(src.command); fe.commandChars = std::move(prep.chars); fe.commandClasses = std::move(prep.classes); - fe.hasCommandLine = - !src.commandLine.isEmpty() && src.commandLine != src.command; + fe.hasCommandLine = !src.commandLine.isEmpty() && + src.commandLine != src.command; if (fe.hasCommandLine) { const FuzzyText prepCl = fuzzyPrepare(src.commandLine); fe.commandLineChars = std::move(prepCl.chars); From f6fa2f023088bb4fae932e11c6db7221e39e973e Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 28 Sep 2026 15:48:13 +0200 Subject: [PATCH 21/90] fix: remove clippingrectangle in favor of passing radius to sub-items --- Modules/Dashboard/Content.qml | 151 +++++++++++---------- Modules/Dashboard/Dash.qml | 2 +- Modules/Dashboard/Media.qml | 8 +- Modules/Dashboard/Processes/SystemInfo.qml | 4 +- Modules/Dashboard/ProcessesTab.qml | 3 + Modules/Dashboard/WeatherTab.qml | 5 +- 6 files changed, 95 insertions(+), 78 deletions(-) diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index b4173e7..0ca16cc 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -11,8 +11,8 @@ import qs.Components Item { id: root - readonly property real nonAnimWidth: view.implicitWidth + clipWrapper.anchors.margins * 2 - readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + clipWrapper.anchors.margins + clipWrapper.anchors.topMargin + readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 + readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins + viewWrapper.anchors.topMargin required property ScreenState screenState @@ -74,115 +74,118 @@ Item { anchors.margins: Tokens.padding.small } - CustomClippingRect { - id: clipWrapper + Item { + id: viewWrapper + + property int radius: Tokens.rounding.extraLarge - anchors.margins + anchors.top: tabs.bottom anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom anchors.topMargin: Tokens.spacing.small anchors.margins: Tokens.padding.small - radius: Tokens.rounding.extraLarge - anchors.margins + clip: true - Item { - id: viewWrapper + Flickable { + id: view + + readonly property int currentIndex: root.screenState.dashboardTab + readonly property Item currentItem: { + repeater.count; + return repeater.itemAt(currentIndex); + } anchors.fill: parent - Flickable { - id: view + implicitWidth: currentItem?.implicitWidth ?? 0 + implicitHeight: currentItem?.implicitHeight ?? 0 - readonly property int currentIndex: root.screenState.dashboardTab - readonly property Item currentItem: { - repeater.count; - return repeater.itemAt(currentIndex); - } + contentX: currentItem?.x ?? 0 + contentWidth: row.implicitWidth + contentHeight: row.implicitHeight - anchors.fill: parent + interactive: false - implicitWidth: currentItem?.implicitWidth ?? 0 - implicitHeight: currentItem?.implicitHeight ?? 0 + onContentXChanged: { + if (!moving || !currentItem) + return; - contentX: currentItem?.x ?? 0 - contentWidth: row.implicitWidth - contentHeight: row.implicitHeight + const x = contentX - currentItem.x; + if (x > currentItem.implicitWidth / 2) + root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1); + else if (x < -currentItem.implicitWidth / 2) + root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); + } - interactive: false + RowLayout { + id: row - onContentXChanged: { - if (!moving || !currentItem) - return; + Repeater { + id: repeater - const x = contentX - currentItem.x; - if (x > currentItem.implicitWidth / 2) - root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1); - else if (x < -currentItem.implicitWidth / 2) - root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); - } + model: ScriptModel { + values: root.dashboardTabs + } - RowLayout { - id: row + delegate: Loader { + id: paneLoader - Repeater { - id: repeater + required property int index + required property var modelData - model: ScriptModel { - values: root.dashboardTabs - } + Layout.alignment: Qt.AlignTop - delegate: Loader { - id: paneLoader + sourceComponent: modelData.component - required property int index - required property var modelData - - Layout.alignment: Qt.AlignTop - - sourceComponent: modelData.component - - Component.onCompleted: active = Qt.binding(() => { - if (index === view.currentIndex) - return true; - const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); - const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); - return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); - }) - } + Component.onCompleted: active = Qt.binding(() => { + if (index === view.currentIndex) + return true; + const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); + const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); + return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); + }) } } + } - Component { - id: dashComponent + Component { + id: dashComponent - Dash { - screenState: root.screenState - } + Dash { + screenState: root.screenState + radius: viewWrapper.radius } + } - Component { - id: weatherComponent + Component { + id: weatherComponent - WeatherTab {} + WeatherTab { + radius: viewWrapper.radius } + } - Component { - id: mediaComponent + Component { + id: mediaComponent - Media { - screenState: root.screenState - } + Media { + screenState: root.screenState + radius: viewWrapper.radius } + } - Component { - id: procsComponent + Component { + id: procsComponent - ProcessesTab {} + ProcessesTab { + radius: viewWrapper.radius } + } - Behavior on contentX { - Anim { - type: Anim.DefaultEffects - } + Behavior on contentX { + Anim { + type: Anim.DefaultEffects } } } diff --git a/Modules/Dashboard/Dash.qml b/Modules/Dashboard/Dash.qml index 31a78b6..4f61d07 100644 --- a/Modules/Dashboard/Dash.qml +++ b/Modules/Dashboard/Dash.qml @@ -12,7 +12,7 @@ GridLayout { id: root readonly property bool dashboardVisible: screenState.dashboard - property int radius: Tokens.rounding.medium + required property int radius required property ScreenState screenState columnSpacing: Tokens.spacing.small diff --git a/Modules/Dashboard/Media.qml b/Modules/Dashboard/Media.qml index 297e996..acf4399 100644 --- a/Modules/Dashboard/Media.qml +++ b/Modules/Dashboard/Media.qml @@ -11,12 +11,18 @@ Item { id: root property ScreenState screenState + required property int radius implicitWidth: 1000 implicitHeight: 320 - BackgroundShapes { + CustomClippingRect { anchors.fill: parent + radius: root.radius + + BackgroundShapes { + anchors.fill: parent + } } RowLayout { diff --git a/Modules/Dashboard/Processes/SystemInfo.qml b/Modules/Dashboard/Processes/SystemInfo.qml index 086e90b..afaedce 100644 --- a/Modules/Dashboard/Processes/SystemInfo.qml +++ b/Modules/Dashboard/Processes/SystemInfo.qml @@ -11,6 +11,8 @@ import qs.Services Item { id: root + required property int radius + implicitHeight: layout.implicitHeight ServiceRef { @@ -53,7 +55,7 @@ Item { property list values color: Colors.tPalette.m3surfaceContainer - radius: Tokens.rounding.largeIncreased + radius: root.radius Layout.fillWidth: true Layout.fillHeight: true implicitHeight: mainLayout.implicitHeight + mainLayout.anchors.margins * 2 diff --git a/Modules/Dashboard/ProcessesTab.qml b/Modules/Dashboard/ProcessesTab.qml index 35687fc..646928c 100644 --- a/Modules/Dashboard/ProcessesTab.qml +++ b/Modules/Dashboard/ProcessesTab.qml @@ -9,6 +9,8 @@ import qs.Services Item { id: root + required property int radius + implicitWidth: Tokens.sizes.dashboard.processesWidth implicitHeight: Tokens.sizes.dashboard.processesHeight @@ -47,5 +49,6 @@ Item { anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right + radius: root.radius } } diff --git a/Modules/Dashboard/WeatherTab.qml b/Modules/Dashboard/WeatherTab.qml index 24538a2..7069abb 100644 --- a/Modules/Dashboard/WeatherTab.qml +++ b/Modules/Dashboard/WeatherTab.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Layouts import ZShell.Config @@ -9,6 +11,7 @@ import qs.Helpers Item { id: root + required property int radius readonly property var today: Weather.forecast && Weather.forecast.length > 0 ? Weather.forecast[0] : null implicitWidth: layout.implicitWidth > 800 ? layout.implicitWidth : 840 @@ -136,7 +139,7 @@ Item { Layout.fillWidth: true implicitHeight: forecastItemColumn.implicitHeight + Tokens.padding.medium * 2 - radius: Tokens.rounding.large + radius: root.radius color: Colors.tPalette.m3surfaceContainer ColumnLayout { From be3c938c3952c6974ac56c15afb18c53fb695630 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 29 Sep 2026 00:02:14 +0200 Subject: [PATCH 22/90] feat: progress on changing interactions to mousearea again --- Drawers/Interactions.qml | 2 +- Drawers/Interactions2.qml | 406 ++++++++++++++--------------- Drawers/Windows.qml | 2 +- Modules/Notifications/Content.qml | 8 +- Plugins/ZShell/Internal/cursor.hpp | 14 +- 5 files changed, 212 insertions(+), 220 deletions(-) diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index 327289a..4e0d6a5 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -1,7 +1,7 @@ import Quickshell import QtQuick -import qs.Components import ZShell.Config +import qs.Components import qs.Helpers import qs.Modules.Bar.Popouts diff --git a/Drawers/Interactions2.qml b/Drawers/Interactions2.qml index b35cf72..25c3ed7 100644 --- a/Drawers/Interactions2.qml +++ b/Drawers/Interactions2.qml @@ -1,309 +1,299 @@ -import QtQuick -import QtQuick.Controls import Quickshell +import QtQuick import ZShell.Config +import ZShell.Internal import qs.Components -import qs.Modules.Bar as Bar -import qs.Modules.Bar.Popouts as BarPopouts +import qs.Helpers +import qs.Modules.Bar.Popouts CustomMouseArea { id: root - required property ShellScreen screen - required property BarPopouts.Wrapper popouts - required property ScreenState screenState - required property Panels panels - required property Bar.BarLoader bar + required property Item bar required property real borderThickness - required property bool fullscreen + required property ScreenState screenState + required property Drawing drawing + required property EdgeGeometry geometry + required property Panels panels + required property Wrapper popouts + required property ShellScreen screen - property point dragStart property bool dashboardShortcutActive property bool osdShortcutActive + property bool singleGestureTriggered: false property bool utilitiesShortcutActive + property bool setInitialPoint: false + property point dragStart - function withinPanelHeight(panel: Item, x: real, y: real): bool { - const panelY = root.borderThickness + panel.y; - return y >= panelY - Config.border.rounding && y <= panelY + panel.height + Config.border.rounding; - } - - function withinPanelWidth(panel: Item, x: real, y: real): bool { - const panelX = bar.implicitWidth + panel.x; - return x >= panelX - Config.border.rounding && x <= panelX + panel.width + Config.border.rounding; + function inBottomPanel(panel: Item, x: real, y: real): bool { + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); + return y > root.height - (panelHeight + geometry.insetBottom(borderThickness)) && withinPanelWidth(panel, x, y); } function inLeftPanel(panel: Item, x: real, y: real): bool { - return x < bar.implicitWidth + panel.x + panel.width && withinPanelHeight(panel, x, y); + return x < panel.x + panel.width + geometry.insetLeft(borderThickness) && withinPanelHeight(panel, x, y); + } + + function inPopoutArea(x: real, y: real): bool { + const panel = panels.popoutsWrapper; + if (!geometry.horizontal) + return inLeftPanel(panel, x, y); + return withinPanelWidth(panel, x, y) && withinPanelHeight(panel, x, y); } function inRightPanel(panel: Item, x: real, y: real): bool { - return x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panel.x) && withinPanelHeight(panel, x, y); + return x > panel.x + geometry.insetLeft(borderThickness) && withinPanelHeight(panel, x, y); } function inTopPanel(panel: Item, x: real, y: real): bool { - const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); // qmllint disable missing-property - return y < Math.max(Config.border.minThickness, Config.border.thickness + panelHeight) && withinPanelWidth(panel, x, y); + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); + return y < geometry.insetTop(borderThickness) + panelHeight && withinPanelWidth(panel, x, y); } - function inBottomPanel(panel: Item, x: real, y: real, isCorner = false): bool { - const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); // qmllint disable missing-property - return y > height - Math.max(Config.border.minThickness, Config.border.thickness + panelHeight) - (isCorner ? Config.border.rounding : 0) && withinPanelWidth(panel, x, y); + function withinPanelHeight(panel: Item, x: real, y: real): bool { + const panelY = panel.y + geometry.insetTop(borderThickness); + return y >= panelY && y <= panelY + panel.height; } - function onWheel(event: WheelEvent): void { - if (fullscreen) - return; - if (event.x < bar.implicitWidth) { - bar.handleWheel(event.y, event.angleDelta); - } + function withinPanelWidth(panel: Item, x: real, y: real): bool { + const panelX = panel.x + geometry.insetLeft(borderThickness); + return x >= panelX && x <= panelX + panel.width; } anchors.fill: parent - acceptedButtons: fullscreen ? Qt.NoButton : Qt.AllButtons + acceptedButtons: bar.fullscreen ? Qt.NoButton : Qt.AllButtons hoverEnabled: true - onPressed: event => dragStart = Qt.point(event.x, event.y) + onPressed: event => { + dragStart = Qt.point(event.x, event.y); + + if (screenState.isDrawing) { + if (event.button & Qt.RightButton) { + drawing.content.clear(); + return; + } + + if (!inLeftPanel(panels.drawing, mouseX, mouseY)) + drawing.content.beginStroke(event.x, event.y); + } + } + + onPressedChanged: { + if (!pressed) { + if (screenState.isDrawing) { + setInitialPoint = false; + drawing.content.endStroke(); + if (!inLeftPanel(panels.drawing, mouseX, mouseY)) + drawing.content.showHover(mouseX, mouseY); + } + } else { + if (screenState.isDrawing) { + panels.drawing.collapse(); + } + } + } + onContainsMouseChanged: { if (!containsMouse) { - // Only hide if not activated by shortcut if (!osdShortcutActive) { screenState.osd = false; - root.panels.osd.hovered = false; + panels.osd.hovered = false; } - if (!dashboardShortcutActive) - screenState.dashboard = false; - - if (!utilitiesShortcutActive) - screenState.utilities = false; - - if (!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) { + if (!popouts.currentName.startsWith("traymenu") || Config.bar.tray.showOnHover) { popouts.hasCurrent = false; - bar.closeTray(); } - if (Config.bar.showOnHover) + if (Config.bar.autoHide || bar.fullscreen) bar.isHovered = false; - if (Config.sidebar.showOnHover) - screenState.sidebar = false; + if (screenState.dashboard && Config.dashboard.showOnHover) + screenState.dashboard = false; } } onPositionChanged: event => { - if (popouts.isDetached) - return; - const x = event.x; const y = event.y; const dragX = x - dragStart.x; const dragY = y - dragStart.y; - if (fullscreen) { - root.panels.osd.hovered = inRightPanel(panels.osdWrapper, x, y); + if (screenState.isDrawing) { + if (pressed) { + drawing.content.appendPoint(x, y); + } else { + if (inLeftPanel(panels.drawing, x, y)) { + if (!(pressedButtons & Qt.LeftButton)) { + panels.drawing.expand(); + drawing.content.hideHover(); + Cursor.clearOverride(); + } + } else { + Cursor.setOverride(Qt.BlankCursor); + drawing.content.showHover(x, y); + } + } + if (drawing.content.hoverVisible) + drawing.content.showHover(x, y); return; } - // Show bar in non-exclusive mode on hover - if (!screenState.bar && Config.bar.showOnHover && x < bar.clampedWidth) - bar.isHovered = true; + if (pressed && dragStart.y >= screen.height - Config.bar.border && dragStart.x > screen.width / 5 && dragY < -Config.launcher.dragThreshold) + screenState.launcher = true; - // Show/hide bar on drag - if (pressed && dragStart.x < bar.clampedWidth) { - if (dragX > Config.bar.dragThreshold) - screenState.bar = true; - else if (dragX < -Config.bar.dragThreshold) - screenState.bar = false; - } + const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboardWrapper, x, y); - if (panels.sidebar.offsetScale === 1) { - // Show osd on hover - const showOsd = inRightPanel(panels.osdWrapper, x, y); - - // Always update visibility based on hover if not in shortcut mode - if (!osdShortcutActive) { - screenState.osd = showOsd; - root.panels.osd.hovered = showOsd; - } else if (showOsd) { - // If hovering over OSD area while in shortcut mode, transition to hover control - osdShortcutActive = false; - root.panels.osd.hovered = true; - } - - const showSidebar = pressed && dragStart.x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panels.sidebar.x); - - // Show sidebar on hover (top-right corner, bounded by notification panel height) - if (Config.sidebar.showOnHover) { - const sidebarTriggerY = Math.max(Config.sidebar.minHoverThreshold, panels.notifications.y + panels.notifications.height + borderThickness); - const showSidebarHover = x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panels.sidebar.x) && y <= sidebarTriggerY; - if (showSidebarHover && !screenState.sidebar) - screenState.sidebar = true; - } - - // Show/hide session on drag - if (pressed && inRightPanel(panels.sessionWrapper, dragStart.x, dragStart.y) && withinPanelHeight(panels.sessionWrapper, x, y)) { - if (dragX < -Config.session.dragThreshold) - screenState.session = true; - else if (dragX > Config.session.dragThreshold) - screenState.session = false; - - // Show sidebar on drag if in session area and session is nearly fully visible - if (showSidebar && panels.session.offsetScale <= 0 && dragX < -Config.sidebar.dragThreshold) - screenState.sidebar = true; - } else if (showSidebar && dragX < -Config.sidebar.dragThreshold) { - // Show sidebar on drag if not in session area - screenState.sidebar = true; - } - } else { - const outOfSidebar = x < width - panels.sidebar.width * (1 - panels.sidebar.offsetScale); - // Show osd on hover - const showOsd = outOfSidebar && inRightPanel(panels.osdWrapper, x, y); - - // Always update visibility based on hover if not in shortcut mode - if (!osdShortcutActive) { - screenState.osd = showOsd; - root.panels.osd.hovered = showOsd; - } else if (showOsd) { - // If hovering over OSD area while in shortcut mode, transition to hover control - osdShortcutActive = false; - root.panels.osd.hovered = true; - } - - // Show/hide session on drag - if (pressed && outOfSidebar && inRightPanel(panels.sessionWrapper, dragStart.x, dragStart.y) && withinPanelHeight(panels.sessionWrapper, x, y)) { - if (dragX < -Config.session.dragThreshold) - screenState.session = true; - else if (dragX > Config.session.dragThreshold) - screenState.session = false; - } - - // Show/hide sidebar on hover - if (Config.sidebar.showOnHover && !pressed) { - const sidebarTriggerY = Math.max(Config.sidebar.minHoverThreshold, panels.notifications.y + panels.notifications.height + borderThickness); - const showSidebarHover = x > Math.min(width - Config.border.minThickness, bar.implicitWidth + panels.sidebar.x) && y <= sidebarTriggerY; - if (showSidebarHover && !screenState.sidebar) { - screenState.sidebar = true; - } else { - const inSidebarArea = inRightPanel(panels.sidebar, x, y) || inRightPanel(panels.sessionWrapper, x, y); - if (!inSidebarArea) - screenState.sidebar = false; - } - } - - // Hide sidebar on drag - if (pressed && inRightPanel(panels.sidebar, dragStart.x, 0) && dragX > Config.sidebar.dragThreshold) - screenState.sidebar = false; - } - - // Show launcher on hover, or show/hide on drag if hover is disabled - if (Config.launcher.showOnHover) { - if (!screenState.launcher && inBottomPanel(panels.launcher, x, y)) - screenState.launcher = true; - } else if (pressed && inBottomPanel(panels.launcher, dragStart.x, dragStart.y) && withinPanelWidth(panels.launcher, x, y)) { - if (dragY < -Config.launcher.dragThreshold) - screenState.launcher = true; - else if (dragY > Config.launcher.dragThreshold) - screenState.launcher = false; - } - - // Show dashboard on hover - const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboard, x, y); - - // Always update visibility based on hover if not in shortcut mode if (!dashboardShortcutActive) { screenState.dashboard = showDashboard; } else if (showDashboard) { - // If hovering over dashboard area while in shortcut mode, transition to hover control dashboardShortcutActive = false; } - // Show/hide dashboard on drag (for touchscreen devices) - if (pressed && inTopPanel(panels.dashboard, dragStart.x, dragStart.y) && withinPanelWidth(panels.dashboard, x, y)) { + if (pressed && geometry.insetTop(borderThickness) > dragStart.y && withinPanelWidth(panels.dashboardWrapper, x, y)) { if (dragY > Config.dashboard.dragThreshold) screenState.dashboard = true; else if (dragY < -Config.dashboard.dragThreshold) screenState.dashboard = false; } - // Show utilities on hover - const showUtilities = inBottomPanel(panels.utilities, x, y, true); - - // Always update visibility based on hover if not in shortcut mode - if (!utilitiesShortcutActive) { - screenState.utilities = showUtilities; - } else if (showUtilities) { - // If hovering over utilities area while in shortcut mode, transition to hover control - utilitiesShortcutActive = false; + if (pressed && geometry.insetTop(borderThickness) > dragStart.y && (Config.dashboard.showOnHover || !inTopPanel(panels.dashboardWrapper, dragStart.x, dragStart.y))) { + if (dragY > 20) { + screenState.settings = true; + } else if (dragY < -20) { + screenState.settings = false; + } } - // Show popouts on hover - if (x < bar.implicitWidth) { - bar.checkPopout(y); - } else if ((!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) && !inLeftPanel(panels.popoutsWrapper, x, y)) { - popouts.hasCurrent = false; - bar.closeTray(); + if (pressed && dragStart.y > screen.height - geometry.insetBottom(borderThickness) && dragStart.x < screen.width / 5 && dragY < -50) + screenState.clipboard = true; + + if (pressed && !Config.dock.hoverToReveal && dragStart.y > screen.height - geometry.insetTop(borderThickness) && dragStart.x > screen.width / 5 && !screenState.launcher) + if (dragY < -10) + screenState.dock = true; + + if (pressed && dragStart.x > screen.width - Config.bar.border && dragStart.y < (screen.height / 2) && dragX < -Config.sidebar.dragThreshold) + screenState.sidebar = true; + + if (pressed && dragStart.x >= screen.width - Config.bar.border && dragStart.y > (screen.height / 2) && dragX < -20) + Hypr.dispatch(`hl.dsp.focus({ workspace = 'r+1', on_current_monitor = true })`); + + if (pressed && dragStart.x <= Config.bar.border && dragX > 20) + Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`); + + if (!screenState.bar && Config.bar.autoHide && geometry.barContains(x, y, true)) + bar.isHovered = true; + + if (panels.sidebar.offsetScale === 1) { + const showOsd = inRightPanel(panels.osdWrapper, x, y); + + if (showOsd) { + osdShortcutActive = false; + panels.osd.hovered = true; + } + } else { + const outOfSidebar = x < width - panels.sidebar.width * (1 - panels.sidebar.offsetScale); + const showOsd = outOfSidebar && inRightPanel(panels.osdWrapper, x, y); + + if (!osdShortcutActive) { + screenState.osd = showOsd; + panels.osd.hovered = showOsd; + } else if (showOsd) { + osdShortcutActive = false; + panels.osd.hovered = true; + } } + + if (Config.dock.enable && Config.dock.hoverToReveal && !screenState.dock && !screenState.launcher && inBottomPanel(panels.dock, x, y)) + screenState.dock = true; + + if (geometry.barContains(x, y)) + bar.checkPopout(geometry.axisPos(x, y)); } - // Monitor individual visibility changes Connections { - function onLauncherChanged() { - // If launcher is hidden, clear shortcut flags for dashboard and OSD + function onDashboardChanged(): void { + if (root.screenState.dashboard) { + const inDashboardArea = root.inTopPanel(root.panels.dashboardWrapper, root.mouseX, root.mouseY); + if (!inDashboardArea) { + root.dashboardShortcutActive = true; + } + + root.screenState.launcher = false; + root.screenState.settings = false; + root.screenState.sidebar = false; + root.screenState.resources = false; + root.popouts.hasCurrent = false; + } else { + root.dashboardShortcutActive = false; + } + } + + function onIsDrawingChanged(): void { + if (root.screenState.isDrawing) { + Cursor.setOverride(Qt.BlankCursor); + } else { + Cursor.clearOverride(); + } + } + + function onLauncherChanged(): void { if (!root.screenState.launcher) { root.dashboardShortcutActive = false; root.osdShortcutActive = false; root.utilitiesShortcutActive = false; - // Also hide dashboard and OSD if they're not being hovered - const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); - const inOsdArea = root.inRightPanel(root.panels.osdWrapper, root.mouseX, root.mouseY); + const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); - if (!inDashboardArea) { - root.screenState.dashboard = false; - } if (!inOsdArea) { root.screenState.osd = false; root.panels.osd.hovered = false; } } - } - function onDashboardChanged() { - if (root.screenState.dashboard) { - // Dashboard became visible, immediately check if this should be shortcut mode - const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); - if (!inDashboardArea) { - root.dashboardShortcutActive = true; - } - } else { - // Dashboard hidden, clear shortcut flag - root.dashboardShortcutActive = false; + if (root.screenState.launcher) { + root.screenState.dashboard = false; + root.screenState.dock = false; + root.screenState.settings = false; } } - function onOsdChanged() { + function onOsdChanged(): void { if (root.screenState.osd) { - // OSD became visible, immediately check if this should be shortcut mode - const inOsdArea = root.inRightPanel(root.panels.osdWrapper, root.mouseX, root.mouseY); + const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); if (!inOsdArea) { root.osdShortcutActive = true; } } else { - // OSD hidden, clear shortcut flag root.osdShortcutActive = false; } } - function onUtilitiesChanged() { - if (root.screenState.utilities) { - // Utilities became visible, immediately check if this should be shortcut mode - const inUtilitiesArea = root.inBottomPanel(root.panels.utilities, root.mouseX, root.mouseY); - if (!inUtilitiesArea) { - root.utilitiesShortcutActive = true; - } - } else { - // Utilities hidden, clear shortcut flag - root.utilitiesShortcutActive = false; + function onResourcesChanged(): void { + if (root.screenState.resources) { + if (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates")) + root.popouts.hasCurrent = false; + + root.screenState.settings = false; + root.screenState.dashboard = false; + } + } + + function onSettingsChanged(): void { + if (root.screenState.settings) { + root.screenState.resources = false; + root.screenState.dashboard = false; + root.screenState.sidebar = false; + root.panels.popouts.hasCurrent = false; + root.screenState.launcher = false; + } + } + + function onSidebarChanged(): void { + if (root.screenState.sidebar) { + if (root.panels.dashboardWrapper.x + root.panels.dashboardWrapper.width > root.panels.sidebar.x) + root.screenState.dashboard = false; + root.screenState.settings = false; + root.popouts.hasCurrent = false; } } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index b2c9887..2121c27 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -375,7 +375,7 @@ CustomWindow { } } - Interactions { + Interactions2 { id: interactions anchors.fill: parent diff --git a/Modules/Notifications/Content.qml b/Modules/Notifications/Content.qml index bad138a..c2b6c48 100644 --- a/Modules/Notifications/Content.qml +++ b/Modules/Notifications/Content.qml @@ -27,7 +27,7 @@ Item { for (let i = 0; i < count; i++) height += (list.itemAtIndex(i) as NotifWrapper)?.nonAnimHeight ?? 0; - if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x || screenState.dashboard) + if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x) return 0; if (screenState.osd) { @@ -47,8 +47,7 @@ Item { implicitWidth: Config.notifs.sizes.width + padding * 2 Behavior on implicitHeight { - Anim { - } + Anim {} } ClippingWrapperRectangle { @@ -65,8 +64,7 @@ Item { orientation: Qt.Vertical spacing: 0 - delegate: NotifWrapper { - } + delegate: NotifWrapper {} displaced: Transition { Anim { property: "y" diff --git a/Plugins/ZShell/Internal/cursor.hpp b/Plugins/ZShell/Internal/cursor.hpp index 7b1356e..7496195 100644 --- a/Plugins/ZShell/Internal/cursor.hpp +++ b/Plugins/ZShell/Internal/cursor.hpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -14,23 +16,25 @@ class Cursor : public QObject { public: Q_INVOKABLE void setOverride(Qt::CursorShape shape) { - if (m_override) + if (m_shape == shape) return; + + if (m_shape) QGuiApplication::changeOverrideCursor(QCursor(shape)); else { QGuiApplication::setOverrideCursor(QCursor(shape)); - m_override = true; } + m_shape = shape; } Q_INVOKABLE void clearOverride() { - if (!m_override) return; + if (!m_shape) return; QGuiApplication::restoreOverrideCursor(); - m_override = false; + m_shape.reset(); } private: - bool m_override = false; + std::optional m_shape; }; } // namespace ZShell::internal From 4ebb4ce859a5084efcb359320cb24ef098f2d451 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 00:41:01 +0200 Subject: [PATCH 23/90] feat: wallpaper cropper sdf shader rectangle, better confirmation controls + hide passive tray icons --- Modules/Bar/Components/TrayIcons.qml | 2 +- Modules/Settings/Common/WallpaperCropper.qml | 404 +++++++++++-------- assets/shaders/crop_sdf.frag | 40 ++ assets/shaders/crop_sdf.frag.qsb | Bin 0 -> 1999 bytes shell.qml | 20 +- 5 files changed, 294 insertions(+), 172 deletions(-) create mode 100644 assets/shaders/crop_sdf.frag create mode 100644 assets/shaders/crop_sdf.frag.qsb diff --git a/Modules/Bar/Components/TrayIcons.qml b/Modules/Bar/Components/TrayIcons.qml index 6fe8ac7..fae91a7 100644 --- a/Modules/Bar/Components/TrayIcons.qml +++ b/Modules/Bar/Components/TrayIcons.qml @@ -42,7 +42,7 @@ WidgetBase { Repeater { id: repeater - model: SystemTray.items + model: SystemTray.items.values.filter(i => i.status !== Status.Passive && i.hasMenu) TrayItem { id: trayItem diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index b2fea36..68076b0 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -26,10 +26,10 @@ Item { const cropRect = cropRectLoader.item; - const cropXPercent = (cropRect.x - cropRect.imageX) / scaledImg.paintedWidth; - const cropYPercent = (cropRect.y - cropRect.imageY) / scaledImg.paintedHeight; - const cropWidthPercent = cropRect.width / scaledImg.paintedWidth; - const cropHeightPercent = cropRect.height / scaledImg.paintedHeight; + const cropXPercent = (cropRect.x - cropRect.imageX) / cropRect.imageW; + const cropYPercent = (cropRect.y - cropRect.imageY) / cropRect.imageH; + const cropWidthPercent = cropRect.width / cropRect.imageW; + const cropHeightPercent = cropRect.height / cropRect.imageH; Wallpapers.setCrop(currentScreen.name, Qt.rect(cropXPercent, cropYPercent, cropWidthPercent, cropHeightPercent), cropRect.zoom); } @@ -79,16 +79,13 @@ Item { visible: opacity > 0 Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } Behavior on y { - Anim { - } + Anim {} } Component.onCompleted: { @@ -99,34 +96,10 @@ Item { Qt.callLater(syncCropToScreen); } - IconButton { - anchors.margins: Tokens.padding.small - anchors.right: parent.right - anchors.top: parent.top - icon: "check" - opacity: wrapper.changesMade ? 1 : 0 - scale: wrapper.changesMade ? 1 : 0 - z: 2 - - Behavior on opacity { - Anim { - } - } - Behavior on scale { - Anim { - } - } - - onClicked: { - wrapper.applyCrop(); - wrapper.changesMade = false; - } - } - ButtonRow { id: screenSelector - anchors.bottom: scaledImg.top + anchors.bottom: imageSlot.top anchors.bottomMargin: Tokens.spacing.medium anchors.left: parent.left anchors.leftMargin: Tokens.padding.extraLarge * 2 @@ -149,6 +122,7 @@ Item { isRound: true shapeMorph: true text: modelData.name + font: Tokens.font.body.builders.large.weight(Font.DemiBold).build() onClicked: wrapper.selectScreen(index) } @@ -178,16 +152,52 @@ Item { onInteraction: value => { wrapper.zoomClipRect(1 + (value * 4)); - wrapper.changesMade = true; + } + + onPosChanged: wrapper.changesMade = true + } + + ButtonRow { + spacing: Tokens.spacing.medium + + IconTextButton { + isRound: true + icon: "close" + text: qsTr("Cancel") + font: Tokens.font.body.large + shapeMorph: true + horizontalPadding: Tokens.padding.large + inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) + inactiveOnColor: Colors.palette.m3onSurfaceVariant + enabled: wrapper.changesMade + + onClicked: { + wrapper.syncCropToScreen(); + wrapper.changesMade = false; + } + } + + IconTextButton { + isRound: true + icon: "check" + text: qsTr("Apply") + font: Tokens.font.body.large + shapeMorph: true + horizontalPadding: Tokens.padding.large + inactiveColor: Colors.palette.m3secondary + inactiveOnColor: Colors.palette.m3onSecondary + enabled: wrapper.changesMade + + onClicked: { + wrapper.applyCrop(); + wrapper.changesMade = false; + } } } } - Image { - id: scaledImg - - property var displayData - property real monitorScale: 1.0 + Item { + id: imageSlot anchors.bottom: sliderLayout.top anchors.bottomMargin: Tokens.spacing.medium @@ -195,147 +205,225 @@ Item { anchors.right: parent.right anchors.top: parent.top anchors.topMargin: wrapper.buttonRowHeight + Tokens.spacing.medium - // anchors.top: screenSelector.bottom - asynchronous: true - fillMode: Image.PreserveAspectFit - retainWhileLoading: true - source: Wallpapers.current - sourceSize.height: parent.height - sourceSize.width: parent.width - onPaintedWidthChanged: { - if (paintedWidth > 0 && cropRectLoader.item) { - if (!wrapper.screens.length) - wrapper.refreshScreens(); + CustomClippingRect { + id: fitBox - wrapper.syncCropToScreen(); - } - } - onSourceChanged: { - if (cropRectLoader.item) { - if (!wrapper.screens.length) - wrapper.refreshScreens(); + readonly property real aspect: scaledImg.implicitHeight > 0 ? scaledImg.implicitWidth / scaledImg.implicitHeight : 1 + readonly property real fitW: Math.min(imageSlot.width, imageSlot.height * aspect) - wrapper.syncCropToScreen(); - } - } - onStatusChanged: { - if (scaledImg.status == Image.Ready && cropRectLoader.item) { - if (!wrapper.screens.length) - wrapper.refreshScreens(); + implicitHeight: Math.round(fitW / aspect) + implicitWidth: Math.round(fitW) + x: Math.round((imageSlot.width - width) / 2) + y: Math.round((imageSlot.height - height) / 2) + radius: Tokens.rounding.largeIncreased - wrapper.syncCropToScreen(); - } - } + Loader { + anchors.centerIn: parent - Loader { - id: cropRectLoader + opacity: scaledImg.status === Image.Ready ? 0 : 1 + active: opacity > 0 - active: scaledImg.status === Image.Ready + sourceComponent: CustomRect { + implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2 + implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2 - sourceComponent: Component { - CustomRect { - id: cropRect + color: Colors.palette.m3primaryContainer + radius: Tokens.rounding.full - property real aspectRatio: wrapper.currentScreen ? wrapper.currentScreen.width / wrapper.currentScreen.height : 1 - readonly property real baseHeight: baseWidth / aspectRatio - readonly property real baseWidth: { - let fittedHeight = scaledImg.paintedHeight; - let fittedWidth = fittedHeight * aspectRatio; + LoadingIndicator { + id: loadingIndicator - if (fittedWidth > scaledImg.paintedWidth) { - fittedWidth = scaledImg.paintedWidth; - fittedHeight = fittedWidth / aspectRatio; - } + anchors.centerIn: parent - return fittedWidth; + containsIcon: true + implicitSize: Math.min(fitBox.width, fitBox.height) * 0.3 } - readonly property real imageX: (scaledImg.width - scaledImg.paintedWidth) / 2 - readonly property real imageY: (scaledImg.height - scaledImg.paintedHeight) / 2 - property real imgAspectRatio: scaledImg.paintedWidth / scaledImg.paintedHeight - property real zoom: 1.0 + } - function centerInImage() { - x = imageX + (scaledImg.paintedWidth - width) / 2; - y = imageY + (scaledImg.paintedHeight - height) / 2; + Behavior on opacity { + Anim { + type: Anim.DefaultEffects } - - function clampToBounds() { - x = Math.max(imageX, Math.min(x, imageX + scaledImg.paintedWidth - width)); - y = Math.max(imageY, Math.min(y, imageY + scaledImg.paintedHeight - height)); - } - - function restoreFromData() { - let data = Wallpapers.getCrop(wrapper.currentScreen.name); - - if (data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001)) { - zoom = data.zoom > 0 ? data.zoom : 1.0; - x = imageX + (data.x * scaledImg.paintedWidth); - y = imageY + (data.y * scaledImg.paintedHeight); - - clampToBounds(); - } else { - zoom = 1.0; - centerInImage(); - } - } - - border.color: Colors.palette.m3primary - border.width: 2 - height: baseHeight / zoom - opacity: 1 - width: baseWidth / zoom - - Behavior on opacity { - Anim { - } - } - - Component.onCompleted: { - restoreFromData(); - } - onHeightChanged: clampToBounds() - onWidthChanged: clampToBounds() } } - } - MouseArea { - id: mouse + Image { + id: scaledImg - function updateCrop(mouseX, mouseY) { - if (!cropRectLoader.item) - return; + anchors.fill: parent + asynchronous: true + fillMode: Image.Stretch + source: Wallpapers.current + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(width * dpr, height * dpr); + } + retainWhileLoading: true + opacity: status === Image.Ready ? 1 : 0 - const cropRect = cropRectLoader.item; - - let nx = mouseX - cropRect.width * 0.5; - let ny = mouseY - cropRect.height * 0.5; - - nx = Math.max(cropRect.imageX, Math.min(nx, cropRect.imageX + scaledImg.paintedWidth - cropRect.width)); - ny = Math.max(cropRect.imageY, Math.min(ny, cropRect.imageY + scaledImg.paintedHeight - cropRect.height)); - - cropRect.x = nx; - cropRect.y = ny; + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } } - anchors.fill: parent - hoverEnabled: true - preventStealing: true + Loader { + id: cropRectLoader - onPositionChanged: mouse => { - if (pressed) { + active: scaledImg.status === Image.Ready + + sourceComponent: Component { + CustomRect { + id: cropRect + + property real aspectRatio: wrapper.currentScreen ? wrapper.currentScreen.width / wrapper.currentScreen.height : 1 + readonly property real baseHeight: baseWidth / aspectRatio + + readonly property real baseWidth: { + let fittedHeight = imageH; + let fittedWidth = fittedHeight * aspectRatio; + + if (fittedWidth > imageW) { + fittedWidth = imageW; + fittedHeight = fittedWidth / aspectRatio; + } + + return fittedWidth; + } + + readonly property real imageH: Math.round(fitBox.height) + readonly property real imageW: Math.round(fitBox.width) + readonly property real imageX: 0 + readonly property real imageY: 0 + property real imgAspectRatio: imageH > 0 ? imageW / imageH : 1 + property real zoom: 1.0 + + function centerInImage() { + x = Math.round(imageX + (imageW - width) / 2); + y = Math.round(imageY + (imageH - height) / 2); + } + + function clampToBounds() { + x = Math.max(imageX, Math.min(x, imageX + imageW - width)); + y = Math.max(imageY, Math.min(y, imageY + imageH - height)); + } + + function restoreFromData() { + let data = Wallpapers.getCrop(wrapper.currentScreen.name); + + if (data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001)) { + zoom = data.zoom > 0 ? data.zoom : 1.0; + x = Math.round(imageX + (data.x * imageW)); + y = Math.round(imageY + (data.y * imageH)); + + clampToBounds(); + } else { + zoom = 1.0; + centerInImage(); + } + } + + height: Math.round(baseHeight / zoom) + width: Math.round(baseWidth / zoom) + color: zoomSlider.dragging || mouse.pressed ? Qt.alpha(Colors.palette.m3tertiary, 0.3) : "transparent" + + Behavior on color { + CAnim {} + } + + Component.onCompleted: { + restoreFromData(); + } + onHeightChanged: clampToBounds() + onWidthChanged: clampToBounds() + } + } + } + + ShaderEffect { + anchors.fill: parent + + readonly property real dpr: (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1 + + function snap(v: real): real { + return Math.round(v * dpr) / dpr; + } + + property color borderColor: Colors.palette.m3tertiary + property vector4d cropBox: cropRectLoader.item ? Qt.vector4d(snap(cropRectLoader.item.x), snap(cropRectLoader.item.y), snap(cropRectLoader.item.width), snap(cropRectLoader.item.height)) : Qt.vector4d(0, 0, 0, 0) + property real borderWidth: 2 + property real cornerRadius: fitBox.radius + property vector2d itemSize: Qt.vector2d(width, height) + property real pixelScale: dpr + + fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/crop_sdf.frag.qsb`) + } + + MouseArea { + id: mouse + + property real grabX: 0 + property real grabY: 0 + + function insideCropRect(mx, my): bool { + const r = cropRectLoader.item; + if (!r) + return false; + + return mx >= r.x && mx <= r.x + r.width && my >= r.y && my <= r.y + r.height; + } + + function beginGrab(mx, my): void { + const r = cropRectLoader.item; + if (!r) + return; + + const inside = insideCropRect(mx, my); + + if (inside) { + grabX = mx - r.x; + grabY = my - r.y; + } else { + grabX = r.width * 0.5; + grabY = r.height * 0.5; + } + } + + function updateCrop(mx, my): void { + if (!cropRectLoader.item) + return; + + const cropRect = cropRectLoader.item; + + let nx = mx - grabX; + let ny = my - grabY; + + nx = Math.max(cropRect.imageX, Math.min(nx, cropRect.imageX + cropRect.imageW - cropRect.width)); + ny = Math.max(cropRect.imageY, Math.min(ny, cropRect.imageY + cropRect.imageH - cropRect.height)); + + cropRect.x = nx; + cropRect.y = ny; + } + + anchors.fill: parent + hoverEnabled: true + preventStealing: true + + cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor + + onPositionChanged: mouse => { + if (pressed) { + updateCrop(mouse.x, mouse.y); + wrapper.changesMade = true; + } + } + onPressed: mouse => { + beginGrab(mouse.x, mouse.y); updateCrop(mouse.x, mouse.y); - wrapper.changesMade = true; } } - onPressed: mouse => { - updateCrop(mouse.x, mouse.y); - wrapper.changesMade = true; - } - onReleased: { - wrapper.changesMade = true; - } } } } diff --git a/assets/shaders/crop_sdf.frag b/assets/shaders/crop_sdf.frag new file mode 100644 index 0000000..bbe2aa0 --- /dev/null +++ b/assets/shaders/crop_sdf.frag @@ -0,0 +1,40 @@ +#version 440 +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + vec2 itemSize; + vec4 cropBox; + vec4 borderColor; + float cornerRadius; + float borderWidth; + float pixelScale; +}; + +float sdRoundBox(vec2 p, vec2 halfSize, float r) { + vec2 q = abs(p) - halfSize + r; + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; +} + +void main() { + vec2 sz = itemSize * pixelScale; + vec2 p = qt_TexCoord0 * sz; + vec4 box = cropBox * pixelScale; + float r = cornerRadius * pixelScale; + float bw = borderWidth * pixelScale; + + float dImage = sdRoundBox(p - sz * 0.5, sz * 0.5, r); + float dCrop = sdRoundBox(p - (box.xy + box.zw * 0.5), box.zw * 0.5, 0.0); + + float d = max(dImage, dCrop); + + // ramp is exactly 1 physical pixel wide + float outer = clamp(0.5 - d, 0.0, 1.0); + float inner = clamp(0.5 - (d + bw), 0.0, 1.0); + float a = outer - inner; + + fragColor = vec4(borderColor.rgb, 1.0) * (borderColor.a * a * qt_Opacity); +} diff --git a/assets/shaders/crop_sdf.frag.qsb b/assets/shaders/crop_sdf.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..786ccc013b2e6be6fb8eca6da93f90d1f49cebc7 GIT binary patch literal 1999 zcmV;=2Qc^m03UyNob6X@R~y$A-5`OeOdKbE1hLX|N%PE^Gh(%4DtVuKr} zwNrT{4KR(Q5u*`Eq)we@>%5vaZQlKZ{RREf$2xcR6_)|hFHKk13bWQM?%8Lb$35rX zGXlVO03-nn02sp>LX>d>F1nb94<7+)C;%7)fPeA9E{`~P7|RIYU_~3(wRz)?yzriz75yKno74@DQTMkh3PY_;X0p zTr{u*51Vkg|I5r{$8!)>1n|+q9DJRW_`PN)Vn4@FVP#zeJ4$tDFS9XJHLa=57U7@< z4;{3*pVnUc*&gpEJk$_km1f~yW=GJ{C3aEA5~^@e*QF36v&aAHV;t7Dpa*Uz22Z9W z<^hc0uS6fhpw8j|hV|b|SC+2i+hHv~JtJoo2a(V`(LIQib}Vnh@tT5{0NmGQPG~$O zK9E4Z-{_=X%QK|qp}w4Adu;3^{~Bz7*sO&O61!|+31ZhQEJ^H+g$)sFS=cbK(85y0 zq&t_8)E9T0xf%H}OO`sEu;lxed>Df|=W!%-p0YlMgpR8KJc6W-H%y&EQeTb~Uy)_F z7|u9`k>+}YujnU`)Yx&xJ&GhAWeiaNsFjzI$605|BS*hdyD=m&3202@6G-dYOp=qL z&e)h&^8GRQBK#s{GyEj4Gt6s%d5An^?f)#-DNBFOs>c)5n|ME=uN32nTp}j=i&nnI z-#pKa=%1lKTmJ&ji;))@&(!%=RK^QNj)v^L3*zth^qpfqBHt%w{JqP%O5NY1?}XKt zk7NALxSw+v(z%*TE}Xw(Q0FSKz$x@3sOO~_qd`!L`lV6JYyAzY07{3ye z-57r@CI>NoJ0^ExaxW&o7L(tid=W$XEKT$L$r&s0zReI9j-03S6=| zo}!)LPa~o0{FLSMX`VYHUtllZru{5A7ieE3COOC%{3&yorw=(l&k{FzUfLzk%fuy5 zv3ZVmml=xtRp`EN9>P^m7 z-XqafC_jmm*2#zXAn%RXRmgjZIun1{+K2RUg?DuUDXm{6Ca?J}8?G1Q{+gJ)gPap) zU$0Ov`+A*No&I03YSds(bt_IY7RM*n+$BzndQ-nO`jmA*y{y~(Zkc+8te5bG*Cx-U z&eVLJv&qOCoHe8L(`Ah^cM${0`@$@&7w=-l6?l#LVn@ zpZD-xtJfb8U%-g|Mtw-^1MB=r&#yDaAIO1)eMD@)!v081Y_B1uWBrNtA5ky+yog~v zBmQjV_OZ3czffo7PdNL{-1wB7Ppo)k-$>ICd z&5=Ol4+=O4FXE9#Bz6m_S_J8s>{A2PR7mhT_2Ugi#E z$P@gZ7}T=#UfRL`i3T{*H3%gCc5>Yf+MeH3X9|UMD{!lxmR7x$)s|YW`%YNg0ej7` zm`=CDpi>Q1rL&xVHLa9V4JVvdYhn4S69!(ls4?w9z=f7m^}=3+uDjJK<%Moz$=h`M z(6p)serwL}T1dqYYHl#^*ZshDSoMRZ8!S3CuhX{ihe%QkY16=YeC$dxLoj8kc6T3I#O=VsM}R%e22lxE(rzQis`%Q^t$iWl=L~XErs@`D#)gI1R3&XYA^|c0-4O&=@{MvM#z-g$GYN<(;X>Y32s*s<_ z#tG#!s-%o|+qux%DOHjRZo#$Z8qSIUWm9%$IHO9^>uovC2Yzdp6w7B#ZqsIA3PM9wQgV!5tS&mhE7&rkL^=%Ah$IIherET@NA)=-1fJ45PHqs zW?)wj4Ms%xr&H?hniTVfl>@%l?h~=I7+BH7Ccn=H%43 hd`|8<5%-7Z{x8k_Uz-2#zciQsH^a04{2PY{g Date: Wed, 30 Sep 2026 00:50:31 +0200 Subject: [PATCH 24/90] fix: normalize zoom slider to linear scale --- Modules/Settings/Common/WallpaperCropper.qml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index 68076b0..bd1e8b0 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -13,13 +13,23 @@ import qs.Services Item { id: wrapper + readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null + readonly property real maxZoom: 5.0 + property int buttonRowHeight: Tokens.padding.large * 3 property bool changesMade: false - readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null property var screens: [] property int selectedScreenIndex: 0 property bool shouldBeActive: true + function zoomFromSlider(t: real): real { + return 1 / (1 - t * (1 - 1 / maxZoom)); + } + + function sliderFromZoom(zoom: real): real { + return (1 - 1 / zoom) / (1 - 1 / maxZoom); + } + function applyCrop(): void { if (!cropRectLoader.item || !currentScreen) return; @@ -144,14 +154,12 @@ Item { Layout.leftMargin: Tokens.padding.small Layout.preferredHeight: Tokens.padding.medium * 3 Layout.rightMargin: Tokens.padding.small - from: 1.0 implicitHeight: Tokens.padding.small * 3 insetIcon: "crop" - to: 5.0 - value: cropRectLoader.item ? cropRectLoader.item.zoom : 1.0 + value: cropRectLoader.item ? wrapper.sliderFromZoom(cropRectLoader.item.zoom) : 0 onInteraction: value => { - wrapper.zoomClipRect(1 + (value * 4)); + wrapper.zoomClipRect(wrapper.zoomFromSlider(value)); } onPosChanged: wrapper.changesMade = true From 62e19b5261fab37dd2d9ae95b26d905783fb534c Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 00:57:29 +0200 Subject: [PATCH 25/90] fix: show bar on hover --- Drawers/Interactions2.qml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Drawers/Interactions2.qml b/Drawers/Interactions2.qml index 25c3ed7..001db80 100644 --- a/Drawers/Interactions2.qml +++ b/Drawers/Interactions2.qml @@ -138,6 +138,9 @@ CustomMouseArea { return; } + if (root.bar.fullscreen && !root.bar.isHovered) + unhideTimer.start(); + if (pressed && dragStart.y >= screen.height - Config.bar.border && dragStart.x > screen.width / 5 && dragY < -Config.launcher.dragThreshold) screenState.launcher = true; @@ -210,6 +213,19 @@ CustomMouseArea { bar.checkPopout(geometry.axisPos(x, y)); } + Timer { + id: unhideTimer + + interval: Config.bar.revealDelay + repeat: false + running: false + + onTriggered: { + if (root.containsMouse) + root.bar.isHovered = true; + } + } + Connections { function onDashboardChanged(): void { if (root.screenState.dashboard) { From a22022e96915f5f368a377a9192aec991e961f92 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 13:58:35 +0200 Subject: [PATCH 26/90] fix: changesMade bool now binding for robustness --- Modules/Settings/Common/WallpaperCropper.qml | 58 ++++++++++++++------ shell.qml | 2 +- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index bd1e8b0..0b5b0d9 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -16,8 +16,20 @@ Item { readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null readonly property real maxZoom: 5.0 + readonly property bool changesMade: { + cropRevision; + + const r = cropRectLoader.item; + if (!r || !currentScreen || scaledImg.status !== Image.Ready) + return false; + + const t = r.savedTarget(); + + return Math.abs(r.x - t.x) > 0.5 || Math.abs(r.y - t.y) > 0.5 || Math.abs(r.zoom - t.zoom) > 0.001; + } + + property int cropRevision: 0 property int buttonRowHeight: Tokens.padding.large * 3 - property bool changesMade: false property var screens: [] property int selectedScreenIndex: 0 property bool shouldBeActive: true @@ -42,6 +54,7 @@ Item { const cropHeightPercent = cropRect.height / cropRect.imageH; Wallpapers.setCrop(currentScreen.name, Qt.rect(cropXPercent, cropYPercent, cropWidthPercent, cropHeightPercent), cropRect.zoom); + cropRevision++; } function refreshScreens(): void { @@ -161,8 +174,6 @@ Item { onInteraction: value => { wrapper.zoomClipRect(wrapper.zoomFromSlider(value)); } - - onPosChanged: wrapper.changesMade = true } ButtonRow { @@ -181,7 +192,6 @@ Item { onClicked: { wrapper.syncCropToScreen(); - wrapper.changesMade = false; } } @@ -198,7 +208,6 @@ Item { onClicked: { wrapper.applyCrop(); - wrapper.changesMade = false; } } } @@ -318,19 +327,33 @@ Item { y = Math.max(imageY, Math.min(y, imageY + imageH - height)); } + // Where the rect should be according to saved data (same rules as a restore). + function savedTarget() { + const data = Wallpapers.getCrop(wrapper.currentScreen.name); + const hasCrop = data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001); + + const z = hasCrop && data.zoom > 0 ? data.zoom : 1.0; + const w = Math.round(baseWidth / z); + const h = Math.round(baseHeight / z); + + let tx = hasCrop ? Math.round(imageX + data.x * imageW) : Math.round(imageX + (imageW - w) / 2); + let ty = hasCrop ? Math.round(imageY + data.y * imageH) : Math.round(imageY + (imageH - h) / 2); + + tx = Math.max(imageX, Math.min(tx, imageX + imageW - w)); + ty = Math.max(imageY, Math.min(ty, imageY + imageH - h)); + + return { + x: tx, + y: ty, + zoom: z + }; + } + function restoreFromData() { - let data = Wallpapers.getCrop(wrapper.currentScreen.name); - - if (data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001)) { - zoom = data.zoom > 0 ? data.zoom : 1.0; - x = Math.round(imageX + (data.x * imageW)); - y = Math.round(imageY + (data.y * imageH)); - - clampToBounds(); - } else { - zoom = 1.0; - centerInImage(); - } + const t = savedTarget(); + zoom = t.zoom; + x = t.x; + y = t.y; } height: Math.round(baseHeight / zoom) @@ -424,7 +447,6 @@ Item { onPositionChanged: mouse => { if (pressed) { updateCrop(mouse.x, mouse.y); - wrapper.changesMade = true; } } onPressed: mouse => { diff --git a/shell.qml b/shell.qml index 60ec9ed..6df3f3a 100644 --- a/shell.qml +++ b/shell.qml @@ -3,7 +3,7 @@ // @ pragma DefaultEnv QSG_RHI_BACKEND=vulkan //@ pragma DefaultEnv QSG_NO_VSYNC=1 //@ pragma DefaultEnv QS_NO_RELOAD_POPUP=1 -// @ pragma DefaultEnv QT_SCALE_FACTOR_ROUNDING_POLICY=Round +//@ pragma DefaultEnv QT_SCALE_FACTOR_ROUNDING_POLICY=Round //@ pragma DefaultEnv QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 //@ pragma DropExpensiveFonts import Quickshell From 60293abe61cbb83f655786b1441a3470673d9160 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 28 Sep 2026 13:11:31 +0200 Subject: [PATCH 27/90] fix: define declared method + insert new bar entries to config file list + rename Hyprsunset -> Nightlight From 08bb221699af516403da3f383a085d665414f361 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 31 Aug 2026 18:43:41 +0200 Subject: [PATCH 28/90] feat: config object migration path and add new options to settings --- Greeter/Services/Colors.qml | 15 +-- Helpers/ModeScheduler.qml | 12 +- Helpers/Nightlight.qml | 14 +- Helpers/Wallpapers.qml | 6 +- Modules/Settings/Pages.qml | 22 ++-- Modules/Settings/Pages/ServicesPage.qml | 4 +- .../Settings/Pages/Wallpaper/ColorsFonts.qml | 14 +- Modules/Settings/Pages/WallpaperPage.qml | 79 ++++++++--- Plugins/ZShell/Config/CMakeLists.txt | 7 +- Plugins/ZShell/Config/migration.cpp | 123 ++++++++++++++++++ Plugins/ZShell/Config/migration.hpp | 38 ++++++ Plugins/ZShell/Services/nightlightmanager.cpp | 2 - Services/Colors.qml | 14 +- cli/src/zshell/subcommands/scheme.py | 4 +- 14 files changed, 274 insertions(+), 80 deletions(-) create mode 100644 Plugins/ZShell/Config/migration.cpp create mode 100644 Plugins/ZShell/Config/migration.hpp diff --git a/Greeter/Services/Colors.qml b/Greeter/Services/Colors.qml index ee9d678..ac4dce6 100644 --- a/Greeter/Services/Colors.qml +++ b/Greeter/Services/Colors.qml @@ -13,21 +13,17 @@ import qs.Paths Singleton { id: root - readonly property M3Palette current: M3Palette { - } + readonly property M3Palette current: M3Palette {} property bool currentLight property string flavour readonly property bool light: showPreview ? previewLight : currentLight readonly property M3Palette palette: showPreview ? preview : current - readonly property M3Palette preview: M3Palette { - } + readonly property M3Palette preview: M3Palette {} property bool previewLight property string scheme property bool showPreview - readonly property M3TPalette tPalette: M3TPalette { - } - readonly property Transparency transparency: Transparency { - } + readonly property M3TPalette tPalette: M3TPalette {} + readonly property Transparency transparency: Transparency {} readonly property alias wallLuminance: analyser.luminance function alterColor(c: color, a: real, layer: int): color { @@ -112,8 +108,7 @@ Singleton { function setMode(mode: string): void { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]); - Config.general.color.mode = mode; - Config.save(); + Config.colors.mode = mode; } function swapRG(c: color): color { diff --git a/Helpers/ModeScheduler.qml b/Helpers/ModeScheduler.qml index a45e15a..14b6aa8 100644 --- a/Helpers/ModeScheduler.qml +++ b/Helpers/ModeScheduler.qml @@ -11,14 +11,14 @@ import qs.Services Singleton { id: root - readonly property int darkEnd: Config.general.color.scheduleDarkEnd - readonly property int darkStart: Config.general.color.scheduleDarkStart - readonly property bool enabled: Config.general.color.scheduleDark && Config.general.color.schemeGeneration + readonly property int darkEnd: Config.colors.scheduleDarkEnd + readonly property int darkStart: Config.colors.scheduleDarkStart + readonly property bool enabled: Config.colors.scheduleDark && Config.colors.schemeGen function applyDarkMode() { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "dark"]); - Config.general.color.mode = "dark"; + Config.colors.mode = "dark"; Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-dark'"]); @@ -30,13 +30,13 @@ Singleton { function applyLightMode() { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "light"]); - Config.general.color.mode = "light"; + Config.colors.mode = "light"; Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-light'"]); Quickshell.execDetached(["sh", "-c", `sed -i 's/color_scheme_path=\\(.*\\)Dark.colors/color_scheme_path=\\1Light.colors/' ${Paths.home}/.config/qt6ct/qt6ct.conf`]); - if (Config.general.color.neovimColors) + if (Config.colors.neovimColors) Quickshell.execDetached(["sed", "-i", "'s/\\(vim.cmd.colorscheme \\).*/\\1\"onelight\"/'", "~/.config/nvim/lua/config/load-colorscheme.lua"]); } diff --git a/Helpers/Nightlight.qml b/Helpers/Nightlight.qml index 540091d..29857f3 100644 --- a/Helpers/Nightlight.qml +++ b/Helpers/Nightlight.qml @@ -9,13 +9,13 @@ Singleton { id: root readonly property bool enabled: service.enabled - readonly property int end: Config.general.color.scheduleHyprsunsetEnd + readonly property int end: Config.display.nightlight.scheduleEnd property bool manualToggle: false - readonly property int start: Config.general.color.scheduleHyprsunsetStart - readonly property int temp: Config.general.color.hyprsunsetTemp + readonly property int start: Config.display.nightlight.scheduleStart + readonly property int temp: Config.display.nightlight.temp function checkStartup(): void { - if (!Config.general.color.scheduleHyprsunset) + if (!Config.display.nightlight.schedule) return; service.apply(); @@ -29,11 +29,11 @@ Singleton { NightlightManager { id: service - activeAuto: Config.general.color.scheduleHyprsunset + activeAuto: Config.display.nightlight.schedule endTime: root.end startTime: root.start temp: root.temp - fadeDuration: Config.general.color.nativeFadeDuration / 1000 - useNativeNightlight: Config.general.color.useNativeNightlight + fadeDuration: Config.display.nightlight.fadeDuration / 1000 + useNativeNightlight: Config.display.nightlight.useNative } } diff --git a/Helpers/Wallpapers.qml b/Helpers/Wallpapers.qml index ba4b64c..d02b01a 100644 --- a/Helpers/Wallpapers.qml +++ b/Helpers/Wallpapers.qml @@ -38,7 +38,7 @@ Searcher { previewPath = path; showPreview = true; - if (Config.general.color.schemeGeneration) + if (Config.colors.schemeGen) previewColorsProc.running = true; } @@ -62,8 +62,8 @@ Searcher { WallpaperPath.currentWallpaperPath = path; Quickshell.screens.forEach(n => setCrop(n.name, Qt.rect(0, 0, 1, 1), 1.0)); Quickshell.execDetached(["zshell-cli", "wallpaper", "lockscreen", "--input-image", `${root.actualCurrent}`, "--output-path", `${Paths.state}/lockscreen_bg.png`, "--blur-amount", `${Config.lock.blurAmount}`]); - if (Config.general.color.schemeGeneration) - Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.general.color.mode}`]); + if (Config.colors.schemeGen) + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.colors.mode}`]); } function stopPreview(): void { diff --git a/Modules/Settings/Pages.qml b/Modules/Settings/Pages.qml index ad148b1..4aacc6d 100644 --- a/Modules/Settings/Pages.qml +++ b/Modules/Settings/Pages.qml @@ -43,17 +43,17 @@ Item { Component.onCompleted: root.loadPage(root.sState.currentPageIdx) - CustomRect { - anchors.fill: parent - color: Qt.alpha(Colors.palette.m3shadow, 0.3) - opacity: root.sState.dimmed ? 1 : 0 - visible: opacity > 0 - z: 0 - - Behavior on opacity { - Anim {} - } - } + // CustomRect { + // anchors.fill: parent + // color: Qt.alpha(Colors.palette.m3shadow, 0.3) + // opacity: root.sState.dimmed ? 1 : 0 + // visible: opacity > 0 + // z: 0 + // + // Behavior on opacity { + // Anim {} + // } + // } } Connections { diff --git a/Modules/Settings/Pages/ServicesPage.qml b/Modules/Settings/Pages/ServicesPage.qml index 11b655e..fe827bf 100644 --- a/Modules/Settings/Pages/ServicesPage.qml +++ b/Modules/Settings/Pages/ServicesPage.qml @@ -202,12 +202,12 @@ PageBase { } ToggleRow { - checked: Config.general.color.smart + checked: Config.colors.smart settingAnchor: "services-smart-color-scheme" subtext: qsTr("Derive theme mode from the wallpaper") text: qsTr("Smart color scheme") - onToggled: Config.general.color.smart = checked + onToggled: Config.colors.smart = checked } SelectRow { diff --git a/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml b/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml index 44a490e..be09d14 100644 --- a/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml +++ b/Modules/Settings/Pages/Wallpaper/ColorsFonts.qml @@ -73,15 +73,15 @@ PageBase { ToggleRow { text: qsTr("Dynamic colors") first: true - subtext: qsTr("Automatic color scheme based on wallpaper is %1").arg(Config.general.color.schemeGeneration ? "enabled" : "disabled") - checked: Config.general.color.schemeGeneration + subtext: qsTr("Automatic color scheme based on wallpaper is %1").arg(Config.colors.schemeGen ? "enabled" : "disabled") + checked: Config.colors.schemeGen settingAnchor: "colors-fonts-dynamic-colors" onToggled: { - Config.general.color.schemeGeneration = checked; + Config.colors.schemeGen = checked; if (checked) - Colors.setMode(Config.general.color.mode); + Colors.setMode(Config.colors.mode); } } @@ -89,7 +89,7 @@ PageBase { settingAnchor: "colors-fonts-preset-scheme" model: root.schemes acceptLabel: qsTr("Confirm") - enabled: !Config.general.color.schemeGeneration + enabled: !Config.colors.schemeGen header: qsTr("Select scheme") rowButton.activeItem: root.schemes.find(s => { var regex = new RegExp(Config.colors.presets.name, "i"); @@ -157,7 +157,7 @@ PageBase { } // qmlformat on acceptLabel: qsTr("Confirm") - enabled: !Config.general.color.schemeGeneration && model.length > 1 + enabled: !Config.colors.schemeGen && model.length > 1 header: qsTr("Select variant") rowButton.activeItem: { const v = Config.colors.presets.variant ?? ""; @@ -210,7 +210,7 @@ PageBase { } // qmlformat on acceptLabel: qsTr("Confirm") - enabled: !Config.general.color.schemeGeneration && model.length > 1 + enabled: !Config.colors.schemeGen && model.length > 1 header: qsTr("Select accent") rowButton.activeItem: { const a = Config.colors.presets.accent ?? ""; diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml index ee78ddc..570019f 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -96,20 +96,21 @@ PageBase { TimeDialogSelect { readonly property string endTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleDarkEnd); + d.setMinutes(Config.colors.scheduleDarkEnd); return Qt.formatTime(d, "hh:mm AP"); } readonly property string startTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleDarkStart); + d.setMinutes(Config.colors.scheduleDarkStart); return Qt.formatTime(d, "hh:mm AP"); } - rowButton.checked: Config.general.color.scheduleDark - rowButton.onToggled: Config.general.color.scheduleDark = rowButton.checked - start: Config.general.color.scheduleDarkStart - end: Config.general.color.scheduleDarkEnd + rowButton.checked: Config.colors.scheduleDark + rowButton.onToggled: Config.colors.scheduleDark = rowButton.checked + start: Config.colors.scheduleDarkStart + end: Config.colors.scheduleDarkEnd first: true + last: true settingAnchor: "style-schedule-dark-mode" acceptLabel: qsTr("Apply") rootParent: root.flickable @@ -122,8 +123,8 @@ PageBase { } onAccepted: { - Config.general.color.scheduleDarkStart = startCurrent; - Config.general.color.scheduleDarkEnd = endCurrent; + Config.colors.scheduleDarkStart = startCurrent; + Config.colors.scheduleDarkEnd = endCurrent; ModeScheduler.checkStartup(); } } @@ -131,27 +132,26 @@ PageBase { TimeDialogSelect { readonly property string endTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleHyprsunsetEnd); + d.setMinutes(Config.display.nightlight.scheduleEnd); return Qt.formatTime(d, "hh:mm AP"); } readonly property string startTime: { var d = new Date(0, 0, 0, 0, 0, 0, 0); - d.setMinutes(Config.general.color.scheduleHyprsunsetStart); + d.setMinutes(Config.display.nightlight.scheduleStart); return Qt.formatTime(d, "hh:mm AP"); } - Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing - rowButton.checked: Config.general.color.scheduleHyprsunset - rowButton.onToggled: Config.general.color.scheduleHyprsunset = rowButton.checked - start: Config.general.color.scheduleHyprsunsetStart - end: Config.general.color.scheduleHyprsunsetEnd - last: true - settingAnchor: "style-schedule-hyprsunset" + rowButton.checked: Config.display.nightlight.schedule + rowButton.onToggled: Config.display.nightlight.schedule = rowButton.checked + start: Config.display.nightlight.scheduleStart + end: Config.display.nightlight.scheduleEnd + first: true + settingAnchor: "style-schedule-nightlight" acceptLabel: qsTr("Apply") rootParent: root.flickable header: qsTr("Select time") - subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) - label: qsTr("Schedule hyprsunset") + subtext: qsTr("Nightlight will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) + label: qsTr("Schedule nightlight") onOpenChanged: { root.sState.dimmed = open; @@ -163,5 +163,46 @@ PageBase { Nightlight.checkStartup(); } } + + ToggleRow { + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + + checked: Config.display.nightlight.useNative + settingAnchor: "display-nightlight-useNative" + text: qsTr("Use native") + subtext: qsTr("Use native shader nightlight for more features") + + onToggled: Config.display.nightlight.useNative = checked + } + + SpinRow { + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + + from: 2000 + settingAnchor: "display-nightlight-temp" + stepSize: 100 + subtext: qsTr("How warm/orange the screen will be. Lower is warmer (Kelvin)") + text: qsTr("Temperature") + to: 6500 + value: Config.display.nightlight.temp + + onMoved: v => Config.display.nightlight.temp = v + } + + SpinRow { + Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing + + from: 0 + settingAnchor: "display-nightlight-fade-duration" + enabled: Config.display.nightlight.useNative + last: true + stepSize: 1 + subtext: qsTr("How long it takes for the gradual nightlight to finish (s)") + text: qsTr("Fade duration") + to: 60 * 5 + value: Config.display.nightlight.fadeDuration / 1000 + + onMoved: v => Config.display.nightlight.fadeDuration = v * 1000 + } } } diff --git a/Plugins/ZShell/Config/CMakeLists.txt b/Plugins/ZShell/Config/CMakeLists.txt index cb55a84..350ab9a 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -4,8 +4,10 @@ qml_module(ZShell-config DEPENDENCIES ZShell.Settings SOURCES - common.hpp common.cpp - rootnodes.hpp rootnodes.cpp + confignode.hpp confignode.cpp + configobject.hpp configobject.cpp + configlist.hpp configlist.cpp + config.hpp config.cpp migration.hpp migration.cpp anim.hpp anim.cpp fontbuilder.hpp fontbuilder.cpp @@ -17,6 +19,7 @@ qml_module(ZShell-config clipboard.hpp colors.hpp dashboard.hpp + display.hpp dock.hpp enums.hpp general.hpp diff --git a/Plugins/ZShell/Config/migration.cpp b/Plugins/ZShell/Config/migration.cpp new file mode 100644 index 0000000..da78206 --- /dev/null +++ b/Plugins/ZShell/Config/migration.cpp @@ -0,0 +1,123 @@ +#include "migration.hpp" + +#include + +namespace ZShell::config { + +namespace { + +// A stored value is "missing" if the path does not resolve to an entry. +// Note: default-constructed QJsonValue is Null, not Undefined. +bool isMissing(const QJsonValue& v) { + return v.isUndefined() || v.isNull(); +} + +QJsonValue valueAt(const QJsonObject& obj, const QStringList& path) { + QJsonObject cur = obj; + + for (int i = 0; i < path.size() - 1; ++i) { + if (!cur.value(path.at(i)).isObject()) return QJsonValue::Undefined; + cur = cur.value(path.at(i)).toObject(); + } + + const QString key = path.last(); + if (!cur.contains(key)) return QJsonValue::Undefined; + return cur.value(key); +} + +QJsonObject placeAt(QJsonObject obj, QStringList path, const QJsonValue& value) { + const QString head = path.takeFirst(); + + if (path.isEmpty()) { + obj[head] = value; + return obj; + } + + QJsonObject child = obj.value(head).isObject() ? obj.value(head).toObject() + : QJsonObject(); + obj[head] = placeAt(child, path, value); + return obj; +} + +// Removes `path` from `obj`. Returns true if a value was removed. +// Parent objects left empty are removed as well. +bool removeAt(QJsonObject& obj, const QStringList& path) { + const QString head = path.first(); + + if (path.size() == 1) { + if (!obj.contains(head)) return false; + obj.remove(head); + return true; + } + + if (!obj.value(head).isObject()) return false; + + QJsonObject child = obj.value(head).toObject(); + const bool removed = removeAt(child, path.mid(1)); + + if (child.isEmpty()) + obj.remove(head); + else + obj[head] = child; + + return removed; +} + +} // namespace + +const QList& ConfigMigrations::rules() { + static const QList s_rules = { + migrate( + "general.color.scheduleHyprsunset", "display.nightlight.schedule"), + migrate( + "general.color.scheduleHyprsunsetStart", + "display.nightlight.scheduleStart"), + migrate( + "general.color.scheduleHyprsunsetEnd", + "display.nightlight.scheduleEnd"), + migrate("general.color.scheduleDark", "colors.scheduleDark"), + migrate("general.color.scheduleDarkStart", "colors.scheduleDarkStart"), + migrate("general.color.scheduleDarkEnd", "colors.scheduleDarkEnd"), + migrate("general.color.schemeGeneration", "colors.schemeGen"), + migrate( + "general.color.useNativeNightlight", + "display.nightlight.useNative"), + migrate( + "general.color.nativeFadeDuration", + "display.nightlight.fadeDuration"), + migrate("general.color.hyprsunsetTemp", "display.nightlight.temp"), + migrate("general.color.neovimColors", "colors.neovimColors"), + migrate("general.color.mode", "colors.mode"), + migrate("general.color.smart", "colors.smart"), + }; + return s_rules; +} + +QJsonObject ConfigMigrations::apply(const QJsonObject& json) { + QJsonObject obj = json; + + for (const auto& rule : rules()) { + const QStringList from = + rule.from.split(QLatin1Char('.'), Qt::SkipEmptyParts); + const QStringList to = + rule.to.split(QLatin1Char('.'), Qt::SkipEmptyParts); + + if (from.isEmpty() || to.isEmpty()) continue; + + const QJsonValue oldValue = valueAt(obj, from); + if (isMissing(oldValue)) continue; + + const bool targetExists = !isMissing(valueAt(obj, to)); + if (!targetExists) obj = placeAt(obj, to, oldValue); + + if (removeAt(obj, from)) { + qInfo() << "Config migration:" << rule.from << "->" << rule.to + << (targetExists ? "(target already set, old value dropped)" + : "(value moved)"); + } + } + + return obj; +} + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/migration.hpp b/Plugins/ZShell/Config/migration.hpp new file mode 100644 index 0000000..1c03505 --- /dev/null +++ b/Plugins/ZShell/Config/migration.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +namespace ZShell::config { + +// A single migration rule: move the value stored at the dotted `from` path +// to the dotted `to` path, then delete the old key (and any parent objects +// left empty). +// +// Rules are applied to the raw JSON file on every load, before the schema +// loads it. They are idempotent: once the old key is gone the rule never +// fires again, so no version stamping is needed. Old rules can stay in the +// table forever. +// +// WARNING: the target path must exist in the schema (i.e. a ConfigObject +// property somewhere in the tree). If it does not, the migrated value is +// loaded as an unknown key and silently dropped on the next save. +struct ConfigMigrationRule { + QString from; + QString to; +}; + +// Helper for writing rules: migrate("general.color.foo", "display.bar.foo") +inline ConfigMigrationRule migrate(const QString& from, const QString& to) { + return {from, to}; +} + +class ConfigMigrations { + public: + [[nodiscard]] static const QList& rules(); + + // Applies all rules to `json`, returning the migrated object. + [[nodiscard]] static QJsonObject apply(const QJsonObject& json); +}; + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Services/nightlightmanager.cpp b/Plugins/ZShell/Services/nightlightmanager.cpp index 78658d4..ea50b1d 100644 --- a/Plugins/ZShell/Services/nightlightmanager.cpp +++ b/Plugins/ZShell/Services/nightlightmanager.cpp @@ -10,7 +10,6 @@ namespace ZShell::services { namespace { constexpr const char* NL_EVENT = "zshell-nightlight"; -constexpr int NEUTRAL_KELVIN = 6500; constexpr int RECONNECT_MS = 2000; } // namespace @@ -404,7 +403,6 @@ void NightlightManager::handleEventLine(const QByteArray& line) { if (line.left(sep) != NL_EVENT) return; - // data = enabled,animating,last,current const auto fields = line.mid(sep + 2).split(','); if (fields.size() < 4) return; diff --git a/Services/Colors.qml b/Services/Colors.qml index 863c03f..970281d 100644 --- a/Services/Colors.qml +++ b/Services/Colors.qml @@ -13,21 +13,17 @@ import qs.Paths Singleton { id: root - readonly property M3Palette current: M3Palette { - } + readonly property M3Palette current: M3Palette {} property bool currentLight property string flavour readonly property bool light: showPreview ? previewLight : currentLight readonly property M3Palette palette: showPreview ? preview : current - readonly property M3Palette preview: M3Palette { - } + readonly property M3Palette preview: M3Palette {} property bool previewLight property string scheme property bool showPreview - readonly property M3TPalette tPalette: M3TPalette { - } - readonly property Transparency transparency: Transparency { - } + readonly property M3TPalette tPalette: M3TPalette {} + readonly property Transparency transparency: Transparency {} readonly property alias wallLuminance: analyser.luminance function alterColor(c: color, a: real, layer: int): color { @@ -112,7 +108,7 @@ Singleton { function setMode(mode: string): void { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]); - Config.general.color.mode = mode; + Config.colors.mode = mode; } function swapRG(c: color): color { diff --git a/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index 2a65612..f0c8e72 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -625,8 +625,8 @@ def generate( scheme_type = config["colors"].get("schemeType", "fruit-salad") scheme = scheme or scheme_type assert isinstance(scheme, str) - config_mode = config["general"]["color"]["mode"] - smart = bool(config["general"]["color"].get("smart", False)) + config_mode = config["colors"]["mode"] + smart = bool(config["colors"].get("smart", False)) scheme_class = get_scheme_class(scheme) p_variant = "default" From 7cc673430c17e1365e5487f4d0339558734105fb Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 14:48:38 +0200 Subject: [PATCH 29/90] separate dashboard and settings changes to new branch --- Components/CustomScrollBar.qml | 7 ++----- Drawers/Panels.qml | 11 +++++------ Modules/Settings/Common/DialogSelectButton.qml | 1 + 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index dedd428..f889cac 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -17,12 +17,9 @@ ScrollBar { property bool shouldBeActive readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0 - property bool shouldBeActive - property bool inverted - enabled: !ShellState.forActive().isDrawing - implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2 - implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0 + implicitHeight: Tokens.padding.extraSmall * 2 + implicitWidth: Tokens.padding.extraSmall * 2 contentItem: Item { } diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 18ecd64..50dd9c8 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -37,12 +37,12 @@ Item { readonly property alias resources: resources readonly property alias resourcesWrapper: resourcesWrapper required property ShellScreen screen + required property ScreenState screenState readonly property alias settings: settings readonly property alias settingsWrapper: settingsWrapper readonly property alias sidebar: sidebar readonly property alias toasts: toasts readonly property alias utilities: utilities - required property ScreenState screenState anchors.bottomMargin: geometry.insetBottom(borderThickness) anchors.fill: parent @@ -84,8 +84,8 @@ Item { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter screen: root.screen - sidebarOrSessionVisible: sidebar.visible screenState: root.screenState + sidebarOrSessionVisible: sidebar.visible } } @@ -121,8 +121,8 @@ Item { anchors.right: parent.right anchors.top: parent.top panels: root - sidebarPanel: sidebar screenState: root.screenState + sidebarPanel: sidebar } Launcher.Wrapper { @@ -140,9 +140,8 @@ Item { anchors.bottom: parent.bottom anchors.right: parent.right - popouts: popouts - sidebar: sidebar screenState: root.screenState + sidebar: sidebar } Item { @@ -150,8 +149,8 @@ Item { property real offsetScale: dashboard.shouldBeActive ? 0 : 1 - anchors.top: parent.top anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top clip: true implicitHeight: dashboard.implicitHeight * (1 - offsetScale) implicitWidth: dashboard.implicitWidth diff --git a/Modules/Settings/Common/DialogSelectButton.qml b/Modules/Settings/Common/DialogSelectButton.qml index 5866d6b..505f58b 100644 --- a/Modules/Settings/Common/DialogSelectButton.qml +++ b/Modules/Settings/Common/DialogSelectButton.qml @@ -8,6 +8,7 @@ import qs.Services DialogRowButton { id: root + property var initialSelect required property var model property var selectedItem From 1ab49d4b38a20630592239e832f9da1ac0888fd1 Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 28 Aug 2026 14:15:57 +0200 Subject: [PATCH 30/90] better support for opening chat in floating window --- Components/CustomScrollBar.qml | 6 +- .../Sidebar/Chat/ChatContent.qml | 22 +-- .../Sidebar/Chat/ChatDelegate.qml | 108 ++++++++++---- .../Notifications/Sidebar/Chat/ChatList.qml | 47 ++++++- .../Notifications/Sidebar/Chat/ChatPanel.qml | 56 +++----- .../Notifications/Sidebar/Chat/ChatState.qml | 2 + .../Sidebar/Chat/Content/ChatHost.qml | 132 ++++++++++++++++++ .../Sidebar/Chat/Content/EmptyBackground.qml | 38 ++--- .../Sidebar/Chat/Content/MathBlockView.qml | 1 - .../Sidebar/Chat/Content/MessageDelegate.qml | 45 ++++-- Modules/Notifications/Sidebar/Chat/Detach.qml | 4 +- .../Sidebar/Chat/SidebarView.qml | 119 +++++++++------- 12 files changed, 417 insertions(+), 163 deletions(-) create mode 100644 Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index 810dd2e..6df6539 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -12,7 +12,7 @@ ScrollBar { readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root readonly property real axisSize: isHorizontal ? flickable.width : flickable.height readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight - readonly property real axisContentPos: isHorizontal ? flickable.contentX : flickable.contentY + readonly property real axisContentPos: isHorizontal ? (flickable.contentX - flickable.originX) : (flickable.contentY - flickable.originY) readonly property real axisLength: isHorizontal ? root.width : root.height readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize) readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize) @@ -102,9 +102,9 @@ ScrollBar { var newPos = contentPosFromThumbStart(thumbStart); if (root.isHorizontal) - root.flickable.contentX = newPos; + root.flickable.contentX = newPos + root.flickable.originX; else - root.flickable.contentY = newPos; + root.flickable.contentY = newPos + root.flickable.originY; } function visualThumbStart() { diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 2413981..85e2a6d 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -87,10 +87,6 @@ Item { signal requestClose - function scrollToBottom(): void { - Qt.callLater(() => listLoader.item?.positionViewAtBeginning()); - } - function send(text: string): void { if (text.trim() === "") return; @@ -129,11 +125,15 @@ Item { isRound: true type: IconButton.Tonal + enabled: !ChatState.isWindow + visible: enabled + onClicked: root.requestClose() } CustomText { - Layout.fillWidth: true + Layout.fillWidth: ChatState.isWindow ? false : true + Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter elide: Text.ElideRight font.pointSize: Tokens.font.size.larger text: qsTr(root.chatData.title) @@ -151,21 +151,20 @@ Item { anchors.topMargin: Tokens.spacing.medium active: root.chatData && root.chatData.loaded - sourceComponent: ListView { + sourceComponent: VerticalFadeListView { id: list property bool userScrolledUp: false property real lastCHeight: 0.0 function scrollToBottom(): void { - scrollAnim.to = 0; scrollAnim.start(); } - cacheBuffer: height * 20 + cacheBuffer: Math.max(height * 20, 0) clip: true - // fadeAmount: 0.05 - // fadeThreshold: Tokens.padding.medium + fadeAmount: 0.05 + fadeThreshold: Tokens.padding.medium model: root.chatData.messagesModel spacing: 0 rotation: 180 @@ -238,6 +237,7 @@ Item { property: "contentY" target: list type: Anim.DefaultEffects + to: list.originY } WheelInverter { @@ -255,7 +255,7 @@ Item { anchors.fill: parent spacing: Tokens.spacing.small - visible: !root.chatData + visible: root.chatData.messageCount < 1 } Item { diff --git a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml index 4784f31..1bcb78f 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml @@ -2,6 +2,7 @@ import QtQuick import ZShell.Components import ZShell.Config import ZShell.Llm +import qs.Effects import qs.Components import qs.Services @@ -9,12 +10,13 @@ CustomClippingRect { id: root property bool expanded: false + property bool highlighted: false required property ChatSession modelData signal clicked(content: ChatSession) signal remove(content: ChatSession) - color: Colors.tPalette.m3surfaceContainer + color: highlighted ? Qt.alpha(Colors.palette.m3primaryContainer, 0.3) : Qt.alpha(Colors.tPalette.m3surfaceContainer, 0.2) implicitHeight: { let h = 0; @@ -33,6 +35,20 @@ CustomClippingRect { return h; } radius: Tokens.rounding.large + clip: true + + CustomRect { + id: borderHighlight + + anchors.fill: parent + border.width: root.highlighted ? 2 : 0 + border.color: Colors.palette.m3secondary + radius: root.radius - border.width + 1 + + Behavior on border.width { + Anim {} + } + } Behavior on implicitHeight { enabled: titleText.readOnly @@ -49,6 +65,7 @@ CustomClippingRect { anchors.topMargin: Tokens.padding.large font.pointSize: Tokens.font.size.extraLarge text: root.modelData.icon || "check" + color: Colors.palette.m3onSurface } Item { @@ -85,31 +102,73 @@ CustomClippingRect { } } - TextEditBase { - id: titleText + Item { + id: titleWrapper anchors.left: parent.left - anchors.leftMargin: readOnly ? 0 : Tokens.padding.large anchors.top: parent.top - anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2 - font.pointSize: Tokens.font.size.normal - text: root.modelData.title - readOnly: true + anchors.right: parent.right + implicitHeight: titleText.implicitHeight + Tokens.padding.small * 2 - Behavior on anchors.leftMargin { - Anim {} - } - Behavior on anchors.topMargin { - Anim {} + layer.enabled: true + layer.effect: OpacityMask { + maskSource: fadeMask } - onEditingFinished: root.modelData.title = text - onReadOnlyChanged: { - if (!readOnly) { - this.forceActiveFocus(); - cursorPosition = text.length; - } else { - root.forceActiveFocus(); + TextFieldBase { + id: titleText + + anchors.left: parent.left + anchors.leftMargin: readOnly ? 0 : Tokens.padding.large + anchors.top: parent.top + anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2 + font.pointSize: Tokens.font.size.normal + text: root.modelData.title || qsTr("New chat") + readOnly: true + color: Colors.palette.m3onSurface + + Behavior on anchors.leftMargin { + Anim {} + } + Behavior on anchors.topMargin { + Anim {} + } + + onEditingFinished: root.modelData.title = text + onReadOnlyChanged: { + if (!readOnly) { + this.forceActiveFocus(); + cursorPosition = text.length; + } else { + root.forceActiveFocus(); + } + } + } + } + + CustomRect { + id: fadeMask + + // anchors.top: titleText.top + // anchors.bottom: titleText.bottom + // anchors.left: titleText.left + // anchors.right: parent.right + anchors.fill: titleWrapper + + layer.enabled: true + visible: false + + gradient: Gradient { + orientation: Gradient.Horizontal + + GradientStop { + color: Qt.rgba(1, 1, 1, 1.0) + position: 0.85 + } + + GradientStop { + color: Qt.rgba(1, 1, 1, 0) + position: 1.0 } } } @@ -151,7 +210,7 @@ CustomClippingRect { id: timestamp anchors.left: parent.left - anchors.top: titleText.bottom + anchors.top: title.bottom anchors.topMargin: Tokens.spacing.extraSmall color: Colors.palette.m3onSurfaceVariant font.pointSize: Tokens.font.size.small @@ -188,7 +247,7 @@ CustomClippingRect { font.pointSize: Tokens.font.size.small maximumLineCount: 1 opacity: !root.expanded ? 1 : 0 - text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? "" + text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet") Behavior on y { Anim {} @@ -213,7 +272,7 @@ CustomClippingRect { color: Colors.palette.m3onSurfaceVariant maximumLineCount: 5 opacity: root.expanded ? 1 : 0 - text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? "" + text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? qsTr("No messages yet") wrapMode: Text.WrapAtWordBoundaryOrAnywhere Behavior on y { @@ -286,7 +345,7 @@ CustomClippingRect { implicitHeight: expandIcon.implicitHeight implicitWidth: expandIcon.implicitHeight radius: Tokens.rounding.full - color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 3) + color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3) StateLayer { onClicked: root.expanded = !root.expanded @@ -298,6 +357,7 @@ CustomClippingRect { anchors.centerIn: parent anchors.verticalCenterOffset: root.expanded ? -1 : 1 rotation: root.expanded ? 180 : 0 + color: Colors.palette.m3onSurface text: "expand_more" Behavior on anchors.verticalCenterOffset { diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml index 9ca55d9..78be0e3 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatList.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -13,9 +13,10 @@ Item { property alias list: list property alias model: list.model + property bool highlight: false signal deleteChatRequest(content: ChatSession) - signal loadChatRequest(content: ChatSession) + signal loadChatRequest(content: ChatSession, index: int) signal newChatRequest CustomText { @@ -40,16 +41,48 @@ Item { anchors.right: parent.right anchors.top: header.bottom anchors.topMargin: Tokens.spacing.medium - cacheBuffer: height * 2 clip: true spacing: Tokens.spacing.medium + add: Transition { + Anim { + from: list.width + property: "x" + } + } + remove: Transition { + Anim { + to: list.width + property: "x" + } + + Anim { + to: 0 + property: "opacity" + } + } + displaced: Transition { + Anim { + property: "y" + } + } + move: Transition { + Anim { + property: "y" + } + } + delegate: ChatDelegate { id: chat - implicitWidth: ListView.view.width + required property int index - onClicked: content => root.loadChatRequest(content) + implicitWidth: ListView.view.width + highlighted: root.highlight && ChatState.chatSession === modelData + + onHighlightedChanged: if (highlighted) + list.currentIndex = index + onClicked: content => root.loadChatRequest(content, index) onRemove: content => root.deleteChatRequest(content) } } @@ -58,12 +91,12 @@ Item { id: newChatBtn anchors.bottom: parent.bottom - anchors.margins: Tokens.padding.small anchors.right: parent.right + anchors.margins: Tokens.padding.small + padding: 8 font.pointSize: Math.round(18 * 1.2) icon: "add" - padding: 8 - radius: Tokens.rounding.full + isRound: true onClicked: { root.newChatRequest(); diff --git a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml index 2c9c44c..ba17161 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml @@ -18,45 +18,35 @@ Item { Component.onCompleted: { if (ChatState.inChat) { - stackLoader.item?.push(chatList); - stackLoader.item?.push(chatContent, { + stack.push(chatList); + stack.push(chatContent, { "chatData": ChatState.chatSession }); } else - stackLoader.item?.push(chatList); + stack.push(chatList); } - Loader { - id: stackLoader - - active: !ChatState.isWindow || root.width <= (ChatState.screen.width / 4) + StackView { + id: stack anchors.fill: parent + } - sourceComponent: StackView { - id: stack + IconButton { + anchors.left: parent.left + anchors.bottom: parent.bottom + icon: "open_in_new" + anchors.margins: Tokens.padding.small + isRound: true + padding: 8 + font.pointSize: Math.round(18 * 1.2) + visible: !ChatState.isWindow + + onClicked: { + Detach.create(); + Visibilities.getForActive().sidebar = false; } } - Loader { - id: sidebarViewLoader - - active: ChatState.isWindow && root.width > (ChatState.screen.width / 4) - anchors.fill: parent - - sourceComponent: SidebarView {} - } - - // IconButton { - // anchors.right: parent.right - // icon: "close" - // visible: !ChatState.isWindow - // - // onClicked: { - // Detach.create(); - // Visibilities.getForActive().sidebar = false; - // } - // } - Component { id: chatList @@ -68,8 +58,8 @@ Item { onDeleteChatRequest: chat => { Chat.chats.remove(chat); } - onLoadChatRequest: chat => { - stackLoader.item?.push(chatContent, { + onLoadChatRequest: (chat, index) => { + stack.push(chatContent, { "chatData": chat }); ChatState.inChat = true; @@ -77,7 +67,7 @@ Item { } onNewChatRequest: { const data = Chat.chats.insert(); - stackLoader.item?.push(chatContent, { + stack.push(chatContent, { "chatData": data }); ChatState.inChat = true; @@ -91,7 +81,7 @@ Item { ChatContent { onRequestClose: { - stackLoader.item?.pop(); + stack.pop(); ChatState.inChat = false; } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatState.qml b/Modules/Notifications/Sidebar/Chat/ChatState.qml index df96065..219a0b9 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatState.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatState.qml @@ -8,6 +8,8 @@ Singleton { id: root property ChatSession chatSession + readonly property string title: chatSession ? chatSession.title : "Chat" + property int currentIdx: -1 property bool inChat: false property bool isWindow: false property ShellScreen screen diff --git a/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml b/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml new file mode 100644 index 0000000..0a75bb3 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml @@ -0,0 +1,132 @@ +import QtQuick +import ZShell.Llm +import ZShell.Config +import qs.Components +import qs.Services +import qs.Modules.Notifications.Sidebar.Chat + +Item { + id: root + + property int animOff + property Item currentItem + property bool loading: false + property int lastIdx: -1 + + readonly property Component conversationComp: ChatContent {} + + function loadConversation(chat): void { + if (currentItem) { + currentItem.destroy(); + currentItem = null; + } + + if (!chat) + return; + + root.loading = true; + + const incubator = root.conversationComp.incubateObject(container, { + chatData: chat + }); + + const attach = () => { + incubator.object.anchors.fill = container; + currentItem = incubator.object; + root.loading = false; + enterAnim.start(); + }; + + if (incubator.status === Component.Ready) + attach(); + else + incubator.onStatusChanged = status => { + if (status === Component.Ready) + attach(); + }; + } + + Item { + id: container + + anchors.fill: parent + layer.enabled: opacity < 1 + objectName: "ConversationContainer" + + Component.onCompleted: { + if (ChatState.chatSession) + root.loadConversation(ChatState.chatSession); + } + } + + LoadingIndicator { + anchors.centerIn: parent + implicitSize: Tokens.font.size.extraLarge * 4 + opacity: root.loading ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + } + + Connections { + function onChatSessionChanged(): void { + exitAnim.complete(); + enterAnim.complete(); + root.animOff = Tokens.padding.small * (ChatState.currentIdx > root.lastIdx ? 1 : -1); + root.lastIdx = ChatState.currentIdx; + exitAnim.start(); + } + + target: ChatState + } + + SequentialAnimation { + id: exitAnim + + Anim { + property: "opacity" + target: container + to: 0 + type: Anim.DefaultEffects + } + + ScriptAction { + script: root.loadConversation(ChatState.chatSession) + } + } + + SequentialAnimation { + id: enterAnim + + PropertyAction { + property: "topMargin" + target: container.anchors + value: root.animOff + } + + PropertyAction { + property: "bottomMargin" + target: container.anchors + value: -root.animOff + } + + ParallelAnimation { + Anim { + from: 0 + property: "opacity" + target: container + to: 1 + type: Anim.SlowEffects + } + + Anim { + properties: "topMargin,bottomMargin" + target: container.anchors + to: 0 + type: Anim.SlowEffects + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml b/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml index ee21512..d3370e7 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml @@ -5,28 +5,28 @@ import qs.Components import qs.Services ColumnLayout { - id: root + id: root - spacing: Tokens.spacing.small + spacing: Tokens.spacing.small - Item { - Layout.fillHeight: true - } + Item { + Layout.fillHeight: true + } - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - color: Colors.tPalette.m3outlineVariant - font.pointSize: 36 - text: "chat" - } + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3outlineVariant + font.pointSize: 36 + text: "chat" + } - CustomText { - Layout.alignment: Qt.AlignHCenter - color: Colors.tPalette.m3onSurfaceVariant - text: qsTr("No messages yet") - } + CustomText { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3onSurfaceVariant + text: qsTr("Send a message to get started") + } - Item { - Layout.fillHeight: true - } + Item { + Layout.fillHeight: true + } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml index 856005d..0a8727d 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml @@ -11,7 +11,6 @@ Item { property color mathColor: Colors.palette.m3onSurface property real fontSize: Tokens.font.size.large - implicitWidth: Math.max(fallbackText.implicitWidth, root.width) implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2 LlmMathText { diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index 036c061..7f267b1 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -17,7 +17,8 @@ MouseArea { required property ChatMessage modelData property bool reasoningExpanded: false readonly property var blocks: blockify(current.segments) - property real savedContentY: -1 + property real savedOffset: -1 + property bool stickActive: false function handleReasoningToggle(expanded: bool): void { const view = root.ListView.view; @@ -25,14 +26,16 @@ MouseArea { return; if (expanded) { - root.savedContentY = view.contentY; + root.savedOffset = view.contentY - root.y; root.reasoningExpanded = true; } else { root.reasoningExpanded = false; - if (root.savedContentY !== -1) { - restoreAnim.to = root.savedContentY; + root.stickActive = false; + settleTimer.stop(); + if (!isNaN(root.savedOffset)) { + restoreAnim.to = root.y + root.savedOffset; restoreAnim.restart(); - root.savedContentY = -1; + root.savedOffset = NaN; } } } @@ -69,6 +72,30 @@ MouseArea { preventStealing: false implicitHeight: layout.implicitHeight + Tokens.padding.medium + Timer { + id: settleTimer + interval: 16 + + onTriggered: { + root.stickActive = false; + } + } + + onImplicitHeightChanged: { + if (root.reasoningExpanded) { + root.stickActive = true; + settleTimer.restart(); + + const view = root.ListView.view; + if (!view) + return; + const bottomEdge = root.y + root.height + Tokens.padding.large * 2; + const viewportBottom = view.contentY + view.height; + if (bottomEdge > viewportBottom) + view.contentY = bottomEdge - view.height; + } + } + Behavior on current { id: changeAnim @@ -119,14 +146,6 @@ MouseArea { } } - Binding { - property: "contentY" - restoreMode: Binding.RestoreNone - target: root.ListView.view - value: root.y + root.height + Tokens.padding.large * 2 - root.ListView.view.height - when: root.reasoningExpanded && root.ListView.view && (root.y + root.height + Tokens.padding.large * 2 > root.ListView.view.contentY + root.ListView.view.height) - } - Anim { id: restoreAnim diff --git a/Modules/Notifications/Sidebar/Chat/Detach.qml b/Modules/Notifications/Sidebar/Chat/Detach.qml index 86fe6bd..1064dde 100644 --- a/Modules/Notifications/Sidebar/Chat/Detach.qml +++ b/Modules/Notifications/Sidebar/Chat/Detach.qml @@ -31,7 +31,7 @@ Singleton { minimumSize.height: Config.sidebar.sizes.width minimumSize.width: Config.sidebar.sizes.width surfaceFormat.opaque: false - title: qsTr("ZShell - Chat") + title: qsTr("ZShell - %1").arg(ChatState.title) Component.onCompleted: ChatState.screen = screen onVisibleChanged: { @@ -41,7 +41,7 @@ Singleton { } } - ChatPanel { + SidebarView { id: chat anchors.fill: parent diff --git a/Modules/Notifications/Sidebar/Chat/SidebarView.qml b/Modules/Notifications/Sidebar/Chat/SidebarView.qml index 9b83259..1ffdb0c 100644 --- a/Modules/Notifications/Sidebar/Chat/SidebarView.qml +++ b/Modules/Notifications/Sidebar/Chat/SidebarView.qml @@ -2,90 +2,109 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import QtQuick.Controls import Quickshell import ZShell.Config import ZShell.Llm import qs.Components +import qs.Modules.Notifications.Sidebar.Chat.Content import qs.Services -Item { +RowLayout { id: root property int breakpoint: 700 property alias conversationModel: sidebar.model - property var currentConversation: null // whatever model item is "open" readonly property bool isWide: root.width >= root.breakpoint property bool narrowShowsSidebar: true - function closeConversation() { - narrowShowsSidebar = true; - } - - function openConversation(conv) { - currentConversation = conv; - narrowShowsSidebar = false; // in narrow mode, jump to the chat + function openConversation(conv: ChatSession, index: int): void { } ChatList { id: sidebar - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: root.isWide ? undefined : parent.right - anchors.top: parent.top - implicitWidth: 20 - list.currentIndex: -1 - list.highlightFollowsCurrentItem: false + Layout.fillHeight: true + Layout.margins: Tokens.padding.medium + Layout.rightMargin: Tokens.spacing.medium + Layout.maximumWidth: Config.sidebar.sizes.width + Layout.preferredWidth: root.width / 4 + Layout.minimumWidth: Config.sidebar.sizes.width / 2 + highlight: true - Behavior on anchors.right { - AnchorAnim {} - } Behavior on implicitWidth { Anim {} } - list.highlight: CustomRect { - color: Colors.palette.m3primary - implicitHeight: sidebar.list.currentItem?.implicitHeight ?? 0 - implicitWidth: sidebar.list.width - radius: Tokens.rounding.medium - x: sidebar.list.currentItem?.chat.x ?? 0 - y: sidebar.list.currentItem?.y ?? 0 - - Behavior on y { - Anim { - duration: Tokens.anim.durations.small - easing: Tokens.anim.expressiveDefaultEffects - } - } - } model: ScriptModel { values: Chat.chats.values } - anchors.onRightChanged: { - if (anchors.right === undefined) - implicitWidth = 20; - } onLoadChatRequest: (chat, index) => { - sidebar.list.currentIndex = index; - root.openConversation(chat); + ChatState.currentIdx = index; + ChatState.chatSession = chat; + } + + onDeleteChatRequest: chat => { + Chat.chats.remove(chat); + ChatState.currentIdx = -1; + ChatState.chatSession = null; + } + onNewChatRequest: { + const data = Chat.chats.insert(); + ChatState.currentIdx = 0; + ChatState.chatSession = data; } } - CustomClippingWrapperRect { - anchors.bottom: parent.bottom - anchors.left: sidebar.right - anchors.right: parent.right - anchors.top: parent.top - child: conversationView + Item { + Layout.margins: Tokens.padding.extraLarge + Layout.leftMargin: Tokens.spacing.extraLarge + Layout.topMargin: Tokens.padding.large + Layout.preferredWidth: Config.sidebar.sizes.width * 2 + Layout.fillWidth: true + Layout.fillHeight: true + + ChatHost { + id: convHost + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + implicitWidth: Math.min(parent.width, 800) + clip: true + + onImplicitWidthChanged: console.log(implicitWidth) + } + + ColumnLayout { + id: noChatLayout + anchors.centerIn: parent + opacity: ChatState.chatSession ? 0 : 1 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + + MaterialIcon { + Layout.alignment: Qt.AlignCenter + text: "chat_bubble_off" + font.pointSize: Tokens.font.size.extraLarge + color: Colors.tPalette.m3onSurfaceVariant + } + + CustomText { + Layout.alignment: Qt.AlignCenter + text: qsTr("Start or create a chat") + color: Colors.tPalette.m3onSurfaceVariant + } + } } - ChatContent { + Component { id: conversationView - anchors.fill: root - anchors.leftMargin: root.isWide ? Config.sidebar.sizes.width / 2 : 0 - chatData: root.currentConversation + ChatContent {} } } From 88bb413f9aea8e998faa2a0e66c93e10630bfd59 Mon Sep 17 00:00:00 2001 From: zach Date: Sat, 29 Aug 2026 18:56:59 +0200 Subject: [PATCH 31/90] fab menu and model selector --- .../Notifications/Sidebar/Chat/ChatList.qml | 312 +++++++++++++++++- .../Notifications/Sidebar/Chat/ChatPanel.qml | 16 - .../Notifications/Sidebar/Chat/ChatState.qml | 6 + Modules/Notifications/Sidebar/Chat/Detach.qml | 2 + 4 files changed, 315 insertions(+), 21 deletions(-) diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml index 78be0e3..541eb14 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatList.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -36,7 +36,7 @@ Item { CustomListView { id: list - anchors.bottom: parent.bottom + anchors.bottom: modelsContainer.top anchors.left: parent.left anchors.right: parent.right anchors.top: header.bottom @@ -85,10 +85,161 @@ Item { onClicked: content => root.loadChatRequest(content, index) onRemove: content => root.deleteChatRequest(content) } + + TapHandler { + onTapped: { + root.focus = true; + ChatState.fabExpanded = false; + modelsContainer.expanded = false; + } + } + } + + CustomClippingRect { + id: modelsContainer + + property bool expanded: false + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: fabRoot.left + anchors.margins: Tokens.padding.small + anchors.rightMargin: Tokens.spacing.small + + implicitHeight: expanded ? 400 : fabRoot.implicitHeight + radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2 + + function prettyModelName(): string { + const pathList = Chat.model.split("\/"); + const name = pathList[pathList.length - 1]; + return name; + } + + Behavior on implicitHeight { + Anim {} + } + + Behavior on radius { + Anim {} + } + + Item { + id: modelName + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + implicitHeight: fabRoot.implicitHeight + + CustomText { + text: modelsContainer.prettyModelName() + color: Colors.palette.m3onSurfaceVariant + anchors.left: parent.left + anchors.leftMargin: Tokens.padding.large + anchors.verticalCenter: parent.verticalCenter + } + + MaterialIcon { + color: Colors.palette.m3onSurfaceVariant + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.large + anchors.verticalCenter: parent.verticalCenter + text: modelsContainer.expanded ? "unfold_less" : "unfold_more" + animate: true + font.pointSize: Tokens.font.size.large + } + + StateLayer { + id: modelLayer + onClicked: { + ChatState.fabExpanded = false; + modelsContainer.expanded = !modelsContainer.expanded; + } + radius: modelsContainer.radius + } + } + + VerticalFadeListView { + id: modelsList + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.top: modelName.bottom + anchors.margins: Tokens.padding.medium + anchors.topMargin: Tokens.spacing.medium + model: Chat.availableModels + opacity: modelsContainer.expanded ? 1 : 0 + currentIndex: Chat.availableModels.indexOf(Chat.model) + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + + CustomScrollBar.vertical: CustomScrollBar { + flickable: modelsList + } + + delegate: CustomRect { + id: model + + required property var modelData + required property int index + readonly property bool selected: model.ListView.view.currentIndex === model.index + + implicitWidth: ListView.view.width + implicitHeight: layout.implicitHeight + Tokens.padding.small * 2 + radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium + color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0) + + Behavior on radius { + Anim { + type: Anim.SlowEffects + } + } + + RowLayout { + id: layout + + anchors.fill: parent + anchors.margins: Tokens.padding.small + anchors.leftMargin: Tokens.padding.large + anchors.rightMargin: Tokens.padding.large + + CustomText { + text: model.modelData + color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + } + + MaterialIcon { + Layout.alignment: Qt.AlignRight + visible: model.selected + color: Colors.palette.m3onTertiaryContainer + font.pointSize: Tokens.font.size.normal + opacity: model.selected ? 1 : 0 + text: "check" + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + } + + StateLayer { + id: stateLayer + + onClicked: { + Chat.selectModel(model.modelData); + } + } + } + } } IconButton { - id: newChatBtn + id: fabRoot anchors.bottom: parent.bottom anchors.right: parent.right @@ -96,17 +247,168 @@ Item { padding: 8 font.pointSize: Math.round(18 * 1.2) icon: "add" - isRound: true + isRound: ChatState.fabExpanded + + label.transform: Rotation { + origin.y: fabRoot.label.height / 2 + origin.x: fabRoot.label.width / 2 + angle: ChatState.fabExpanded ? 135 : 0 + + Behavior on angle { + Anim {} + } + } onClicked: { - root.newChatRequest(); + modelsContainer.expanded = false; + ChatState.fabExpanded = !ChatState.fabExpanded; } Elevation { anchors.fill: parent - level: newChatBtn.stateLayer.containsMouse ? 4 : 3 + level: fabRoot.stateLayer.containsMouse ? 4 : 3 radius: parent.radius z: -1 } } + + ColumnLayout { + id: fabMenu + + anchors.bottom: fabRoot.top + anchors.right: fabRoot.right + anchors.bottomMargin: Tokens.padding.medium + + Repeater { + id: fabRep + + model: ListModel { + ListElement { + name: "new chat" + icon: "add" + } + ListElement { + name: "pop out in window" + icon: "open_in_new" + } + } + + CustomClippingRect { + id: fabMenuItem + + required property var modelData + required property int index + + Layout.alignment: Qt.AlignRight + implicitHeight: fabMenuItemInner.implicitHeight + Tokens.padding.medium * 2 + + radius: sLayer.pressed ? Tokens.rounding.small : implicitHeight / 2 + color: Colors.palette.m3primaryContainer + visible: !(modelData.name === "pop out in window" && ChatState.isWindow) + + Behavior on radius { + Anim { + type: Anim.DefaultEffects + } + } + + opacity: 0 + + states: State { + name: "visible" + when: ChatState.fabExpanded + + PropertyChanges { + fabMenuItem.implicitWidth: fabMenuItemInner.implicitWidth + Tokens.padding.large * 2 + fabMenuItem.opacity: 1 + fabMenuItemInner.opacity: 1 + } + } + + transitions: [ + Transition { + to: "visible" + + SequentialAnimation { + PauseAnimation { + duration: (fabRep.count - 1 - fabMenuItem.index) * 50 + } + ParallelAnimation { + Anim { + property: "implicitWidth" + type: Anim.FastSpatial + } + Anim { + property: "opacity" + duration: Tokens.anim.durations.small + easing: Tokens.anim.standard + } + } + } + }, + Transition { + from: "visible" + + SequentialAnimation { + PauseAnimation { + duration: fabMenuItem.index * Tokens.anim.durations.small / 8 + } + ParallelAnimation { + Anim { + property: "implicitWidth" + type: Anim.FastSpatial + } + Anim { + property: "opacity" + duration: Tokens.anim.durations.small + easing: Tokens.anim.standard + } + } + } + } + ] + + RowLayout { + id: fabMenuItemInner + + anchors.centerIn: parent + spacing: Tokens.spacing.medium + opacity: 0 + + MaterialIcon { + text: fabMenuItem.modelData.icon + color: Colors.palette.m3onPrimaryContainer + font.pointSize: Tokens.font.size.large + fill: 1 + } + + CustomText { + animate: true + text: fabMenuItem.modelData.name + font.capitalization: Font.Capitalize + color: Colors.palette.m3onPrimaryContainer + Layout.preferredWidth: implicitWidth + + Behavior on Layout.preferredWidth { + Anim {} + } + } + } + + StateLayer { + id: sLayer + onClicked: { + const name = fabMenuItem.modelData.name; + + if (name === "new chat") + root.newChatRequest(); + else if (name === "pop out in window") + Detach.create(); + + ChatState.fabExpanded = false; + } + } + } + } + } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml index ba17161..6f8c765 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml @@ -31,22 +31,6 @@ Item { anchors.fill: parent } - IconButton { - anchors.left: parent.left - anchors.bottom: parent.bottom - icon: "open_in_new" - anchors.margins: Tokens.padding.small - isRound: true - padding: 8 - font.pointSize: Math.round(18 * 1.2) - visible: !ChatState.isWindow - - onClicked: { - Detach.create(); - Visibilities.getForActive().sidebar = false; - } - } - Component { id: chatList diff --git a/Modules/Notifications/Sidebar/Chat/ChatState.qml b/Modules/Notifications/Sidebar/Chat/ChatState.qml index 219a0b9..c24e59a 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatState.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatState.qml @@ -3,6 +3,7 @@ pragma Singleton import QtQuick import Quickshell import ZShell.Llm +import qs.Helpers Singleton { id: root @@ -11,6 +12,11 @@ Singleton { readonly property string title: chatSession ? chatSession.title : "Chat" property int currentIdx: -1 property bool inChat: false + property bool fabExpanded: false property bool isWindow: false property ShellScreen screen + property bool sidebarVisible: Visibilities.getForActive().sidebar + + onSidebarVisibleChanged: if (!sidebarVisible) + fabExpanded = false } diff --git a/Modules/Notifications/Sidebar/Chat/Detach.qml b/Modules/Notifications/Sidebar/Chat/Detach.qml index 1064dde..c0ab82d 100644 --- a/Modules/Notifications/Sidebar/Chat/Detach.qml +++ b/Modules/Notifications/Sidebar/Chat/Detach.qml @@ -3,6 +3,7 @@ pragma Singleton import Quickshell import QtQuick import ZShell.Config +import qs.Helpers import qs.Services Singleton { @@ -10,6 +11,7 @@ Singleton { function create(parent: Item, props: var): void { chatComp.createObject(parent ?? dummy, props); + Visibilities.getForActive().sidebar = false; ChatState.isWindow = true; } From 2df1cfe9aa1ef38e74eac404e34e216cffdd97bb Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 31 Aug 2026 19:02:26 +0200 Subject: [PATCH 32/90] chore: format llm c files --- Plugins/ZShell/Llm/chat.cpp | 31 +- Plugins/ZShell/Llm/chat.hpp | 12 +- Plugins/ZShell/Llm/chatstore.cpp | 543 +++++++++++-------------- Plugins/ZShell/Llm/chatstore.hpp | 5 - Plugins/ZShell/Llm/codehighlighter.cpp | 1 - Plugins/ZShell/Llm/codehighlighter.hpp | 67 +-- Plugins/ZShell/Llm/generation.cpp | 120 ++---- Plugins/ZShell/Llm/generation.hpp | 33 +- Plugins/ZShell/Llm/llmclient.cpp | 392 +++++++----------- Plugins/ZShell/Llm/llmclient.hpp | 24 +- Plugins/ZShell/Llm/markdownblock.hpp | 9 +- Plugins/ZShell/Llm/markdownparser.cpp | 61 +-- Plugins/ZShell/Llm/markdownparser.hpp | 12 - Plugins/ZShell/Llm/mathtext.cpp | 222 +++++----- Plugins/ZShell/Llm/mathtext.hpp | 23 +- Plugins/ZShell/Llm/message.cpp | 23 +- Plugins/ZShell/Llm/message.hpp | 23 +- Plugins/ZShell/Llm/messagemodel.cpp | 6 +- Plugins/ZShell/Llm/messagemodel.hpp | 13 +- Plugins/ZShell/Llm/segment.cpp | 73 +--- Plugins/ZShell/Llm/segment.hpp | 27 +- Plugins/ZShell/Llm/session.cpp | 9 +- Plugins/ZShell/Llm/session.hpp | 7 - Plugins/ZShell/Llm/tool.cpp | 12 +- Plugins/ZShell/Llm/tool.hpp | 14 - Plugins/ZShell/Llm/webfetchtool.cpp | 160 ++++---- Plugins/ZShell/Llm/webfetchtool.hpp | 9 - 27 files changed, 741 insertions(+), 1190 deletions(-) diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index 6387699..b7adb9b 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -10,9 +10,10 @@ namespace ZShell::llm { Chat::Chat(QObject* parent) -: QObject(parent), m_store(new ChatStore(this)), m_client(new LlmClient(this)) { - if (!config::Config::instance()) - new config::Config(); + : QObject(parent) + , m_store(new ChatStore(this)) + , m_client(new LlmClient(this)) { + if (!config::Config::instance()) new config::Config(); m_store->setLlmClient(m_client); m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); @@ -44,10 +45,8 @@ Chat::Chat(QObject* parent) } Q_EMIT busyChanged(); }); - connect( - m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged); - connect( - m_client, &LlmClient::modelChanged, this, &Chat::modelChanged); + connect(m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged); + connect(m_client, &LlmClient::modelChanged, this, &Chat::modelChanged); connect( m_client, &LlmClient::availableModelsChanged, @@ -88,7 +87,7 @@ Chat::Chat(QObject* parent) this, [this](ChatSession* session, const QString& title) { qInfo() << "Chat: applying generated title" << session->id() - << title << "(was" << session->title() << ")"; + << title << "(was" << session->title() << ")"; session->setTitle(title); m_store->saveMeta(session); }); @@ -97,8 +96,8 @@ Chat::Chat(QObject* parent) &LlmClient::iconSuggested, this, [this](ChatSession* session, const QString& icon) { - qInfo() << "Chat: applying generated icon" << session->id() - << icon << "(was" << session->icon() << ")"; + qInfo() << "Chat: applying generated icon" << session->id() << icon + << "(was" << session->icon() << ")"; session->setIcon(icon); m_store->saveMeta(session); }); @@ -141,8 +140,7 @@ QString Chat::streamingChatId() const { Chat* Chat::s_instance = nullptr; Chat* Chat::create(QQmlEngine*, QJSEngine*) { - if (!s_instance) - s_instance = new Chat(); + if (!s_instance) s_instance = new Chat(); return s_instance; } @@ -151,8 +149,7 @@ void Chat::stop() { } void Chat::dismissError() { - if (m_lastError.isEmpty()) - return; + if (m_lastError.isEmpty()) return; m_lastError.clear(); Q_EMIT lastErrorChanged(); } @@ -162,11 +159,9 @@ void Chat::refreshModels() { } void Chat::selectModel(const QString& id) { - if (id.isEmpty()) - return; + if (id.isEmpty()) return; m_client->setModel(id); - if (auto* config = config::Config::instance()) - config->llm()->set_model(id); + if (auto* config = config::Config::instance()) config->llm()->set_model(id); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp index bbafabe..276b4a3 100644 --- a/Plugins/ZShell/Llm/chat.hpp +++ b/Plugins/ZShell/Llm/chat.hpp @@ -25,12 +25,18 @@ class Chat : public QObject { Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged) Q_PROPERTY(QString model READ model NOTIFY modelChanged) - Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged) + Q_PROPERTY( + QStringList availableModels READ availableModels NOTIFY + availableModelsChanged) Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged) - Q_PROPERTY(bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY toolsEnabledChanged) + Q_PROPERTY( + bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY + toolsEnabledChanged) Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) - Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged) + Q_PROPERTY( + QString streamingChatId READ streamingChatId NOTIFY + streamingChatIdChanged) public: explicit Chat(QObject* parent = nullptr); diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp index 73936b2..3d9056f 100644 --- a/Plugins/ZShell/Llm/chatstore.cpp +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -37,24 +37,16 @@ QString segmentTypeName(LlmSegment::Type type) { } LlmSegment::Type segmentTypeFromName(const QString& name) { - if (name == QLatin1String("tool_call")) - return LlmSegment::Type::ToolCall; - if (name == QLatin1String("content")) - return LlmSegment::Type::Content; + if (name == QLatin1String("tool_call")) return LlmSegment::Type::ToolCall; + if (name == QLatin1String("content")) return LlmSegment::Type::Content; return LlmSegment::Type::Reasoning; } -// A null QString binds as SQL NULL, which violates the NOT NULL columns; -// DEFAULT only applies to omitted columns, not explicit NULLs. QString sqlText(const QString& value) { - if (value.isNull()) - return QStringLiteral(""); + if (value.isNull()) return QStringLiteral(""); return value; } -// Plain data for one session's messages, fetched on a worker thread -// and turned into the QObject tree on the GUI thread. Messages are -// ordered as the model displays them (newest first). struct SegmentRow { QString type; QString text; @@ -82,14 +74,13 @@ struct MessageRow { } // namespace ChatStore::ChatStore(QObject* parent) -: QObject(parent), m_connectionName(QUuid::createUuid().toString()) { + : QObject(parent), m_connectionName(QUuid::createUuid().toString()) { openDb(); load(); } ChatStore::~ChatStore() { - if (m_connectionName.isEmpty()) - return; + if (m_connectionName.isEmpty()) return; QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); db.close(); QSqlDatabase::removeDatabase(m_connectionName); @@ -110,7 +101,7 @@ void ChatStore::openDb() { db.setDatabaseName(m_dbPath); if (!db.open()) { qWarning() << "ChatStore: failed to open database" << m_dbPath << ":" - << db.lastError().text(); + << db.lastError().text(); return; } { @@ -119,59 +110,55 @@ void ChatStore::openDb() { } { QSqlQuery query(db); - query.exec( - QStringLiteral( - "CREATE TABLE IF NOT EXISTS sessions (\n" - " id TEXT PRIMARY KEY,\n" - " title TEXT NOT NULL DEFAULT '',\n" - " icon TEXT NOT NULL DEFAULT '',\n" - " created_at INTEGER NOT NULL,\n" - " updated_at INTEGER NOT NULL,\n" - " pinned INTEGER NOT NULL DEFAULT 0\n" - ")")); + query.exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS sessions (\n" + " id TEXT PRIMARY KEY,\n" + " title TEXT NOT NULL DEFAULT '',\n" + " icon TEXT NOT NULL DEFAULT '',\n" + " created_at INTEGER NOT NULL,\n" + " updated_at INTEGER NOT NULL,\n" + " pinned INTEGER NOT NULL DEFAULT 0\n" + ")")); } { QSqlQuery query(db); - query.exec( - QStringLiteral( - "CREATE TABLE IF NOT EXISTS messages (\n" - " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" - " session_id TEXT NOT NULL REFERENCES sessions (id) " - "ON DELETE CASCADE,\n" - " role TEXT NOT NULL,\n" - " timestamp INTEGER NOT NULL\n" - ")")); + query.exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS messages (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " session_id TEXT NOT NULL REFERENCES sessions (id) " + "ON DELETE CASCADE,\n" + " role TEXT NOT NULL,\n" + " timestamp INTEGER NOT NULL\n" + ")")); } { QSqlQuery query(db); - query.exec( - QStringLiteral( - "CREATE TABLE IF NOT EXISTS generations (\n" - " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" - " message_id INTEGER NOT NULL REFERENCES messages " - "(id) ON DELETE CASCADE,\n" - " timestamp INTEGER NOT NULL,\n" - " is_active INTEGER NOT NULL DEFAULT 1\n" - ")")); + query.exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS generations (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " message_id INTEGER NOT NULL REFERENCES messages " + "(id) ON DELETE CASCADE,\n" + " timestamp INTEGER NOT NULL,\n" + " is_active INTEGER NOT NULL DEFAULT 1\n" + ")")); } { QSqlQuery query(db); - query.exec( - QStringLiteral( - "CREATE TABLE IF NOT EXISTS segments (\n" - " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" - " generation_id INTEGER NOT NULL REFERENCES generations " - "(id) ON DELETE CASCADE,\n" - " type TEXT NOT NULL,\n" - " text TEXT NOT NULL DEFAULT '',\n" - " name TEXT NOT NULL DEFAULT '',\n" - " tool_call_id TEXT NOT NULL DEFAULT '',\n" - " arguments TEXT NOT NULL DEFAULT '',\n" - " result TEXT NOT NULL DEFAULT '',\n" - " status INTEGER NOT NULL DEFAULT 0,\n" - " elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" - " timestamp INTEGER NOT NULL\n" - ")")); + query.exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS segments (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " generation_id INTEGER NOT NULL REFERENCES generations " + "(id) ON DELETE CASCADE,\n" + " type TEXT NOT NULL,\n" + " text TEXT NOT NULL DEFAULT '',\n" + " name TEXT NOT NULL DEFAULT '',\n" + " tool_call_id TEXT NOT NULL DEFAULT '',\n" + " arguments TEXT NOT NULL DEFAULT '',\n" + " result TEXT NOT NULL DEFAULT '',\n" + " status INTEGER NOT NULL DEFAULT 0,\n" + " elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" + " timestamp INTEGER NOT NULL\n" + ")")); } { QSqlQuery query(db); @@ -200,8 +187,7 @@ QVariantList ChatStore::values() const { } ChatSession* ChatStore::at(int index) const { - if (index < 0 || index >= m_sessions.size()) - return nullptr; + if (index < 0 || index >= m_sessions.size()) return nullptr; return m_sessions.at(index); } @@ -218,7 +204,7 @@ ChatSession* ChatStore::insert(int index) { query.bindValue(":updated_at", now); if (!query.exec()) qWarning() << "ChatStore: failed to insert session" << id << ":" - << query.lastError().text(); + << query.lastError().text(); } auto* session = new ChatSession(id, this); session->setMeta(QString(), now, now, 0); @@ -238,8 +224,7 @@ void ChatStore::remove(ChatSession* chat) { } void ChatStore::removeSession(ChatSession* session) { - if (!session || !m_sessions.contains(session)) - return; + if (!session || !m_sessions.contains(session)) return; const QList before = m_sessions; Q_EMIT sessionRemoved(session); { @@ -255,7 +240,7 @@ void ChatStore::removeSession(ChatSession* session) { void ChatStore::move(int from, int to) { if (from < 0 || from >= m_sessions.size() || to < 0 || - to >= m_sessions.size() || from == to) + to >= m_sessions.size() || from == to) return; m_sessions.move(from, to); Q_EMIT valuesChanged(); @@ -269,8 +254,7 @@ void ChatStore::clear() { ChatSession* ChatStore::sessionById(const QString& id) { for (auto* session : m_sessions) - if (session->id() == id) - return session; + if (session->id() == id) return session; return nullptr; } @@ -279,32 +263,28 @@ void ChatStore::setLlmClient(LlmClient* client) { } void ChatStore::persist(ChatSession* session) { - if (!session || !m_sessions.contains(session)) - return; + if (!session || !m_sessions.contains(session)) return; if (!session->isLoaded()) { - // Saving now would persist an incomplete model and wipe the - // stored history; run it again when the load lands. m_pendingPersists.insert(session); return; } session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); - if (!saveSession(session)) - return; + if (!saveSession(session)) return; sortAndNotify(); } void ChatStore::saveMeta(ChatSession* session) { - if (!session || !m_sessions.contains(session)) - return; + if (!session || !m_sessions.contains(session)) return; QSqlQuery query(db()); - query.prepare("UPDATE sessions SET title = :title, icon = :icon " - "WHERE id = :id"); + query.prepare( + "UPDATE sessions SET title = :title, icon = :icon " + "WHERE id = :id"); query.bindValue(":title", sqlText(session->title())); query.bindValue(":icon", sqlText(session->icon())); query.bindValue(":id", session->id()); if (!query.exec()) qWarning() << "ChatStore: failed to save meta for" << session->id() - << ":" << query.lastError().text(); + << ":" << query.lastError().text(); } bool ChatStore::saveSession(ChatSession* session) { @@ -312,7 +292,7 @@ bool ChatStore::saveSession(ChatSession* session) { QSqlDatabase handle = db(); if (!handle.transaction()) { qWarning() << "ChatStore: failed to begin transaction:" - << handle.lastError().text(); + << handle.lastError().text(); return false; } bool ok = true; @@ -338,18 +318,18 @@ bool ChatStore::saveSession(ChatSession* session) { "INSERT INTO messages (session_id, role, timestamp) " "VALUES (:id, :role, :timestamp)"); QSqlQuery generationInsert(handle); - ok = ok && generationInsert.prepare( - "INSERT INTO generations (message_id, timestamp, is_active) " - "VALUES (:mid, :timestamp, :is_active)"); + ok = ok && + generationInsert.prepare( + "INSERT INTO generations (message_id, timestamp, is_active) " + "VALUES (:mid, :timestamp, :is_active)"); QSqlQuery segmentInsert(handle); - ok = ok && segmentInsert.prepare( - "INSERT INTO segments (generation_id, type, text, name, " - "tool_call_id, arguments, result, status, elapsed_ms, " - "timestamp) VALUES (:gid, :type, :text, :name, " - ":tool_call_id, :arguments, :result, :status, :elapsed_ms, " - ":timestamp)"); - // The model holds messages most recent first; the database keeps - // natural rowid order, so iterate from the oldest row up. + ok = ok && + segmentInsert.prepare( + "INSERT INTO segments (generation_id, type, text, name, " + "tool_call_id, arguments, result, status, elapsed_ms, " + "timestamp) VALUES (:gid, :type, :text, :name, " + ":tool_call_id, :arguments, :result, :status, :elapsed_ms, " + ":timestamp)"); const auto* model = session->messagesModel(); for (int row = model->rowCount() - 1; ok && row >= 0; --row) { const auto* message = model->at(row); @@ -363,8 +343,8 @@ bool ChatStore::saveSession(ChatSession* session) { if (!messageInsert.exec()) { ok = false; qWarning() << "ChatStore: saveSession" << id - << "message insert failed:" - << messageInsert.lastError().text(); + << "message insert failed:" + << messageInsert.lastError().text(); break; } const int messageId = messageInsert.lastInsertId().toInt(); @@ -379,8 +359,8 @@ bool ChatStore::saveSession(ChatSession* session) { if (!generationInsert.exec()) { ok = false; qWarning() << "ChatStore: saveSession" << id - << "generation insert failed:" - << generationInsert.lastError().text(); + << "generation insert failed:" + << generationInsert.lastError().text(); break; } const int generationId = @@ -395,18 +375,17 @@ bool ChatStore::saveSession(ChatSession* session) { ":tool_call_id", sqlText(segment->toolCallId())); segmentInsert.bindValue( ":arguments", sqlText(segment->arguments())); - segmentInsert.bindValue(":result", sqlText(segment->result())); + segmentInsert.bindValue( + ":result", sqlText(segment->result())); segmentInsert.bindValue( ":status", static_cast(segment->status())); - segmentInsert.bindValue( - ":elapsed_ms", segment->elapsedMs()); - segmentInsert.bindValue( - ":timestamp", segment->timestamp()); + segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs()); + segmentInsert.bindValue(":timestamp", segment->timestamp()); if (!segmentInsert.exec()) { ok = false; qWarning() << "ChatStore: saveSession" << id - << "segment insert failed:" - << segmentInsert.lastError().text(); + << "segment insert failed:" + << segmentInsert.lastError().text(); break; } } @@ -414,211 +393,183 @@ bool ChatStore::saveSession(ChatSession* session) { } } if (!ok || !handle.commit()) { - qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back"; + qWarning() << "ChatStore: saveSession" << id + << "commit failed, rolling back"; handle.rollback(); ok = false; - qWarning() << "ChatStore: failed to save session" << session->id() << ":" - << handle.lastError().text(); + qWarning() << "ChatStore: failed to save session" << session->id() + << ":" << handle.lastError().text(); } return ok; } void ChatStore::loadMessagesInto(ChatSession* session) { - if (!session) - return; + if (!session) return; const QString sessionId = session->id(); const QString path = m_dbPath; - // SQL on a worker thread (its own connection; QSqlDatabase objects - // are thread-affine). Rows come back as plain data. - QThreadPool::globalInstance()->start( - [store = QPointer(this), - session = QPointer(session), sessionId, path]() { - QList rows; - const QString connName = QUuid::createUuid().toString(); - { - QSqlDatabase db = QSqlDatabase::addDatabase( - QStringLiteral("QSQLITE"), connName); - db.setDatabaseName(path); - if (db.open()) { - // Tolerate the GUI thread writing while we read. - QSqlQuery busy(db); - busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000")); - // Newest first so the model receives rows in - // display order. - QSqlQuery query(db); - query.prepare( - "SELECT id, role, timestamp FROM messages " - "WHERE session_id = :id ORDER BY rowid DESC"); - query.bindValue(":id", sessionId); - if (!query.exec()) { - qWarning() - << "ChatStore: failed to load messages for" - << sessionId << ":" - << query.lastError().text(); - } else { - while (query.next()) { - const int messageId = query.value(0).toInt(); - MessageRow message; - message.user = - query.value(1).toString() == - QLatin1String("user"); - message.timestamp = query.value(2).toLongLong(); - QSqlQuery generationQuery(db); - generationQuery.prepare( - "SELECT id, timestamp, is_active FROM " - "generations WHERE message_id = :mid " - "ORDER BY rowid"); - generationQuery.bindValue(":mid", messageId); - if (generationQuery.exec()) { - while (generationQuery.next()) { - GenerationRow generation; - generation.timestamp = - generationQuery.value(1).toLongLong(); - generation.active = - generationQuery.value(2).toInt() != 0; - QSqlQuery segmentQuery(db); - segmentQuery.prepare( - "SELECT type, text, name, tool_call_id, " - "arguments, result, status, elapsed_ms, " - "timestamp FROM segments WHERE " - "generation_id = :gid ORDER BY rowid"); - segmentQuery.bindValue( - ":gid", - generationQuery.value(0).toInt()); - if (segmentQuery.exec()) { - while (segmentQuery.next()) { - SegmentRow segment; - segment.type = - segmentQuery.value(0).toString(); - segment.text = - segmentQuery.value(1).toString(); - segment.name = - segmentQuery.value(2).toString(); - segment.toolCallId = - segmentQuery.value(3).toString(); - segment.arguments = - segmentQuery.value(4).toString(); - segment.result = - segmentQuery.value(5).toString(); - segment.status = - segmentQuery.value(6).toInt(); - segment.elapsedMs = - segmentQuery.value(7).toLongLong(); - segment.timestamp = - segmentQuery.value(8).toLongLong(); - generation.segments.append(segment); - } - } else { - qWarning() - << "ChatStore: failed to load " - "segments for generation" - << generationQuery.value(0).toInt() - << ":" - << segmentQuery.lastError().text(); - } - message.generations.append(generation); - } - } else { - qWarning() - << "ChatStore: failed to load generations " - "for message" - << messageId << ":" - << generationQuery.lastError().text(); - } - rows.append(message); - } - } - db.close(); + QThreadPool::globalInstance()->start([store = QPointer(this), + session = + QPointer(session), + sessionId, + path]() { + QList rows; + const QString connName = QUuid::createUuid().toString(); + { + QSqlDatabase db = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName); + db.setDatabaseName(path); + if (db.open()) { + QSqlQuery busy(db); + busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000")); + QSqlQuery query(db); + query.prepare( + "SELECT id, role, timestamp FROM messages " + "WHERE session_id = :id ORDER BY rowid DESC"); + query.bindValue(":id", sessionId); + if (!query.exec()) { + qWarning() << "ChatStore: failed to load messages for" + << sessionId << ":" << query.lastError().text(); } else { - qWarning() - << "ChatStore: failed to open database for load:" - << db.lastError().text(); - } - } - // Remove only once every QSqlDatabase copy and query is gone; - // while any reference is alive Qt refuses the removal and the - // connection is left dangling in a broken state. - QSqlDatabase::removeDatabase(connName); - - // Build the object tree on the GUI thread. Deliver through - // the app instance (never destroyed) and re-check the - // pointers there: posting to `store` from the pool thread - // would race with its destruction. - QMetaObject::invokeMethod( - QCoreApplication::instance(), - [store, session, rows = std::move(rows)]() mutable { - ChatStore* st = store; - ChatSession* s = session; - if (!st || !s) - return; - - // Rows fetched from disk; newest first. - auto* model = s->model(); - if (!model) - return; - QList messages; - for (const MessageRow& row : rows) { - auto* message = model->createMessage( - row.user ? ChatMessage::Role::User - : ChatMessage::Role::Assistant, - row.timestamp); - int activeIndex = 0; - for (int i = 0; i < row.generations.size(); ++i) { - const GenerationRow& generationRow = - row.generations.at(i); - auto* generation = - message->addGeneration(generationRow.timestamp); - for (const SegmentRow& segmentRow : - generationRow.segments) { - auto* segment = new LlmSegment( - segmentTypeFromName(segmentRow.type), - segmentRow.timestamp, - generation); - segment->setText(segmentRow.text); - segment->setName(segmentRow.name); - segment->setToolCallId(segmentRow.toolCallId); - segment->appendArguments(segmentRow.arguments); - segment->setResult(segmentRow.result); - segment->setStatus( - static_cast( - segmentRow.status)); - segment->restore(segmentRow.elapsedMs); - generation->addSegment(segment); + while (query.next()) { + const int messageId = query.value(0).toInt(); + MessageRow message; + message.user = query.value(1).toString() == + QLatin1String("user"); + message.timestamp = query.value(2).toLongLong(); + QSqlQuery generationQuery(db); + generationQuery.prepare( + "SELECT id, timestamp, is_active FROM " + "generations WHERE message_id = :mid " + "ORDER BY rowid"); + generationQuery.bindValue(":mid", messageId); + if (generationQuery.exec()) { + while (generationQuery.next()) { + GenerationRow generation; + generation.timestamp = + generationQuery.value(1).toLongLong(); + generation.active = + generationQuery.value(2).toInt() != 0; + QSqlQuery segmentQuery(db); + segmentQuery.prepare( + "SELECT type, text, name, tool_call_id, " + "arguments, result, status, elapsed_ms, " + "timestamp FROM segments WHERE " + "generation_id = :gid ORDER BY rowid"); + segmentQuery.bindValue( + ":gid", generationQuery.value(0).toInt()); + if (segmentQuery.exec()) { + while (segmentQuery.next()) { + SegmentRow segment; + segment.type = + segmentQuery.value(0).toString(); + segment.text = + segmentQuery.value(1).toString(); + segment.name = + segmentQuery.value(2).toString(); + segment.toolCallId = + segmentQuery.value(3).toString(); + segment.arguments = + segmentQuery.value(4).toString(); + segment.result = + segmentQuery.value(5).toString(); + segment.status = + segmentQuery.value(6).toInt(); + segment.elapsedMs = + segmentQuery.value(7).toLongLong(); + segment.timestamp = + segmentQuery.value(8).toLongLong(); + generation.segments.append(segment); + } + } else { + qWarning() + << "ChatStore: failed to load " + "segments for generation" + << generationQuery.value(0).toInt() + << ":" + << segmentQuery.lastError().text(); + } + message.generations.append(generation); } - if (generationRow.active) - activeIndex = i; + } else { + qWarning() + << "ChatStore: failed to load generations " + "for message" + << messageId << ":" + << generationQuery.lastError().text(); } - message->setActiveGeneration(activeIndex); - messages.append(message); + rows.append(message); } - // Rows added live while the load was in flight are - // newer than anything on disk; keep them in front. - if (model->rowCount() > 0) { - QList live = messages; - for (int r = 0; r < model->rowCount(); ++r) - live.prepend(model->at(r)); - messages = live; - } - if (!messages.isEmpty() || model->rowCount() > 0) - s->adoptMessages(messages); + } + db.close(); + } else { + qWarning() << "ChatStore: failed to open database for load:" + << db.lastError().text(); + } + } + QSqlDatabase::removeDatabase(connName); - // Mark loaded only once the model holds both the - // fetched history and the rows added live while the - // load ran, so a deferred startGeneration (triggered - // by loaded()) builds its context from the complete - // conversation. - s->markLoaded(); + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [store, session, rows = std::move(rows)]() mutable { + ChatStore* st = store; + ChatSession* s = session; + if (!st || !s) return; - if (s->takeClearPending()) { - // Cleared while the load was in flight; drop - // everything now that the model is populated. - s->clear(); - } else if (st->m_pendingPersists.remove(s)) { - st->persist(s); + auto* model = s->model(); + if (!model) return; + QList messages; + for (const MessageRow& row : rows) { + auto* message = model->createMessage( + row.user ? ChatMessage::Role::User + : ChatMessage::Role::Assistant, + row.timestamp); + int activeIndex = 0; + for (int i = 0; i < row.generations.size(); ++i) { + const GenerationRow& generationRow = + row.generations.at(i); + auto* generation = + message->addGeneration(generationRow.timestamp); + for (const SegmentRow& segmentRow : + generationRow.segments) { + auto* segment = new LlmSegment( + segmentTypeFromName(segmentRow.type), + segmentRow.timestamp, + generation); + segment->setText(segmentRow.text); + segment->setName(segmentRow.name); + segment->setToolCallId(segmentRow.toolCallId); + segment->appendArguments(segmentRow.arguments); + segment->setResult(segmentRow.result); + segment->setStatus( + static_cast( + segmentRow.status)); + segment->restore(segmentRow.elapsedMs); + generation->addSegment(segment); + } + if (generationRow.active) activeIndex = i; } - }, - Qt::QueuedConnection); - }); + message->setActiveGeneration(activeIndex); + messages.append(message); + } + if (model->rowCount() > 0) { + QList live = messages; + for (int r = 0; r < model->rowCount(); ++r) + live.prepend(model->at(r)); + messages = live; + } + if (!messages.isEmpty() || model->rowCount() > 0) + s->adoptMessages(messages); + + s->markLoaded(); + + if (s->takeClearPending()) { + s->clear(); + } else if (st->m_pendingPersists.remove(s)) { + st->persist(s); + } + }, + Qt::QueuedConnection); + }); } void ChatStore::load() { @@ -647,22 +598,18 @@ void ChatStore::sortAndNotify() { m_sessions.begin(), m_sessions.end(), [](const ChatSession* a, const ChatSession* b) { - if (a->pinned() != b->pinned()) - return a->pinned() > b->pinned(); + if (a->pinned() != b->pinned()) return a->pinned() > b->pinned(); return a->updatedAtMs() > b->updatedAtMs(); }); notify(before); } void ChatStore::notify(const QList& before) { - if (before.size() != m_sessions.size()) - Q_EMIT countChanged(); + if (before.size() != m_sessions.size()) Q_EMIT countChanged(); bool same = before.size() == m_sessions.size(); for (int i = 0; same && i < m_sessions.size(); ++i) - if (before.at(i) != m_sessions.at(i)) - same = false; - if (!same) - Q_EMIT valuesChanged(); + if (before.at(i) != m_sessions.at(i)) same = false; + if (!same) Q_EMIT valuesChanged(); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chatstore.hpp b/Plugins/ZShell/Llm/chatstore.hpp index f8fe539..75b335b 100644 --- a/Plugins/ZShell/Llm/chatstore.hpp +++ b/Plugins/ZShell/Llm/chatstore.hpp @@ -39,9 +39,6 @@ class ChatStore : public QObject { void persist(ChatSession* session); void saveMeta(ChatSession* session); - // Loads the session's messages from the database. The SQL runs on a - // worker thread; the object tree is built and the model updated on - // the GUI thread when it arrives (ChatSession::loaded). void loadMessagesInto(ChatSession* session); Q_SIGNALS: @@ -61,8 +58,6 @@ class ChatStore : public QObject { LlmClient* m_llmClient = nullptr; QString m_connectionName; QString m_dbPath; - // Sessions whose persist() ran before their messages finished - // loading; persisted once the load lands. QSet m_pendingPersists; [[nodiscard]] QSqlDatabase db() const; diff --git a/Plugins/ZShell/Llm/codehighlighter.cpp b/Plugins/ZShell/Llm/codehighlighter.cpp index c926c57..86e2f54 100644 --- a/Plugins/ZShell/Llm/codehighlighter.cpp +++ b/Plugins/ZShell/Llm/codehighlighter.cpp @@ -213,7 +213,6 @@ QVariantList CodeHighlighter::lookupSpans( QMutexLocker locker(&m_cacheMutex); const auto it = m_spanCache.constFind(key); if (it == m_spanCache.constEnd() || it->code != code) return {}; - // Most recently used; eviction drops the oldest entries first. const qsizetype pos = m_spanCacheOrder.indexOf(key); if (pos >= 0) m_spanCacheOrder.move(pos, m_spanCacheOrder.size() - 1); return it->spans; diff --git a/Plugins/ZShell/Llm/codehighlighter.hpp b/Plugins/ZShell/Llm/codehighlighter.hpp index 3987379..4f0b116 100644 --- a/Plugins/ZShell/Llm/codehighlighter.hpp +++ b/Plugins/ZShell/Llm/codehighlighter.hpp @@ -15,38 +15,6 @@ class QJSEngine; namespace ZShell::llm { -// Syntax highlighting for LLM code blocks via tree-sitter. -// -// The tree-sitter runtime is linked. Grammar libraries are dlopen()'d -// lazily, so a missing grammar degrades that language to plain text -// instead of breaking the build or the app. At configure time CMake -// discovers installed grammars (system packages plus the parsers -// Neovim's nvim-treesitter installs) and pairs each with highlight -// queries (vendored, Neovim's, or fetched from -// tree-sitter/highlighting); both are embedded in the generated -// highlight-queries.hpp. -// -// For each grammar the first loadable (ABI-compatible) library wins -// and the first query that compiles against it wins, so a version -// skew between a library and its query degrades gracefully. -// -// The fence language the LLM wrote (```cpp, ```python, ...) is mapped -// to a grammar through an alias table; tags not in the table are used -// as grammar ids as-is. -// -// highlight() parses the code off the GUI thread and delivers a list of -// span maps by calling target's "onHighlightSpans(token, spans)" method -// (on the GUI thread): -// { "start": int, "length": int, "kind": QString } -// where kind is a semantic role (keyword, string, comment, number, -// function, type, ...) that QML maps to theme colors. An empty list -// means "no highlighting" (unknown language or grammar not installed). -// token is passed back unchanged so the caller can drop results for -// superseded code; a destroyed target is simply skipped. -// -// Successful results are cached by (language, code). A request for -// unchanged code delivers the cached spans directly, without re-parsing -// — while a segment streams, only the grown tail is ever re-parsed. class CodeHighlighter : public QObject { Q_OBJECT QML_ELEMENT @@ -54,17 +22,16 @@ class CodeHighlighter : public QObject { public: Q_INVOKABLE void highlight( - const QString& code, const QString& language, QObject* target, int token); + const QString& code, + const QString& language, + QObject* target, + int token); static CodeHighlighter* create(QQmlEngine*, QJSEngine*); struct Grammar { - // Library candidates in priority order (system package, then - // Neovim copies); libs[i] pairs with symbols[i]. std::vector libs; std::vector symbols; - // Candidate query sources in priority order; first that - // compiles against the loaded grammar wins. std::vector queries; }; @@ -73,29 +40,27 @@ class CodeHighlighter : public QObject { bool bad = false; // permanent failure, do not retry void* lib = nullptr; const void* lang = nullptr; // const TSLanguage* - void* query = nullptr; // TSQuery* + void* query = nullptr; // TSQuery* }; [[nodiscard]] static const QHash& aliases(); - // Maps a tree-sitter capture name to a role index (0 = unstyled). [[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length); [[nodiscard]] static const char* roleName(uint8_t role); - // Maps a fence language tag to the grammar id (see aliases()). [[nodiscard]] static QString resolveId(const QString& language); - [[nodiscard]] static QString cacheKey(const QString& id, const QString& code); - // The parsing work; runs on worker threads, so the per-language - // state must be initialized under m_stateMutex and is shared as an - // immutable object afterwards. - [[nodiscard]] QVariantList doHighlight(const QString& code, const QString& language) const; - // Exact-match span cache. lookupSpans() runs on the GUI thread, - // storeSpans() on worker threads; both take m_cacheMutex. - [[nodiscard]] QVariantList lookupSpans(const QString& code, const QString& language) const; - void storeSpans(const QString& code, const QString& language, - const QVariantList& spans) const; + [[nodiscard]] static QString cacheKey( + const QString& id, const QString& code); + [[nodiscard]] QVariantList doHighlight( + const QString& code, const QString& language) const; + [[nodiscard]] QVariantList lookupSpans( + const QString& code, const QString& language) const; + void storeSpans( + const QString& code, + const QString& language, + const QVariantList& spans) const; struct SpanCacheEntry { QString code; // re-compared on lookup; a hash collision can - // never deliver the wrong spans + // never deliver the wrong spans QVariantList spans; }; diff --git a/Plugins/ZShell/Llm/generation.cpp b/Plugins/ZShell/Llm/generation.cpp index bd96d4f..129de52 100644 --- a/Plugins/ZShell/Llm/generation.cpp +++ b/Plugins/ZShell/Llm/generation.cpp @@ -5,22 +5,19 @@ namespace ZShell::llm { ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent) -: QObject(parent), m_timestamp(timestamp) { + : QObject(parent), m_timestamp(timestamp) { m_timer.setParent(this); m_timer.setInterval(500); m_timer.setTimerType(Qt::CoarseTimer); connect(&m_timer, &QTimer::timeout, this, [this]() { bool anyRunning = false; for (auto* segment : m_segments) { - if (!segment->running()) - continue; + if (!segment->running()) continue; anyRunning = true; segment->elapsedMsChanged(); } - if (anyRunning) - Q_EMIT elapsedMsChanged(); - if (!anyRunning && !m_streaming) - m_timer.stop(); + if (anyRunning) Q_EMIT elapsedMsChanged(); + if (!anyRunning && !m_streaming) m_timer.stop(); }); } @@ -28,7 +25,7 @@ QString ChatGeneration::content() const { QStringList parts; for (const auto* segment : m_segments) { if (segment->type() != LlmSegment::Type::Content || - segment->text().isEmpty()) + segment->text().isEmpty()) continue; parts.append(segment->text()); } @@ -39,7 +36,7 @@ QString ChatGeneration::reasoning() const { QStringList parts; for (const auto* segment : m_segments) { if (segment->type() != LlmSegment::Type::Reasoning || - segment->text().isEmpty()) + segment->text().isEmpty()) continue; parts.append(segment->text()); } @@ -47,10 +44,8 @@ QString ChatGeneration::reasoning() const { } bool ChatGeneration::reasoningActive() const { - if (!m_streaming) - return false; - if (!content().isEmpty()) - return false; + if (!m_streaming) return false; + if (!content().isEmpty()) return false; return !hasRunningTool(); } @@ -81,44 +76,35 @@ qint64 ChatGeneration::toolsElapsedMs() const { int ChatGeneration::toolCallCount() const { int count = 0; for (const auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::ToolCall) - ++count; + if (segment->type() == LlmSegment::Type::ToolCall) ++count; return count; } bool ChatGeneration::hasRunningTool() const { for (const auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::ToolCall && - segment->running()) + if (segment->type() == LlmSegment::Type::ToolCall && segment->running()) return true; return false; } void ChatGeneration::updateReasoningActive() { const bool active = reasoningActive(); - if (m_reasoningActive == active) - return; + if (m_reasoningActive == active) return; m_reasoningActive = active; Q_EMIT reasoningActiveChanged(); } void ChatGeneration::setContent(const QString& value) { - // Replaces the entire answer: the first content segment takes the - // new text, any later content bursts are cleared. LlmSegment* first = nullptr; for (auto* segment : m_segments) { - if (segment->type() != LlmSegment::Type::Content) - continue; + if (segment->type() != LlmSegment::Type::Content) continue; if (!first) first = segment; else segment->setText(QString()); } if (!first) { - // Do not materialize an empty content segment (e.g. the assistant - // placeholder created before the stream starts). - if (value.isEmpty()) - return; + if (value.isEmpty()) return; first = new LlmSegment( LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), @@ -129,35 +115,30 @@ void ChatGeneration::setContent(const QString& value) { } void ChatGeneration::appendContent(const QString& piece) { - if (piece.isEmpty()) - return; + if (piece.isEmpty()) return; for (auto* segment : m_segments) { if (segment->type() == LlmSegment::Type::Reasoning && - segment->running()) + segment->running()) segment->close(); } openContentSegment()->appendText(piece); } void ChatGeneration::appendReasoning(const QString& piece) { - if (piece.isEmpty()) - return; + if (piece.isEmpty()) return; for (auto* segment : m_segments) { - if (segment->type() == LlmSegment::Type::Content && - segment->running()) + if (segment->type() == LlmSegment::Type::Content && segment->running()) segment->close(); } openReasoningSegment()->appendText(piece); } void ChatGeneration::setStreaming(bool value) { - if (m_streaming == value) - return; + if (m_streaming == value) return; m_streaming = value; Q_EMIT streamingChanged(); if (value) { - if (!m_timer.isActive()) - m_timer.start(); + if (!m_timer.isActive()) m_timer.start(); } else { closeOpenSegments(); m_timer.stop(); @@ -168,13 +149,10 @@ void ChatGeneration::setStreaming(bool value) { LlmSegment* ChatGeneration::openContentSegment() { for (auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::Content && - segment->running()) + if (segment->type() == LlmSegment::Type::Content && segment->running()) return segment; auto* segment = new LlmSegment( - LlmSegment::Type::Content, - QDateTime::currentMSecsSinceEpoch(), - this); + LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), this); segment->begin(); addSegment(segment); return segment; @@ -183,12 +161,10 @@ LlmSegment* ChatGeneration::openContentSegment() { LlmSegment* ChatGeneration::openReasoningSegment() { for (auto* segment : m_segments) if (segment->type() == LlmSegment::Type::Reasoning && - segment->running()) + segment->running()) return segment; auto* segment = new LlmSegment( - LlmSegment::Type::Reasoning, - QDateTime::currentMSecsSinceEpoch(), - this); + LlmSegment::Type::Reasoning, QDateTime::currentMSecsSinceEpoch(), this); segment->begin(); addSegment(segment); return segment; @@ -198,9 +174,7 @@ LlmSegment* ChatGeneration::beginToolCall( const QString& name, const QString& toolCallId) { closeOpenSegments(); auto* segment = new LlmSegment( - LlmSegment::Type::ToolCall, - QDateTime::currentMSecsSinceEpoch(), - this); + LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this); segment->setName(name); segment->setToolCallId(toolCallId); segment->setStatus(LlmSegment::Status::Running); @@ -211,39 +185,33 @@ LlmSegment* ChatGeneration::beginToolCall( } void ChatGeneration::addSegment(LlmSegment* segment) { - if (!segment || m_segments.contains(segment)) - return; + if (!segment || m_segments.contains(segment)) return; segment->setParent(this); - connect( - segment, &LlmSegment::textChanged, this, [this, segment]() { - if (segment->type() == LlmSegment::Type::Reasoning) - Q_EMIT reasoningChanged(); - else if (segment->type() == LlmSegment::Type::Content) - Q_EMIT contentChanged(); - updateReasoningActive(); - }); - connect( - segment, &LlmSegment::statusChanged, this, [this]() { - Q_EMIT toolStateChanged(); - }); - connect( - segment, &LlmSegment::resultChanged, this, [this]() { - Q_EMIT toolStateChanged(); - }); - connect( - segment, &LlmSegment::runningChanged, this, [this]() { - Q_EMIT elapsedMsChanged(); - Q_EMIT toolStateChanged(); - updateReasoningActive(); - }); + connect(segment, &LlmSegment::textChanged, this, [this, segment]() { + if (segment->type() == LlmSegment::Type::Reasoning) + Q_EMIT reasoningChanged(); + else if (segment->type() == LlmSegment::Type::Content) + Q_EMIT contentChanged(); + updateReasoningActive(); + }); + connect(segment, &LlmSegment::statusChanged, this, [this]() { + Q_EMIT toolStateChanged(); + }); + connect(segment, &LlmSegment::resultChanged, this, [this]() { + Q_EMIT toolStateChanged(); + }); + connect(segment, &LlmSegment::runningChanged, this, [this]() { + Q_EMIT elapsedMsChanged(); + Q_EMIT toolStateChanged(); + updateReasoningActive(); + }); m_segments.append(segment); Q_EMIT segmentsChanged(); } void ChatGeneration::closeOpenSegments() { for (auto* segment : m_segments) - if (segment->running()) - segment->close(); + if (segment->running()) segment->close(); updateReasoningActive(); } diff --git a/Plugins/ZShell/Llm/generation.hpp b/Plugins/ZShell/Llm/generation.hpp index c6e093e..20b4709 100644 --- a/Plugins/ZShell/Llm/generation.hpp +++ b/Plugins/ZShell/Llm/generation.hpp @@ -10,39 +10,35 @@ namespace ZShell::llm { -// One attempt at answering a message: a chronologically ordered list -// of segments. Content bursts, reasoning bursts and tool calls all -// appear in the order the model produced them; a new content (or -// reasoning) segment starts whenever the model switches between them. -// Together with the attempt's aggregate state. class ChatGeneration : public QObject { Q_OBJECT QML_ELEMENT QML_UNCREATABLE("Chat generations are managed by ChatMessage") - Q_PROPERTY(QString content READ content WRITE setContent NOTIFY contentChanged) + Q_PROPERTY( + QString content READ content WRITE setContent NOTIFY contentChanged) Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) - Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) - Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) + Q_PROPERTY( + qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY + elapsedMsChanged) + Q_PROPERTY( + qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged) Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) - Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) + Q_PROPERTY( + bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) Q_PROPERTY(bool hasRunningTool READ hasRunningTool NOTIFY toolStateChanged) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) Q_PROPERTY( - QList segments READ segments - NOTIFY segmentsChanged) + QList segments READ segments NOTIFY + segmentsChanged) Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged) public: explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); - // All content bursts, joined (the model's full answer). [[nodiscard]] QString content() const; - // Every reasoning burst, joined. [[nodiscard]] QString reasoning() const; - // True while the model is thinking: streaming, no content yet, and - // no tool call in flight. [[nodiscard]] bool reasoningActive() const; [[nodiscard]] qint64 reasoningElapsedMs() const; [[nodiscard]] qint64 contentElapsedMs() const; @@ -58,18 +54,11 @@ class ChatGeneration : public QObject { void appendReasoning(const QString& piece); void setStreaming(bool value); - // The in-flight content segment, or a fresh one. A new content - // segment starts whenever the model resumes writing after reasoning - // or a tool call. [[nodiscard]] LlmSegment* openContentSegment(); - // The in-flight reasoning segment, or a fresh one. [[nodiscard]] LlmSegment* openReasoningSegment(); - // Creates and appends a running tool-call segment. [[nodiscard]] LlmSegment* beginToolCall( const QString& name, const QString& toolCallId); - // Appends a segment created by the persistence layer. void addSegment(LlmSegment* segment); - // Stops the clocks of every in-flight segment. void closeOpenSegments(); Q_SIGNALS: diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp index b898456..b29f92b 100644 --- a/Plugins/ZShell/Llm/llmclient.cpp +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -22,8 +22,7 @@ QString LlmClient::completionsPath( QString base = endpoint.trimmed(); while (base.endsWith('/')) base.chop(1); - if (!base.endsWith("/v1")) - base += "/v1"; + if (!base.endsWith("/v1")) base += "/v1"; return base + subpath; } @@ -37,28 +36,24 @@ QString LlmClient::serverErrorMessage( if (errorValue.isObject()) { const QString message = errorValue.toObject()["message"].toString(); - if (!message.isEmpty()) - return message; + if (!message.isEmpty()) return message; } else if (!errorValue.toString().isEmpty()) { return errorValue.toString(); } } } - return fallback.isEmpty() - ? QStringLiteral("Request to LLM server failed") - : fallback; + return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed") + : fallback; } void LlmClient::setBusy(bool value) { - if (m_busy == value) - return; + if (m_busy == value) return; m_busy = value; Q_EMIT busyChanged(); } void LlmClient::setStreamingChatId(const QString& id) { - if (m_streamingChatId == id) - return; + if (m_streamingChatId == id) return; m_streamingChatId = id; Q_EMIT streamingChatIdChanged(); } @@ -74,39 +69,32 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) { } LlmClient::~LlmClient() { - if (m_reply) - m_reply->abort(); + if (m_reply) m_reply->abort(); m_tools->cancelAll(); endStream(); } void LlmClient::setEndpoint(const QString& value) { - if (m_endpoint == value) - return; + if (m_endpoint == value) return; m_endpoint = value; Q_EMIT endpointChanged(); probeContextSize(); - if (m_model.isEmpty()) - refreshModels(); + if (m_model.isEmpty()) refreshModels(); } void LlmClient::setModel(const QString& value) { - if (m_model == value) - return; + if (m_model == value) return; m_model = value; Q_EMIT modelChanged(); - if (m_model.isEmpty()) - refreshModels(); + if (m_model.isEmpty()) refreshModels(); } void LlmClient::setTemperature(double value) { m_temperature = value; } -void LlmClient::startGeneration( - ChatSession* session, ChatGeneration* target) { - if (m_busy || !session || !target) - return; +void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { + if (m_busy || !session || !target) return; m_active = session; m_streaming = target; m_streaming->setStreaming(true); @@ -124,8 +112,9 @@ void LlmClient::startGeneration( const int targetRow = model->rowOf(qobject_cast(target->parent())); if (targetRow < 0) { - fail(QStringLiteral("Internal error: generation target is not in the " - "session")); + fail(QStringLiteral( + "Internal error: generation target is not in the " + "session")); return; } @@ -141,8 +130,7 @@ void LlmClient::startGeneration( } void LlmClient::sendRound() { - if (!m_active || !m_streaming) - return; + if (!m_active || !m_streaming) return; m_finishReason.clear(); m_callBuilders.clear(); m_callResults.clear(); @@ -162,13 +150,12 @@ void LlmClient::sendRound() { const int targetRow = model->rowOf(qobject_cast(m_streaming->parent())); if (targetRow < 0) { - fail(QStringLiteral("Internal error: generation target is not in the " - "session")); + fail(QStringLiteral( + "Internal error: generation target is not in the " + "session")); return; } - // Context: every message older than the target, oldest first, plus - // the tool exchanges of the current turn so far. QJsonArray messages = buildContextMessages(m_active, targetRow); for (const QJsonValue& value : m_transcript) messages.append(value); @@ -180,11 +167,9 @@ void LlmClient::sendRound() { body[QStringLiteral("messages")] = messages; body[QStringLiteral("stream")] = true; body[QStringLiteral("temperature")] = m_temperature; - if (!m_model.isEmpty()) - body[QStringLiteral("model")] = m_model; + if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model; const QJsonArray toolSpecs = m_tools->specifications(); - if (!toolSpecs.isEmpty()) - body[QStringLiteral("tools")] = toolSpecs; + if (!toolSpecs.isEmpty()) body[QStringLiteral("tools")] = toolSpecs; QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); @@ -193,26 +178,22 @@ void LlmClient::sendRound() { m_reply = m_manager.post(request, QJsonDocument(body).toJson()); connect(m_reply, &QNetworkReply::readyRead, this, [this]() { - if (m_reply) - m_buffer.append(m_reply->readAll()); + if (m_reply) m_buffer.append(m_reply->readAll()); drainBuffer(); }); connect(m_reply, &QNetworkReply::finished, this, [this]() { QNetworkReply* reply = m_reply; - if (!reply) - return; + if (!reply) return; m_reply = nullptr; const QNetworkReply::NetworkError error = reply->error(); const QString errorString = reply->errorString(); - // Data not already consumed by readyRead is only reachable here. const QByteArray responseBody = reply->readAll(); m_buffer.append(responseBody); reply->deleteLater(); drainBuffer(); - if (!m_streaming) - return; + if (!m_streaming) return; if (error == QNetworkReply::NoError) roundFinished(); @@ -230,8 +211,7 @@ QJsonArray LlmClient::buildContextMessages( for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { const auto* message = model->at(row); const auto* generation = message->activeGeneration(); - if (!generation) - continue; + if (!generation) continue; if (message->role() == ChatMessage::Role::User) { QJsonObject user; @@ -241,8 +221,6 @@ QJsonArray LlmClient::buildContextMessages( continue; } - // Assistant message: replay its tool calls (and their results) - // so the model keeps the full history of the turn. QList toolSegments; for (const auto* segment : generation->segments()) if (segment->type() == LlmSegment::Type::ToolCall) @@ -278,8 +256,7 @@ QJsonArray LlmClient::buildContextMessages( for (const auto* segment : toolSegments) { QJsonObject toolMessage; toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); - toolMessage[QStringLiteral("tool_call_id")] = - segment->toolCallId(); + toolMessage[QStringLiteral("tool_call_id")] = segment->toolCallId(); toolMessage[QStringLiteral("content")] = segment->result(); messages.append(toolMessage); } @@ -288,51 +265,37 @@ QJsonArray LlmClient::buildContextMessages( } void LlmClient::applyToolCallDelta(const QJsonObject& call) { - if (!m_streaming) - return; + if (!m_streaming) return; const int index = call[QStringLiteral("index")].toInt(-1); - if (index < 0) - return; + if (index < 0) return; while (m_callBuilders.size() <= index) m_callBuilders.append(ToolCallBuilder{}); ToolCallBuilder& builder = m_callBuilders[index]; builder.seen = true; const QString id = call[QStringLiteral("id")].toString(); - if (!id.isEmpty()) - builder.id = id; + if (!id.isEmpty()) builder.id = id; const QJsonObject function = call[QStringLiteral("function")].toObject(); const QString name = function[QStringLiteral("name")].toString(); - if (!name.isEmpty()) - builder.name = name; - const QString arguments = - function[QStringLiteral("arguments")].toString(); - if (!arguments.isEmpty()) - builder.arguments += arguments; + if (!name.isEmpty()) builder.name = name; + const QString arguments = function[QStringLiteral("arguments")].toString(); + if (!arguments.isEmpty()) builder.arguments += arguments; if (!builder.segment) { - // A new call: close the in-flight text segments and open a - // running tool-call segment so the UI can track it live. m_streaming->closeOpenSegments(); - builder.segment = - m_streaming->beginToolCall(builder.name, builder.id); + builder.segment = m_streaming->beginToolCall(builder.name, builder.id); } builder.segment->setName(builder.name); builder.segment->setToolCallId(builder.id); - if (!arguments.isEmpty()) - builder.segment->appendArguments(arguments); + if (!arguments.isEmpty()) builder.segment->appendArguments(arguments); } void LlmClient::roundFinished() { - // [DONE] and the reply's finished signal both funnel here; only the - // first may act. - if (m_roundDone || !m_streaming) - return; + if (m_roundDone || !m_streaming) return; m_roundDone = true; bool hasCalls = false; for (const auto& builder : m_callBuilders) - if (builder.seen) - hasCalls = true; + if (builder.seen) hasCalls = true; if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) { finishTurn(); return; @@ -345,8 +308,6 @@ void LlmClient::roundFinished() { m_streaming->closeOpenSegments(); - // Record the assistant's tool-call message in the transcript so the - // next round (and the model) can see it. const QString content = m_streaming->content(); const QString reasoning = m_streaming->reasoning(); QJsonObject assistant; @@ -362,8 +323,7 @@ void LlmClient::roundFinished() { } QJsonArray calls; for (const auto& builder : m_callBuilders) { - if (!builder.seen) - continue; + if (!builder.seen) continue; QJsonObject function; function[QStringLiteral("name")] = builder.name; function[QStringLiteral("arguments")] = builder.arguments; @@ -387,112 +347,95 @@ void LlmClient::executeAllCalls() { for (int i = 0; i < m_callBuilders.size(); ++i) { const auto& call = m_callBuilders.at(i); - if (!call.seen) - continue; + if (!call.seen) continue; LlmTool* tool = m_tools->tool(call.name); QJsonObject args; QString errorText; if (!tool) { - errorText = QStringLiteral("Error: unknown tool '%1'") - .arg(call.name); + errorText = + QStringLiteral("Error: unknown tool '%1'").arg(call.name); } else if (!call.arguments.isEmpty()) { const QJsonDocument doc = QJsonDocument::fromJson(call.arguments.toUtf8()); if (!doc.isObject()) { - errorText = - QStringLiteral("Error: tool arguments are not valid " - "JSON: %1") - .arg(call.arguments); + errorText = QStringLiteral( + "Error: tool arguments are not valid " + "JSON: %1") + .arg(call.arguments); } else { args = doc.object(); } } if (!errorText.isEmpty()) { - m_callResults[i] = { errorText, false }; + m_callResults[i] = {errorText, false}; if (LlmSegment* segment = call.segment) segment->finishTool(errorText, false); continue; } ++m_pendingCalls; - tool->execute( - args, - [this, i, call](const QJsonObject& result) { - if (!m_streaming) - return; - const bool success = - result.contains(QStringLiteral("output")); - const QString content = success - ? result[QStringLiteral("output")].toString() - : QStringLiteral("Error: ") + - result[QStringLiteral("error")].toString(); - m_callResults[i] = { content, success }; - if (LlmSegment* segment = call.segment) - segment->finishTool(content, success); - if (--m_pendingCalls == 0) - flushCallResults(); - }); + tool->execute(args, [this, i, call](const QJsonObject& result) { + if (!m_streaming) return; + const bool success = result.contains(QStringLiteral("output")); + const QString content = + success ? result[QStringLiteral("output")].toString() + : QStringLiteral("Error: ") + + result[QStringLiteral("error")].toString(); + m_callResults[i] = {content, success}; + if (LlmSegment* segment = call.segment) + segment->finishTool(content, success); + if (--m_pendingCalls == 0) flushCallResults(); + }); } - if (m_pendingCalls == 0) - flushCallResults(); + if (m_pendingCalls == 0) flushCallResults(); } void LlmClient::flushCallResults() { - if (!m_toolPhase) - return; + if (!m_toolPhase) return; m_toolPhase = false; - if (!m_streaming) - return; + if (!m_streaming) return; for (int i = 0; i < m_callBuilders.size(); ++i) { const auto& call = m_callBuilders.at(i); - if (!call.seen) - continue; + if (!call.seen) continue; QJsonObject toolMessage; toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); toolMessage[QStringLiteral("tool_call_id")] = call.id; - toolMessage[QStringLiteral("content")] = - m_callResults.at(i).content; + toolMessage[QStringLiteral("content")] = m_callResults.at(i).content; m_transcript.append(toolMessage); } sendRound(); } void LlmClient::stop() { - if (!m_busy) - return; + if (!m_busy) return; if (m_toolPhase) { m_tools->cancelAll(); if (m_streaming) { for (auto* segment : m_streaming->segments()) { if (segment->type() == LlmSegment::Type::ToolCall && - segment->running()) - segment->finishTool( - QStringLiteral("Cancelled"), false); + segment->running()) + segment->finishTool(QStringLiteral("Cancelled"), false); } } finishTurn(); return; } - if (m_reply) - m_reply->abort(); + if (m_reply) m_reply->abort(); } void LlmClient::endStream() { - if (!m_streaming) - return; + if (!m_streaming) return; auto* generation = m_streaming; auto* session = m_active; m_streaming = nullptr; m_active = nullptr; generation->setStreaming(false); - if (generation->content().isEmpty() && - generation->reasoning().isEmpty() && - generation->toolCallCount() == 0) { + if (generation->content().isEmpty() && generation->reasoning().isEmpty() && + generation->toolCallCount() == 0) { if (auto* message = qobject_cast(generation->parent())) { if (message->generationCount() <= 1) { - if (session) - session->removeMessage(message); + if (session) session->removeMessage(message); } else { message->removeGeneration(generation); } @@ -503,16 +446,14 @@ void LlmClient::endStream() { } void LlmClient::finishTurn() { - if (!m_streaming) - return; + if (!m_streaming) return; ChatSession* session = m_active; endStream(); if (session && m_pendingClear == session) { session->clearMessages(); m_pendingClear.clear(); } - if (session) - session->persist(); + if (session) session->persist(); } void LlmClient::clearOnFinish(ChatSession* session) { @@ -520,8 +461,7 @@ void LlmClient::clearOnFinish(ChatSession* session) { } void LlmClient::sessionRemoved(ChatSession* session) { - if (m_pendingClear == session) - m_pendingClear.clear(); + if (m_pendingClear == session) m_pendingClear.clear(); if (m_active == session) { stop(); endStream(); @@ -537,8 +477,7 @@ void LlmClient::fail(const QString& message) { void LlmClient::drainBuffer() { while (true) { const qsizetype newline = m_buffer.indexOf('\n'); - if (newline < 0) - break; + if (newline < 0) break; const QByteArray line = m_buffer.left(newline).trimmed(); m_buffer.remove(0, newline + 1); handleLine(line); @@ -546,8 +485,7 @@ void LlmClient::drainBuffer() { } void LlmClient::handleLine(const QByteArray& line) { - if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) - return; + if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) return; const QByteArray data = line.mid(5).trimmed(); if (data == "[DONE]") { @@ -556,42 +494,35 @@ void LlmClient::handleLine(const QByteArray& line) { } const QJsonDocument doc = QJsonDocument::fromJson(data); - if (!doc.isObject()) - return; + if (!doc.isObject()) return; const QJsonObject obj = doc.object(); updateTokenUsage(obj); if (obj.contains("error")) { const QJsonObject error = obj["error"].toObject(); const QString message = error["message"].toString(); - fail(message.isEmpty() - ? QStringLiteral("LLM server returned an error") - : message); + fail( + message.isEmpty() ? QStringLiteral("LLM server returned an error") + : message); return; } for (const QJsonValue& choiceValue : obj["choices"].toArray()) { - if (!m_streaming) - continue; + if (!m_streaming) continue; const QJsonObject choice = choiceValue.toObject(); const QJsonObject delta = choice["delta"].toObject(); const QString finishReason = choice[QStringLiteral("finish_reason")].toString(); - if (!finishReason.isEmpty()) - m_finishReason = finishReason; + if (!finishReason.isEmpty()) m_finishReason = finishReason; m_streaming->appendContent(delta["content"].toString()); - QString reasoning = - delta["reasoning_content"].toString(); - if (reasoning.isEmpty()) - reasoning = delta["reasoning"].toString(); + QString reasoning = delta["reasoning_content"].toString(); + if (reasoning.isEmpty()) reasoning = delta["reasoning"].toString(); m_streaming->appendReasoning(reasoning); - for (const QJsonValue& callValue : - delta["tool_calls"].toArray()) { - if (!m_streaming) - break; + for (const QJsonValue& callValue : delta["tool_calls"].toArray()) { + if (!m_streaming) break; applyToolCallDelta(callValue.toObject()); } } @@ -600,8 +531,7 @@ void LlmClient::handleLine(const QByteArray& line) { void LlmClient::refreshModels() { const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); - if (!url.isValid() || url.host().isEmpty()) - return; + if (!url.isValid() || url.host().isEmpty()) return; auto* reply = m_manager.get(QNetworkRequest(url)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { @@ -625,8 +555,7 @@ void LlmClient::refreshModels() { models.append(id); } } - if (models.isEmpty()) - return; + if (models.isEmpty()) return; m_availableModels = models; Q_EMIT availableModelsChanged(); @@ -639,66 +568,55 @@ void LlmClient::refreshModels() { } void LlmClient::setContextSize(int size) { - if (size <= 0 || m_contextSize == size) - return; + if (size <= 0 || m_contextSize == size) return; m_contextSize = size; Q_EMIT contextSizeChanged(); } void LlmClient::probeContextSize() { - // llama.cpp-specific endpoint; other servers fall back to 4096. QString base = m_endpoint.trimmed(); while (base.endsWith('/')) base.chop(1); const QUrl url = QUrl::fromUserInput(base + "/props"); - if (!url.isValid() || url.host().isEmpty()) - return; + if (!url.isValid() || url.host().isEmpty()) return; auto* reply = m_manager.get(QNetworkRequest(url)); - connect( - reply, - &QNetworkReply::finished, - this, - [this, reply]() { - const QNetworkReply::NetworkError error = reply->error(); - const QByteArray data = reply->readAll(); - reply->deleteLater(); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + const QNetworkReply::NetworkError error = reply->error(); + const QByteArray data = reply->readAll(); + reply->deleteLater(); - int size = 0; - if (error == QNetworkReply::NoError) { - const QJsonDocument doc = QJsonDocument::fromJson(data); - if (doc.isArray()) { - for (const auto& value : doc.array()) { - const QJsonObject slot = value.toObject(); - if (slot.contains("n_ctx")) { - size = slot["n_ctx"].toInt(0); - if (size > 0) - break; - } + int size = 0; + if (error == QNetworkReply::NoError) { + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (doc.isArray()) { + for (const auto& value : doc.array()) { + const QJsonObject slot = value.toObject(); + if (slot.contains("n_ctx")) { + size = slot["n_ctx"].toInt(0); + if (size > 0) break; } - } else if (doc.isObject()) { - const QJsonObject obj = doc.object(); - size = obj["n_ctx"].toInt(0); - if (size <= 0) - size = obj["default_generation_settings"].toObject() - ["n_ctx"].toInt(0); } + } else if (doc.isObject()) { + const QJsonObject obj = doc.object(); + size = obj["n_ctx"].toInt(0); + if (size <= 0) + size = obj["default_generation_settings"] + .toObject()["n_ctx"] + .toInt(0); } - setContextSize(size > 0 ? size : 4096); - }); + } + setContextSize(size > 0 ? size : 4096); + }); } void LlmClient::updateTokenUsage(const QJsonObject& data) { - if (!m_active || m_contextSize <= 0) - return; + if (!m_active || m_contextSize <= 0) return; const QJsonObject usage = data["usage"].toObject(); - if (usage.isEmpty()) - return; - const double used = - usage.value("prompt_tokens").toDouble() + - usage.value("completion_tokens").toDouble(); - if (used > 0) - m_active->setLastTokenCount(static_cast(used)); + if (usage.isEmpty()) return; + const double used = usage.value("prompt_tokens").toDouble() + + usage.value("completion_tokens").toDouble(); + if (used > 0) m_active->setLastTokenCount(static_cast(used)); } void LlmClient::shortRequest( @@ -708,11 +626,10 @@ void LlmClient::shortRequest( std::function onResult) { const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); - if (!url.isValid() || url.host().isEmpty()) - return; + if (!url.isValid() || url.host().isEmpty()) return; qInfo() << "LlmClient:" << tag << "request POST" << url.toString() - << "model=" << m_model; + << "model=" << m_model; QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); @@ -729,8 +646,7 @@ void LlmClient::shortRequest( messages.append(user); QJsonObject body; - if (!m_model.isEmpty()) - body[QStringLiteral("model")] = m_model; + if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model; body[QStringLiteral("stream")] = false; body[QStringLiteral("temperature")] = 0.3; body[QStringLiteral("max_tokens")] = 128; @@ -749,25 +665,25 @@ void LlmClient::shortRequest( reply->deleteLater(); qInfo() << "LlmClient:" << tag << "request finished" - << "error=" << reply->error() << reply->errorString() - << "http=" << reply->attribute( - QNetworkRequest::HttpStatusCodeAttribute) - .toInt() - << "response=" - << QString::fromUtf8(data.left(400)).simplified(); + << "error=" << reply->error() << reply->errorString() + << "http=" + << reply + ->attribute(QNetworkRequest::HttpStatusCodeAttribute) + .toInt() + << "response=" + << QString::fromUtf8(data.left(400)).simplified(); - if (reply->error() != QNetworkReply::NoError) - return; + if (reply->error() != QNetworkReply::NoError) return; const QJsonDocument doc = QJsonDocument::fromJson(data); const QJsonArray choices = doc.object()[QStringLiteral("choices")].toArray(); - if (choices.isEmpty()) - return; - const QString result = - choices.at(0).toObject()[QStringLiteral("message")].toObject() - [QStringLiteral("content")].toString() - .trimmed(); + if (choices.isEmpty()) return; + const QString result = choices.at(0) + .toObject()[QStringLiteral("message")] + .toObject()[QStringLiteral("content")] + .toString() + .trimmed(); qInfo() << "LlmClient:" << tag << "raw result" << result; onResult(result); }); @@ -788,24 +704,21 @@ void LlmClient::requestTitle(ChatSession* session, const QString& userText) { } const auto isQuote = [](QChar c) { return c == QLatin1Char('"') || c == QLatin1Char('\'') || - c == QChar(u'\u201C') || c == QChar(u'\u201D') || - c == QChar(u'\u2018') || c == QChar(u'\u2019'); + c == QChar(u'\u201C') || c == QChar(u'\u201D') || + c == QChar(u'\u2018') || c == QChar(u'\u2019'); }; while (title.size() >= 2 && isQuote(title.at(0)) && - isQuote(title.at(title.size() - 1))) + isQuote(title.at(title.size() - 1))) title = title.mid(1, title.size() - 2).simplified(); - while (!title.isEmpty() && - (title.endsWith(QLatin1Char('.')) || - title.endsWith(QLatin1Char('!')) || - title.endsWith(QLatin1Char('?')))) + while (!title.isEmpty() && (title.endsWith(QLatin1Char('.')) || + title.endsWith(QLatin1Char('!')) || + title.endsWith(QLatin1Char('?')))) title.chop(1); if (title.size() < 2) { - qWarning() << "LlmClient: title rejected (too short)" - << title; + qWarning() << "LlmClient: title rejected (too short)" << title; return; } - if (title.size() > 48) - title = title.left(47) + QStringLiteral("…"); + if (title.size() > 48) title = title.left(47) + QStringLiteral("…"); qInfo() << "LlmClient: suggesting title" << title; Q_EMIT titleSuggested(session, title); }); @@ -813,13 +726,11 @@ void LlmClient::requestTitle(ChatSession* session, const QString& userText) { void LlmClient::requestIcon(ChatSession* session, const QString& userText) { const QStringList icons = { - "chat", "lightbulb", "code", - "description", "article", "school", - "work", "build", "science", - "palette", "music_note", "sports_esports", - "takeout_dining", "flight", "photo_camera", - "psychology_alt", "favorite", "savings", - "gamepad", "auto_awesome", + "chat", "lightbulb", "code", "description", + "article", "school", "work", "build", + "science", "palette", "music_note", "sports_esports", + "takeout_dining", "flight", "photo_camera", "psychology_alt", + "favorite", "savings", "gamepad", "auto_awesome", }; const QString prompt = QStringLiteral( @@ -831,8 +742,7 @@ void LlmClient::requestIcon(ChatSession* session, const QString& userText) { QStringLiteral("icon"), prompt, userText, - [this, session = QPointer(session), icons]( - QString name) { + [this, session = QPointer(session), icons](QString name) { if (!session) { qWarning() << "LlmClient: icon request: session gone"; return; @@ -842,12 +752,12 @@ void LlmClient::requestIcon(ChatSession* session, const QString& userText) { return c == QLatin1Char('"') || c == QLatin1Char('\''); }; while (name.size() >= 2 && isQuote(name.at(0)) && - isQuote(name.at(name.size() - 1))) + isQuote(name.at(name.size() - 1))) name = name.mid(1, name.size() - 2).simplified(); name.replace(QLatin1Char(' '), QLatin1Char('_')); if (!icons.contains(name)) { qWarning() << "LlmClient: icon not in list, using default" - << name; + << name; name = QStringLiteral("chat"); } qInfo() << "LlmClient: suggesting icon" << name; diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp index 1397d57..56fdfc4 100644 --- a/Plugins/ZShell/Llm/llmclient.hpp +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -23,9 +23,6 @@ class ChatSession; class LlmSegment; class LlmTool; -// The only component that talks to the LLM server: owns the network -// manager, the in-flight streaming state, the SSE parsing and the -// tool-calling loop. class LlmClient : public QObject { Q_OBJECT @@ -53,17 +50,10 @@ class LlmClient : public QObject { [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } [[nodiscard]] ChatSession* streamingSession() const { return m_active; } - // Streams a new assistant reply into `target` (the active generation - // of a message of `session`). The context sent to the model is every - // message of the session that is older than the target message. - // Tool calls made by the model are executed and fed back - // transparently until the model produces its final answer. void startGeneration(ChatSession* session, ChatGeneration* target); void stop(); void endStream(); - // Clears the session's conversation once the current stream ends. void clearOnFinish(ChatSession* session); - // A session is about to be destroyed; drop any state pointing at it. void sessionRemoved(ChatSession* session); void refreshModels(); @@ -91,23 +81,13 @@ class LlmClient : public QObject { bool seen = false; }; - // Sends one streaming round: context + transcript so far. void sendRound(); - // The session context for a round, oldest first, ending just before - // `stopBeforeRow` (the message of the generation being streamed). QJsonArray buildContextMessages( ChatSession* session, int stopBeforeRow) const; void applyToolCallDelta(const QJsonObject& call); - // One round's stream ended; either ends the turn or executes the - // requested tool calls and sends the next round. Runs at most once - // per round ([DONE] and the reply's finished signal both reach it). void roundFinished(); - // Dispatches every call of the round; tools run concurrently. void executeAllCalls(); - // All results in: appends the tool messages (in call order) and - // sends the next round. void flushCallResults(); - // Ends the current turn gracefully and persists the session. void finishTurn(); void fail(const QString& message); void handleLine(const QByteArray& line); @@ -120,7 +100,8 @@ class LlmClient : public QObject { const QString& systemPrompt, const QString& userText, std::function onResult); - static QString completionsPath(const QString& endpoint, const QString& subpath); + static QString completionsPath( + const QString& endpoint, const QString& subpath); static QString serverErrorMessage( const QByteArray& body, const QString& fallback); @@ -139,7 +120,6 @@ class LlmClient : public QObject { double m_temperature = 0.7; int m_contextSize = 0; - // State of the multi-round tool loop of the current turn. QJsonArray m_transcript; QList m_callBuilders; struct ToolCallResult { diff --git a/Plugins/ZShell/Llm/markdownblock.hpp b/Plugins/ZShell/Llm/markdownblock.hpp index cbc6408..7242ed3 100644 --- a/Plugins/ZShell/Llm/markdownblock.hpp +++ b/Plugins/ZShell/Llm/markdownblock.hpp @@ -5,9 +5,6 @@ namespace ZShell::llm { -// Marker class exposing the LlmMarkdown block type enum to QML -// (LlmMarkdown.Type.*). Blocks themselves are value maps produced by -// MarkdownParser and stored on LlmSegment. class LlmMarkdown : public QObject { Q_OBJECT QML_ELEMENT @@ -16,9 +13,9 @@ class LlmMarkdown : public QObject { public: enum class Type : int { Text = 0, // Paragraph, list, quote, table; "text" is markdown source - Heading, // "level" + "text" (markdown source of the content) - Code, // "language" + "code" - Math // "latex" (display math, without the $$ delimiters) + Heading, // "level" + "text" (markdown source of the content) + Code, // "language" + "code" + Math // "latex" (display math, without the $$ delimiters) }; Q_ENUM(Type) diff --git a/Plugins/ZShell/Llm/markdownparser.cpp b/Plugins/ZShell/Llm/markdownparser.cpp index 3b9ab7d..756334d 100644 --- a/Plugins/ZShell/Llm/markdownparser.cpp +++ b/Plugins/ZShell/Llm/markdownparser.cpp @@ -13,15 +13,12 @@ namespace ZShell::llm { QVariantList MarkdownParser::parse(const QString& source) { QVariantList blocks; - if (source.trimmed().isEmpty()) - return blocks; + if (source.trimmed().isEmpty()) return blocks; const QStringList lines = source.split('\n'); - // cmark-gfm line/column numbers are 1-based and inclusive. auto sliceSource = [&](int startLine, int endLine) -> QString { - if (startLine < 1 || endLine < startLine) - return QString(); + if (startLine < 1 || endLine < startLine) return QString(); const int from = startLine; const int to = qMin(endLine, static_cast(lines.size())); return lines.mid(from - 1, to - from + 1).join('\n').trimmed(); @@ -29,15 +26,11 @@ QVariantList MarkdownParser::parse(const QString& source) { const QByteArray utf8 = source.toUtf8(); cmark_node* doc = cmark_parse_document( - utf8.constData(), static_cast(utf8.size()), + utf8.constData(), + static_cast(utf8.size()), CMARK_OPT_DEFAULT | CMARK_OPT_SOURCEPOS); - if (!doc) - return blocks; + if (!doc) return blocks; - // cmark-gfm has no math extension, so $$...$$ parses as an ordinary - // paragraph. Re-detect display math (a $$...$$ pair) here and split it - // out as its own block. Single-$ inline math is intentionally left - // untouched (rendered raw) for now. const QRegularExpression mathRe( QStringLiteral("\\$\\$(.+?)\\$\\$"), QRegularExpression::DotMatchesEverythingOption); @@ -45,29 +38,22 @@ QVariantList MarkdownParser::parse(const QString& source) { auto makeBlock = [&](LlmMarkdown::Type type) { QVariantMap block; block.insert("type", static_cast(type)); - // Stable per-position identity ("index:type") for the QML - // ScriptModel: blocks that survive a re-parse keep their id, so - // their delegates are updated in place instead of recreated - // (which would drop code highlights mid-stream). The type is - // part of the id so a block that changes type is rebuilt. - block.insert("id", - QString::number(static_cast(blocks.size())) - + QLatin1Char(':') - + QString::number(static_cast(type))); + block.insert( + "id", + QString::number(static_cast(blocks.size())) + + QLatin1Char(':') + QString::number(static_cast(type))); return block; }; auto appendText = [&](const QString& text) { - if (text.trimmed().isEmpty()) - return; + if (text.trimmed().isEmpty()) return; QVariantMap block = makeBlock(LlmMarkdown::Type::Text); block.insert("text", text); blocks.append(block); }; auto appendMath = [&](const QString& latex) { - if (latex.trimmed().isEmpty()) - return; + if (latex.trimmed().isEmpty()) return; QVariantMap block = makeBlock(LlmMarkdown::Type::Math); block.insert("latex", latex); blocks.append(block); @@ -81,8 +67,7 @@ QVariantList MarkdownParser::parse(const QString& source) { }; auto appendHeading = [&](int level, const QString& text) { - if (text.trimmed().isEmpty()) - return; + if (text.trimmed().isEmpty()) return; QVariantMap block = makeBlock(LlmMarkdown::Type::Heading); block.insert("level", level); block.insert("text", text); @@ -90,7 +75,7 @@ QVariantList MarkdownParser::parse(const QString& source) { }; for (cmark_node* node = cmark_node_first_child(doc); node; - node = cmark_node_next(node)) { + node = cmark_node_next(node)) { const cmark_node_type type = cmark_node_get_type(node); const int startLine = cmark_node_get_start_line(node); const int endLine = cmark_node_get_end_line(node); @@ -98,21 +83,20 @@ QVariantList MarkdownParser::parse(const QString& source) { if (type == CMARK_NODE_CODE_BLOCK) { const char* literal = cmark_node_get_literal(node); QString code = literal ? QString::fromUtf8(literal) : QString(); - // Fenced block literals carry a trailing newline; drop one. - if (code.endsWith('\n')) - code.chop(1); + if (code.endsWith('\n')) code.chop(1); QString language; if (const char* info = cmark_node_get_fence_info(node); info) - language = QString::fromUtf8(info).section(' ', 0, 0).trimmed().toLower(); + language = QString::fromUtf8(info) + .section(' ', 0, 0) + .trimmed() + .toLower(); appendCode(language, code); continue; } if (type == CMARK_NODE_HEADING) { - // Inline content only (no leading #), so QML can style by - // level. const char* content = cmark_node_get_string_content(node); appendHeading( cmark_node_get_heading_level(node), @@ -125,11 +109,11 @@ QVariantList MarkdownParser::parse(const QString& source) { int cursor = 0; bool anyMath = false; for (auto it = mathRe.globalMatch(text, cursor); it.hasNext(); - it = mathRe.globalMatch(text, cursor)) { + it = mathRe.globalMatch(text, cursor)) { const QRegularExpressionMatch m = it.next(); anyMath = true; - appendText( - text.mid(cursor, static_cast(m.capturedStart() - cursor))); + appendText(text.mid( + cursor, static_cast(m.capturedStart() - cursor))); appendMath(m.captured(1).trimmed()); cursor = static_cast(m.capturedEnd()); } @@ -140,9 +124,6 @@ QVariantList MarkdownParser::parse(const QString& source) { continue; } - // Lists, block quotes, tables, horizontal rules, custom blocks: - // hand the raw markdown source to QML (rendered via - // Text.MarkdownText). appendText(sliceSource(startLine, endLine)); } diff --git a/Plugins/ZShell/Llm/markdownparser.hpp b/Plugins/ZShell/Llm/markdownparser.hpp index ea739d7..433e06b 100644 --- a/Plugins/ZShell/Llm/markdownparser.hpp +++ b/Plugins/ZShell/Llm/markdownparser.hpp @@ -5,18 +5,6 @@ namespace ZShell::llm { -// Splits a markdown document into top-level blocks for QML rendering -// (cmark-gfm AST walk). Inline structure is not flattened; Text and -// Heading blocks carry markdown source that QML renders with -// Text.MarkdownText. -// -// Block map keys: -// "type" int (LlmMarkdown::Type) -// "level" int (Heading) -// "language" QString (Code, lowercased, empty when unknown) -// "code" QString (Code) -// "text" QString (Text/Heading, markdown source) -// "latex" QString (Math, without the $$ delimiters) class MarkdownParser { public: [[nodiscard]] static QVariantList parse(const QString& source); diff --git a/Plugins/ZShell/Llm/mathtext.cpp b/Plugins/ZShell/Llm/mathtext.cpp index 975f68e..8abcede 100644 --- a/Plugins/ZShell/Llm/mathtext.cpp +++ b/Plugins/ZShell/Llm/mathtext.cpp @@ -16,15 +16,10 @@ namespace ZShell::llm { namespace { -// A few px of breathing room around the equation. constexpr int kRenderMargin = 2; constexpr unsigned int kResolutionDpi = 96; -// The render cache is unbounded between clears; cap it so a very long -// session with many distinct equations cannot grow it forever. constexpr int kCacheLimit = 512; -// Registers the embedded Latin Modern faces with the font database -// (once per process) and reports which families became available. struct LatinModern { bool roman = false; bool math = false; @@ -33,37 +28,44 @@ struct LatinModern { const LatinModern& loadLatinModern() { static const LatinModern fonts = [] { LatinModern result; - // addApplicationFont(QByteArray) fails in this environment, so - // the embedded bytes are staged to a per-process temp file and - // registered through the (stable) file-based API. - const auto add = [](const unsigned char* data, size_t size, - const QString& fileName, const QString& family) { + const auto add = [](const unsigned char* data, + size_t size, + const QString& fileName, + const QString& family) { const QString path = QDir::tempPath() + QLatin1Char('/') + fileName; { QFile f(path); if (!f.open(QIODevice::WriteOnly) || - f.write(reinterpret_cast(data), - static_cast(size)) != static_cast(size)) + f.write( + reinterpret_cast(data), + static_cast(size)) != static_cast(size)) return false; } const int key = QFontDatabase::addApplicationFont(path); - if (key < 0) - return false; + if (key < 0) return false; return QFontDatabase::applicationFontFamilies(key).contains(family); }; - result.roman = - add(lmfont::lmroman10_regular, sizeof(lmfont::lmroman10_regular), - QStringLiteral("lmroman10-regular.otf"), QStringLiteral("LMRoman10")) - && add(lmfont::lmroman10_italic, sizeof(lmfont::lmroman10_italic), - QStringLiteral("lmroman10-italic.otf"), QStringLiteral("LMRoman10")) - && add(lmfont::lmroman10_bold, sizeof(lmfont::lmroman10_bold), - QStringLiteral("lmroman10-bold.otf"), QStringLiteral("LMRoman10")) - && add(lmfont::lmroman10_bolditalic, sizeof(lmfont::lmroman10_bolditalic), - QStringLiteral("lmroman10-bolditalic.otf"), - QStringLiteral("LMRoman10")); - result.math = add( - lmfont::latinmodern_math, sizeof(lmfont::latinmodern_math), - QStringLiteral("latinmodern-math.otf"), QStringLiteral("Latin Modern Math")); + result.roman = add(lmfont::lmroman10_regular, + sizeof(lmfont::lmroman10_regular), + QStringLiteral("lmroman10-regular.otf"), + QStringLiteral("LMRoman10")) && + add(lmfont::lmroman10_italic, + sizeof(lmfont::lmroman10_italic), + QStringLiteral("lmroman10-italic.otf"), + QStringLiteral("LMRoman10")) && + add(lmfont::lmroman10_bold, + sizeof(lmfont::lmroman10_bold), + QStringLiteral("lmroman10-bold.otf"), + QStringLiteral("LMRoman10")) && + add(lmfont::lmroman10_bolditalic, + sizeof(lmfont::lmroman10_bolditalic), + QStringLiteral("lmroman10-bolditalic.otf"), + QStringLiteral("LMRoman10")); + result.math = + add(lmfont::latinmodern_math, + sizeof(lmfont::latinmodern_math), + QStringLiteral("latinmodern-math.otf"), + QStringLiteral("Latin Modern Math")); return result; }(); return fonts; @@ -72,22 +74,17 @@ const LatinModern& loadLatinModern() { } // namespace LlmMathText::LlmMathText(QObject* parent) -// No parent: the renderer is used from pool threads, and a parented -// QObject would taint children it creates there. -: QObject(parent), m_renderer(std::make_shared( - nullptr, /* useFontsForGUI */ true)) { - // Latin Modern is the default font of modern LaTeX; use the embedded - // faces instead of whatever the system happens to have installed. + : QObject(parent) + , m_renderer( + std::make_shared(nullptr, /* useFontsForGUI */ true)) { const LatinModern& fonts = loadLatinModern(); if (fonts.roman) - m_renderer->setFontRomanAndMath(QStringLiteral("LMRoman10"), - JKQTMathTextFontEncoding::MTFEUnicode); + m_renderer->setFontRomanAndMath( + QStringLiteral("LMRoman10"), JKQTMathTextFontEncoding::MTFEUnicode); if (fonts.math) { - // Same pattern as JKQTMathText's useXITS(): the OpenType math - // font supplies the math alphabet and operators from its MATH - // table. - m_renderer->setFontMathRoman(QStringLiteral("Latin Modern Math"), - JKQTMathTextFontEncoding::MTFEUnicode); + m_renderer->setFontMathRoman( + QStringLiteral("Latin Modern Math"), + JKQTMathTextFontEncoding::MTFEUnicode); m_renderer->setFallbackFontSymbols( QStringLiteral("Latin Modern Math"), JKQTMathTextFontEncoding::MTFEUnicode); @@ -95,38 +92,31 @@ LlmMathText::LlmMathText(QObject* parent) } void LlmMathText::setLatex(const QString& value) { - if (m_latex == value) - return; + if (m_latex == value) return; m_latex = value; reRender(); } void LlmMathText::setColor(const QColor& value) { - if (m_color == value) - return; + if (m_color == value) return; m_color = value; reRender(); } void LlmMathText::setFontPointSize(double value) { - if (qFuzzyCompare(m_fontPointSize, value)) - return; + if (qFuzzyCompare(m_fontPointSize, value)) return; m_fontPointSize = value; reRender(); } void LlmMathText::setDevicePixelRatio(qreal value) { - if (qFuzzyCompare(m_devicePixelRatio, value)) - return; + if (qFuzzyCompare(m_devicePixelRatio, value)) return; m_devicePixelRatio = value; reRender(); } namespace { -// Process-wide render cache; the key is the full render state, so -// re-opening a chat reuses already-rendered equations. Accessed on the -// GUI thread only. struct MathRender { bool ok = false; QImage image; @@ -154,10 +144,9 @@ void LlmMathText::reRender() { return; } - const QString key = m_latex - + QLatin1Char(0x1f) + m_color.name() - + QLatin1Char(0x1f) + QString::number(m_fontPointSize) - + QLatin1Char(0x1f) + QString::number(m_devicePixelRatio); + const QString key = m_latex + QLatin1Char(0x1f) + m_color.name() + + QLatin1Char(0x1f) + QString::number(m_fontPointSize) + + QLatin1Char(0x1f) + QString::number(m_devicePixelRatio); if (auto it = mathCache().find(key); it != mathCache().end()) { m_image = it->image; m_imageUrl = it->url; @@ -168,83 +157,70 @@ void LlmMathText::reRender() { return; } - // A render is already running; it re-renders the latest state when - // it completes (id mismatch), so there is nothing to do here. - if (m_inFlight) - return; + if (m_inFlight) return; m_inFlight = true; const QString latex = m_latex; const QColor color = m_color; const double pointSize = m_fontPointSize; const qreal dpr = m_devicePixelRatio; - // The worker captures the renderer by value (shared_ptr) so it - // stays alive even if this object is destroyed mid-render; it is - // only ever used by the single in-flight worker (m_inFlight), - // never concurrently. auto renderer = m_renderer; - QThreadPool::globalInstance()->start([this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() { - MathRender render; - renderer->setFontPointSize(pointSize); - renderer->setFontColor(color); - if (renderer->parse( - latex, JKQTMathText::LatexParser, JKQTMathText::DefaultParseOptions)) { - const QImage image = renderer->drawIntoImage( - /* drawBoxes */ false, - QColor(Qt::transparent), - kRenderMargin, - dpr, - kResolutionDpi); - if (!image.isNull()) { - QByteArray png; - { - QBuffer buffer(&png); - buffer.open(QIODevice::WriteOnly); - image.save(&buffer, "PNG"); + QThreadPool::globalInstance()->start( + [this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() { + MathRender render; + renderer->setFontPointSize(pointSize); + renderer->setFontColor(color); + if (renderer->parse( + latex, + JKQTMathText::LatexParser, + JKQTMathText::DefaultParseOptions)) { + const QImage image = renderer->drawIntoImage( + /* drawBoxes */ false, + QColor(Qt::transparent), + kRenderMargin, + dpr, + kResolutionDpi); + if (!image.isNull()) { + QByteArray png; + { + QBuffer buffer(&png); + buffer.open(QIODevice::WriteOnly); + image.save(&buffer, "PNG"); + } + render.image = image; + render.url = QUrl( + QStringLiteral("data:image/png;base64,") + + QString::fromLatin1(png.toBase64())); + render.width = image.width() / dpr; + render.height = image.height() / dpr; + render.ok = true; } - render.image = image; - render.url = QUrl( - QStringLiteral("data:image/png;base64,") - + QString::fromLatin1(png.toBase64())); - // drawIntoImage renders at devicePixelRatio; convert - // back to logical pixels. - render.width = image.width() / dpr; - render.height = image.height() / dpr; - render.ok = true; } - } - // Deliver through the app instance (never destroyed) and - // re-check the pointer on the GUI thread: posting to `this` - // from the pool thread would race with its destruction. - QPointer guard(this); - QMetaObject::invokeMethod( - QCoreApplication::instance(), - [guard, id, key, render = std::move(render)]() mutable { - LlmMathText* self = guard; - if (!self) - return; - self->m_inFlight = false; - if (id != self->m_requestId) { - // Superseded while the worker ran; render the - // latest state. - self->reRender(); - return; - } - if (render.ok) { - auto& cache = mathCache(); - if (cache.size() >= kCacheLimit) - cache.clear(); - cache.insert(key, render); - } - self->m_image = render.image; - self->m_imageUrl = render.url; - self->m_width = render.width; - self->m_height = render.height; - self->m_ok = render.ok; - Q_EMIT self->changed(); - }, - Qt::QueuedConnection); - }); + QPointer guard(this); + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [guard, id, key, render = std::move(render)]() mutable { + LlmMathText* self = guard; + if (!self) return; + self->m_inFlight = false; + if (id != self->m_requestId) { + self->reRender(); + return; + } + if (render.ok) { + auto& cache = mathCache(); + if (cache.size() >= kCacheLimit) cache.clear(); + cache.insert(key, render); + } + self->m_image = render.image; + self->m_imageUrl = render.url; + self->m_width = render.width; + self->m_height = render.height; + self->m_ok = render.ok; + Q_EMIT self->changed(); + }, + Qt::QueuedConnection); + }); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/mathtext.hpp b/Plugins/ZShell/Llm/mathtext.hpp index ad25613..3f97a59 100644 --- a/Plugins/ZShell/Llm/mathtext.hpp +++ b/Plugins/ZShell/Llm/mathtext.hpp @@ -13,27 +13,20 @@ namespace ZShell::llm { -// QML wrapper around JKQTMathText (JKQtPlotter's LaTeX renderer). -// -// Parses a display-math string and renders it into a transparent -// QImage at the given device pixel ratio, off the GUI thread (with a -// process-wide cache keyed on the full render state, so re-opening a -// chat does not re-render the same equations). QML displays the image -// (scaling it to the bubble width when needed) and falls back to the -// raw LaTeX when parsing fails. class LlmMathText : public QObject { Q_OBJECT QML_ELEMENT Q_PROPERTY(QString latex READ latex WRITE setLatex NOTIFY changed) Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY changed) - Q_PROPERTY(double fontPointSize READ fontPointSize WRITE setFontPointSize NOTIFY changed) - Q_PROPERTY(qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio NOTIFY changed) + Q_PROPERTY( + double fontPointSize READ fontPointSize WRITE setFontPointSize NOTIFY + changed) + Q_PROPERTY( + qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio + NOTIFY changed) Q_PROPERTY(QImage image READ image NOTIFY changed) - // data: URL of the rendered equation; usable directly as - // Image.source (a raw QImage is not). Q_PROPERTY(QUrl imageUrl READ imageUrl NOTIFY changed) - // Logical (CSS pixel) size of the rendered equation. Q_PROPERTY(qreal width READ width NOTIFY changed) Q_PROPERTY(qreal height READ height NOTIFY changed) Q_PROPERTY(bool ok READ ok NOTIFY changed) @@ -61,8 +54,6 @@ class LlmMathText : public QObject { private: void reRender(); - // Shared so an in-flight worker render keeps the renderer alive if - // this object (and its QML item) is destroyed mid-render. std::shared_ptr m_renderer; QString m_latex; QColor m_color; @@ -73,8 +64,6 @@ class LlmMathText : public QObject { qreal m_width = 0; qreal m_height = 0; bool m_ok = false; - // Bumps on every reRender; a delivery carrying an older id was - // superseded and is dropped. int m_requestId = 0; bool m_inFlight = false; }; diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp index 6c5f2b5..070b3cb 100644 --- a/Plugins/ZShell/Llm/message.cpp +++ b/Plugins/ZShell/Llm/message.cpp @@ -16,13 +16,12 @@ ChatSession* sessionOf(const ChatMessage* message) { } // namespace ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent) -: QObject(parent), m_role(role), m_timestamp(timestamp) {} + : QObject(parent), m_role(role), m_timestamp(timestamp) {} ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) { auto* generation = new ChatGeneration(timestamp, this); m_generations.append(generation); - if (m_active < 0) - m_active = static_cast(m_generations.size() - 1); + if (m_active < 0) m_active = static_cast(m_generations.size() - 1); Q_EMIT generationsChanged(); return generation; } @@ -35,8 +34,7 @@ ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) { void ChatMessage::removeGeneration(ChatGeneration* generation) { const int index = static_cast(m_generations.indexOf(generation)); - if (index < 0) - return; + if (index < 0) return; const bool wasActive = index == m_active; m_generations.removeAt(index); delete generation; @@ -46,13 +44,11 @@ void ChatMessage::removeGeneration(ChatGeneration* generation) { m_active = static_cast(m_generations.size() - 1); } Q_EMIT generationsChanged(); - if (wasActive) - Q_EMIT activeGenerationChanged(); + if (wasActive) Q_EMIT activeGenerationChanged(); } void ChatMessage::setActiveInternal(int index) { - if (index < 0 || index >= m_generations.size() || index == m_active) - return; + if (index < 0 || index >= m_generations.size() || index == m_active) return; m_active = index; Q_EMIT activeGenerationChanged(); } @@ -64,18 +60,15 @@ void ChatMessage::setActiveGeneration(int index) { void ChatMessage::edit(const QString& newContent) { if (auto* generation = activeGeneration()) generation->setContent(newContent); - if (auto* session = sessionOf(this)) - session->persist(); + if (auto* session = sessionOf(this)) session->persist(); } void ChatMessage::retry() { - if (auto* session = sessionOf(this)) - session->retry(this); + if (auto* session = sessionOf(this)) session->retry(this); } void ChatMessage::generate() { - if (auto* session = sessionOf(this)) - session->continueFrom(this); + if (auto* session = sessionOf(this)) session->continueFrom(this); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/message.hpp b/Plugins/ZShell/Llm/message.hpp index 5fbaaf5..87310ed 100644 --- a/Plugins/ZShell/Llm/message.hpp +++ b/Plugins/ZShell/Llm/message.hpp @@ -16,24 +16,23 @@ class ChatMessage : public QObject { Q_PROPERTY(Role role READ role CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) - Q_PROPERTY(int generationCount READ generationCount NOTIFY generationsChanged) Q_PROPERTY( - QList generations READ generations - NOTIFY generationsChanged) + int generationCount READ generationCount NOTIFY generationsChanged) + Q_PROPERTY( + QList generations READ generations NOTIFY + generationsChanged) Q_PROPERTY( ZShell::llm::ChatGeneration* activeGeneration READ activeGeneration NOTIFY activeGenerationChanged) - Q_PROPERTY(int activeGenerationIndex READ activeGenerationIndex NOTIFY activeGenerationChanged) + Q_PROPERTY( + int activeGenerationIndex READ activeGenerationIndex NOTIFY + activeGenerationChanged) public: - enum class Role : int { - User = 0, - Assistant - }; + enum class Role : int { User = 0, Assistant }; Q_ENUM(Role) - explicit ChatMessage( - Role role, qint64 timestamp, QObject* parent = nullptr); + explicit ChatMessage(Role role, qint64 timestamp, QObject* parent = nullptr); [[nodiscard]] Role role() const { return m_role; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; } @@ -48,8 +47,7 @@ class ChatMessage : public QObject { } [[nodiscard]] int activeGenerationIndex() const { return m_active; } [[nodiscard]] ChatGeneration* generation(int index) const { - if (index < 0 || index >= m_generations.size()) - return nullptr; + if (index < 0 || index >= m_generations.size()) return nullptr; return m_generations.at(index); } @@ -58,7 +56,6 @@ class ChatMessage : public QObject { Q_INVOKABLE void retry(); Q_INVOKABLE void generate(); - // Creates an empty generation; callers fill it with segments. ChatGeneration* addGeneration(qint64 timestamp); ChatGeneration* appendGeneration(qint64 timestamp); void removeGeneration(ChatGeneration* generation); diff --git a/Plugins/ZShell/Llm/messagemodel.cpp b/Plugins/ZShell/Llm/messagemodel.cpp index d02c44a..9dd50c8 100644 --- a/Plugins/ZShell/Llm/messagemodel.cpp +++ b/Plugins/ZShell/Llm/messagemodel.cpp @@ -100,11 +100,9 @@ void ChatMessageModel::clear() { void ChatMessageModel::loadMessages(QList messages) { beginResetModel(); - // The new list may share rows with the current one (live rows kept - // in front of fetched rows); delete only what is truly gone. for (ChatMessage* message : m_messages) - if (std::find(messages.begin(), messages.end(), message) - == messages.end()) + if (std::find(messages.begin(), messages.end(), message) == + messages.end()) delete message; m_messages = std::move(messages); endResetModel(); diff --git a/Plugins/ZShell/Llm/messagemodel.hpp b/Plugins/ZShell/Llm/messagemodel.hpp index 3c7f44b..016c5a6 100644 --- a/Plugins/ZShell/Llm/messagemodel.hpp +++ b/Plugins/ZShell/Llm/messagemodel.hpp @@ -12,17 +12,14 @@ namespace ZShell::llm { class ChatSession; -// Owns a session's messages, most recent first: row 0 is always the newest -// message. Items are exposed through a role named "modelData" (like -// FileSystemModel), so delegates receive each ChatMessage as `modelData`. class ChatMessageModel : public QAbstractListModel { Q_OBJECT QML_ELEMENT QML_UNCREATABLE("Chat message models are owned by ChatSession") Q_PROPERTY( - ZShell::llm::ChatMessage* lastMessage READ lastMessage - NOTIFY lastMessageChanged) + ZShell::llm::ChatMessage* lastMessage READ lastMessage NOTIFY + lastMessageChanged) public: explicit ChatMessageModel(ChatSession* session, QObject* parent = nullptr); @@ -35,7 +32,6 @@ class ChatMessageModel : public QAbstractListModel { [[nodiscard]] QHash roleNames() const override; [[nodiscard]] ChatSession* session() const { return m_session; } - // Most recent message first. [[nodiscard]] QList messages() const { return m_messages; } [[nodiscard]] ChatMessage* at(int row) const; [[nodiscard]] int rowOf(const ChatMessage* message) const; @@ -44,17 +40,12 @@ class ChatMessageModel : public QAbstractListModel { return m_messages.isEmpty() ? nullptr : m_messages.first(); } - // Creates a message owned by this model without inserting it. ChatMessage* createMessage(ChatMessage::Role role, qint64 timestamp); - // Appends a new message as the newest one (row 0) with a single - // generation holding `content`. ChatMessage* appendNewest( ChatMessage::Role role, const QString& content, qint64 timestamp); void removeMessage(ChatMessage* message); void removeRange(int firstRow, int lastRow); void clear(); - // Replaces every row; takes ownership of the given messages, most recent - // first. void loadMessages(QList messages); signals: diff --git a/Plugins/ZShell/Llm/segment.cpp b/Plugins/ZShell/Llm/segment.cpp index 72bf249..e75087c 100644 --- a/Plugins/ZShell/Llm/segment.cpp +++ b/Plugins/ZShell/Llm/segment.cpp @@ -10,36 +10,27 @@ namespace ZShell::llm { namespace { -// Re-parse cadence while a segment streams; content between refreshes is -// at most this stale. constexpr int kMarkdownRefreshMs = 150; } // namespace LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent) -: QObject(parent), m_type(type), m_timestamp(timestamp) { + : QObject(parent), m_type(type), m_timestamp(timestamp) { m_markdownTimer.setInterval(kMarkdownRefreshMs); - connect( - &m_markdownTimer, &QTimer::timeout, this, [this]() { - if (!m_markdownDirty) - return; - // A parse may still be running; leave the dirty flag set so - // it re-parses the newest text when that one completes. - if (parseMarkdown()) - m_markdownDirty = false; - }); + connect(&m_markdownTimer, &QTimer::timeout, this, [this]() { + if (!m_markdownDirty) return; + if (parseMarkdown()) m_markdownDirty = false; + }); } qint64 LlmSegment::elapsedMs() const { - if (m_startedAt <= 0) - return 0; + if (m_startedAt <= 0) return 0; const qint64 end = m_endedAt > 0 ? m_endedAt - : QDateTime::currentMSecsSinceEpoch(); + : QDateTime::currentMSecsSinceEpoch(); return end - m_startedAt; } void LlmSegment::begin() { - if (m_running) - return; + if (m_running) return; m_running = true; m_startedAt = QDateTime::currentMSecsSinceEpoch(); m_endedAt = 0; @@ -54,62 +45,52 @@ void LlmSegment::close() { Q_EMIT runningChanged(); Q_EMIT elapsedMsChanged(); } - // The segment is final; parse immediately so the UI does not wait - // out the debounce. if (m_type == Type::Content && m_markdownDirty) { m_markdownTimer.stop(); - if (parseMarkdown()) - m_markdownDirty = false; + if (parseMarkdown()) m_markdownDirty = false; } } void LlmSegment::appendText(const QString& piece) { - if (piece.isEmpty()) - return; + if (piece.isEmpty()) return; m_text += piece; Q_EMIT textChanged(); scheduleMarkdown(); } void LlmSegment::setText(const QString& value) { - if (m_text == value) - return; + if (m_text == value) return; m_text = value; Q_EMIT textChanged(); scheduleMarkdown(); } void LlmSegment::setName(const QString& value) { - if (m_name == value) - return; + if (m_name == value) return; m_name = value; Q_EMIT nameChanged(); } void LlmSegment::setToolCallId(const QString& value) { - if (m_toolCallId == value) - return; + if (m_toolCallId == value) return; m_toolCallId = value; Q_EMIT toolCallIdChanged(); } void LlmSegment::appendArguments(const QString& piece) { - if (piece.isEmpty()) - return; + if (piece.isEmpty()) return; m_arguments += piece; Q_EMIT argumentsChanged(); } void LlmSegment::setResult(const QString& value) { - if (m_result == value) - return; + if (m_result == value) return; m_result = value; Q_EMIT resultChanged(); } void LlmSegment::setStatus(Status value) { - if (m_status == value) - return; + if (m_status == value) return; m_status = value; Q_EMIT statusChanged(); } @@ -126,39 +107,27 @@ void LlmSegment::restore(qint64 elapsedMs) { } void LlmSegment::scheduleMarkdown() { - // Content segments only; reasoning/tool output is never parsed. - // (User content is parsed too, so it can later be rendered as blocks - // as well; the QML currently only does that for assistant messages.) - if (m_type != Type::Content) - return; + if (m_type != Type::Content) return; m_markdownDirty = true; - if (!m_markdownTimer.isActive()) - m_markdownTimer.start(); + if (!m_markdownTimer.isActive()) m_markdownTimer.start(); } bool LlmSegment::parseMarkdown() { - if (m_parseInFlight) - return false; + if (m_parseInFlight) return false; m_parseInFlight = true; const QString text = m_text; QThreadPool::globalInstance()->start([this, text]() { const QVariantList blocks = MarkdownParser::parse(text); - // Deliver through qApp (never destroyed) and re-check the - // pointer on the GUI thread: posting to `this` from the pool - // thread would race with its destruction. QPointer guard(this); QMetaObject::invokeMethod( QCoreApplication::instance(), [guard, blocks]() { LlmSegment* seg = guard; - if (!seg) - return; + if (!seg) return; seg->m_parseInFlight = false; seg->m_markdown = blocks; Q_EMIT seg->markdownChanged(); - // Text arrived while the worker was running; parse it. - if (seg->m_markdownDirty) - seg->parseMarkdown(); + if (seg->m_markdownDirty) seg->parseMarkdown(); }, Qt::QueuedConnection); }); diff --git a/Plugins/ZShell/Llm/segment.hpp b/Plugins/ZShell/Llm/segment.hpp index 949f78b..ce125d1 100644 --- a/Plugins/ZShell/Llm/segment.hpp +++ b/Plugins/ZShell/Llm/segment.hpp @@ -8,10 +8,6 @@ namespace ZShell::llm { -// One unit of activity within a ChatGeneration. A generation keeps its -// segments in chronological order: zero or more reasoning bursts and -// tool calls interleaved, plus at most one content segment holding the -// final answer. class LlmSegment : public QObject { Q_OBJECT QML_ELEMENT @@ -27,26 +23,13 @@ class LlmSegment : public QObject { Q_PROPERTY(Status status READ status NOTIFY statusChanged) Q_PROPERTY(bool running READ running NOTIFY runningChanged) Q_PROPERTY(qint64 elapsedMs READ elapsedMs NOTIFY elapsedMsChanged) - // Parsed markdown blocks (QVariantList of maps, see MarkdownParser). - // Only Content segments are parsed. Refreshes are debounced so a - // streaming segment re-parses at a steady cadence rather than per - // chunk. Q_PROPERTY(QVariantList markdown READ markdown NOTIFY markdownChanged) public: - enum class Type : int { - Reasoning = 0, - ToolCall, - Content - }; + enum class Type : int { Reasoning = 0, ToolCall, Content }; Q_ENUM(Type) - enum class Status : int { - None = 0, - Running, - Success, - Error - }; + enum class Status : int { None = 0, Running, Success, Error }; Q_ENUM(Status) explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); @@ -63,9 +46,7 @@ class LlmSegment : public QObject { [[nodiscard]] qint64 elapsedMs() const; [[nodiscard]] QVariantList markdown() const { return m_markdown; } - // Starts the segment's clock; a no-op while already running. void begin(); - // Stops the segment's clock; a no-op when not running. void close(); void appendText(const QString& piece); void setText(const QString& value); @@ -74,9 +55,7 @@ class LlmSegment : public QObject { void appendArguments(const QString& piece); void setResult(const QString& value); void setStatus(Status value); - // Completes a tool call with the model-facing result text. void finishTool(const QString& resultText, bool success); - // Restores persisted timing without a live clock. void restore(qint64 elapsedMs); Q_SIGNALS: @@ -92,8 +71,6 @@ class LlmSegment : public QObject { private: void scheduleMarkdown(); - // Kicks off an off-thread parse; returns false when one is already - // in flight (the pending change is picked up when it completes). bool parseMarkdown(); Type m_type; diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp index d6287d8..2f083ba 100644 --- a/Plugins/ZShell/Llm/session.cpp +++ b/Plugins/ZShell/Llm/session.cpp @@ -145,10 +145,10 @@ void ChatSession::startGeneration(ChatMessage* target) { if (isLoaded()) { clientObject->startGeneration(this, generation); } else { - // The store load is still in flight; the request needs the - // full history, so start once it lands. connect( - this, &ChatSession::loaded, clientObject, + this, + &ChatSession::loaded, + clientObject, [this, generation, clientObject]() { clientObject->startGeneration(this, generation); }); @@ -192,8 +192,6 @@ void ChatSession::retry(ChatMessage* target) { const int row = m_model->rowOf(target); if (row < 0) return; - // Drop everything newer than the target, then regenerate from the - // context ending at the user message before it. m_model->removeRange(0, row - 1); if (m_model->rowCount() < 2) return; @@ -233,7 +231,6 @@ void ChatSession::clear() { } } if (!isLoaded()) { - // The load lands shortly; drop everything once it does. m_clearPending = true; return; } diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp index 5e97a78..a024322 100644 --- a/Plugins/ZShell/Llm/session.hpp +++ b/Plugins/ZShell/Llm/session.hpp @@ -44,14 +44,9 @@ class ChatSession : public QObject { [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } [[nodiscard]] bool pinned() const { return m_pinned; } [[nodiscard]] int messageCount() const { return m_messageCount; } - // The messages model; the first access starts the (async) load - // from the store. [[nodiscard]] ChatMessageModel* messagesModel(); void ensureLoaded(); - // True once the async load from the store has finished. [[nodiscard]] bool isLoaded() const { return m_loaded; } - // The model without triggering a load (ChatStore use during the - // load itself). [[nodiscard]] ChatMessageModel* model() const { return m_model; } void markLoaded(); [[nodiscard]] bool takeClearPending(); @@ -71,7 +66,6 @@ class ChatSession : public QObject { qint64 updatedAt, int messageCount); - // Replaces the model's rows with `messages` (most recent first). void adoptMessages(QList messages); void persist(); void removeMessage(ChatMessage* message); @@ -88,7 +82,6 @@ class ChatSession : public QObject { void updatedAtChanged(); void pinnedChanged(); void messageCountChanged(); - // The messages finished loading from the store. void loaded(); private: diff --git a/Plugins/ZShell/Llm/tool.cpp b/Plugins/ZShell/Llm/tool.cpp index 15a9c25..05ddb35 100644 --- a/Plugins/ZShell/Llm/tool.cpp +++ b/Plugins/ZShell/Llm/tool.cpp @@ -22,29 +22,25 @@ QJsonObject LlmTool::specification() const { ToolRegistry::ToolRegistry(QObject* parent) : QObject(parent) {} void ToolRegistry::setEnabled(bool value) { - if (m_enabled == value) - return; + if (m_enabled == value) return; m_enabled = value; Q_EMIT enabledChanged(); } void ToolRegistry::registerTool(LlmTool* tool) { - if (!tool || m_tools.contains(tool)) - return; + if (!tool || m_tools.contains(tool)) return; tool->setParent(this); m_tools.append(tool); } LlmTool* ToolRegistry::tool(const QString& name) const { for (const auto* tool : m_tools) - if (tool->name() == name) - return const_cast(tool); + if (tool->name() == name) return const_cast(tool); return nullptr; } QJsonArray ToolRegistry::specifications() const { - if (!m_enabled) - return {}; + if (!m_enabled) return {}; QJsonArray specs; for (const auto* tool : m_tools) specs.append(tool->specification()); diff --git a/Plugins/ZShell/Llm/tool.hpp b/Plugins/ZShell/Llm/tool.hpp index ef2195c..f8e12df 100644 --- a/Plugins/ZShell/Llm/tool.hpp +++ b/Plugins/ZShell/Llm/tool.hpp @@ -10,9 +10,6 @@ namespace ZShell::llm { -// A capability the model may invoke mid-turn. Tools run asynchronously -// and report exactly one result: `{"output": ...}` on success or -// `{"error": ...}` on failure. class LlmTool : public QObject { Q_OBJECT @@ -22,24 +19,16 @@ class LlmTool : public QObject { [[nodiscard]] virtual QString name() const = 0; [[nodiscard]] virtual QString description() const = 0; - // JSON Schema describing the tool's `arguments` object. [[nodiscard]] virtual QJsonObject parameters() const = 0; - // Runs the tool; `done` is invoked exactly once, with - // `{"output": ...}` on success or `{"error": ...}` on failure. - // `done` must be invoked asynchronously (on a later event loop - // iteration), never synchronously within execute(). virtual void execute( const QJsonObject& args, std::function done) = 0; - // Abandons in-flight work, if any. virtual void cancel(); - // The OpenAI-compatible `tools` entry for this tool. [[nodiscard]] QJsonObject specification() const; }; -// Owns the set of tools available to the model. class ToolRegistry : public QObject { Q_OBJECT @@ -51,12 +40,9 @@ class ToolRegistry : public QObject { [[nodiscard]] bool enabled() const { return m_enabled; } void setEnabled(bool value); - // Takes ownership; tools become children of the registry. void registerTool(LlmTool* tool); [[nodiscard]] LlmTool* tool(const QString& name) const; - // The request body's `tools` array; empty while disabled. [[nodiscard]] QJsonArray specifications() const; - // Abandons in-flight work in every tool. void cancelAll(); Q_SIGNALS: diff --git a/Plugins/ZShell/Llm/webfetchtool.cpp b/Plugins/ZShell/Llm/webfetchtool.cpp index bc573f8..ff86b8f 100644 --- a/Plugins/ZShell/Llm/webfetchtool.cpp +++ b/Plugins/ZShell/Llm/webfetchtool.cpp @@ -32,12 +32,9 @@ WebFetchTool::WebFetchTool(QObject* parent) : LlmTool(parent) {} WebFetchTool::~WebFetchTool() { for (auto* job : m_jobs) { - if (job->timer) - job->timer->stop(); - if (job->reply) - job->reply->abort(); + if (job->timer) job->timer->stop(); + if (job->reply) job->reply->abort(); } - // Pending result callbacks are dropped; the client is going away. qDeleteAll(m_jobs); } @@ -64,9 +61,9 @@ QJsonObject WebFetchTool::parameters() const { QJsonObject format; format[QStringLiteral("type")] = QStringLiteral("string"); format[QStringLiteral("enum")] = formats; - format[QStringLiteral("description")] = - QStringLiteral("The format to return the content in. Defaults to " - "text."); + format[QStringLiteral("description")] = QStringLiteral( + "The format to return the content in. Defaults to " + "text."); QJsonObject timeout; timeout[QStringLiteral("type")] = QStringLiteral("integer"); @@ -90,18 +87,14 @@ QJsonObject WebFetchTool::parameters() const { } void WebFetchTool::completeJob(Job* job, QJsonObject result) { - // Deliver on a later event loop iteration; LlmClient relies on tool - // results never arriving synchronously within execute(). QMetaObject::invokeMethod( this, [this, job, result = std::move(result)]() mutable { - if (!m_jobs.contains(job)) - return; + if (!m_jobs.contains(job)) return; auto done = std::move(job->done); m_jobs.removeAll(job); job->timer->deleteLater(); - if (job->reply) - job->reply->deleteLater(); + if (job->reply) job->reply->deleteLater(); delete job; done(std::move(result)); }, @@ -126,7 +119,7 @@ void WebFetchTool::execute( return; } if (url.scheme() != QLatin1String("http") && - url.scheme() != QLatin1String("https")) { + url.scheme() != QLatin1String("https")) { fail(QStringLiteral("URL must use http:// or https://")); return; } @@ -136,20 +129,18 @@ void WebFetchTool::execute( if (job->format != QLatin1String("html")) job->format = QStringLiteral("text"); - const int timeoutMs = qBound( - 1, - args[QStringLiteral("timeout")].toInt( - DefaultTimeoutSeconds), - MaxTimeoutSeconds) * - 1000; + const int timeoutMs = + qBound( + 1, + args[QStringLiteral("timeout")].toInt(DefaultTimeoutSeconds), + MaxTimeoutSeconds) * + 1000; job->timer = new QTimer(this); job->timer->setSingleShot(true); - connect( - job->timer, &QTimer::timeout, this, [this, job]() { - if (job->reply) - job->reply->abort(); - }); + connect(job->timer, &QTimer::timeout, this, [this, job]() { + if (job->reply) job->reply->abort(); + }); job->timer->start(timeoutMs); QNetworkRequest request(url); @@ -159,13 +150,12 @@ void WebFetchTool::execute( job->format == QLatin1String("html") ? "text/html;q=1.0, application/xhtml+xml;q=0.9, */*;q=0.1" : "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, " - "*/*;q=0.1"); + "*/*;q=0.1"); request.setRawHeader("Accept-Language", "en-US,en;q=0.9"); job->reply = m_manager.get(request); connect(job->reply, &QNetworkReply::readyRead, this, [this, job]() { - if (!job->reply) - return; + if (!job->reply) return; job->body += job->reply->readAll(); if (job->body.size() > MaxResponseBytes) { job->tooLarge = true; @@ -176,8 +166,7 @@ void WebFetchTool::execute( QNetworkReply* reply = job->reply; job->reply = nullptr; job->timer->stop(); - if (!reply) - return; + if (!reply) return; const QNetworkReply::NetworkError error = reply->error(); const QString errorString = reply->errorString(); @@ -187,13 +176,14 @@ void WebFetchTool::execute( const QByteArray contentType = reply->rawHeader("Content-Type").toLower(); const int status = - reply->attribute(QNetworkRequest::HttpStatusCodeAttribute) - .toInt(); + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); if (job->tooLarge) { - completeJob(job, makeError( - QStringLiteral("Response too large (exceeds the 5 MB " - "limit"))); + completeJob( + job, + makeError(QStringLiteral( + "Response too large (exceeds the 5 MB " + "limit"))); return; } if (error != QNetworkReply::NoError) { @@ -207,25 +197,23 @@ void WebFetchTool::execute( completeJob( job, makeError( - QStringLiteral("Server returned status %1") - .arg(status))); + QStringLiteral("Server returned status %1").arg(status))); return; } - const QString mime = - QString::fromLatin1(contentType).section(QLatin1Char(';'), 0, 0) - .trimmed(); + const QString mime = QString::fromLatin1(contentType) + .section(QLatin1Char(';'), 0, 0) + .trimmed(); if (mime.startsWith(QLatin1String("image/"))) { completeJob( job, makeError( - QStringLiteral( - "Unsupported fetched image content type: %1") + QStringLiteral("Unsupported fetched image content type: %1") .arg(mime))); return; } - const bool textual = mime.isEmpty() || - mime.startsWith(QLatin1String("text/")) || + const bool textual = + mime.isEmpty() || mime.startsWith(QLatin1String("text/")) || mime == QLatin1String("application/json") || mime.endsWith(QLatin1String("+json")) || mime == QLatin1String("application/xml") || @@ -236,19 +224,18 @@ void WebFetchTool::execute( completeJob( job, makeError( - QStringLiteral( - "Unsupported fetched file content type: %1") + QStringLiteral("Unsupported fetched file content type: %1") .arg(mime))); return; } QString content = QString::fromUtf8(body); if (mime.contains(QLatin1String("text/html")) && - job->format == QLatin1String("text")) + job->format == QLatin1String("text")) content = extractTextFromHtml(content); if (content.size() > MaxOutputChars) content = content.left(MaxOutputChars) + - QStringLiteral("\n[... truncated ...]"); + QStringLiteral("\n[... truncated ...]"); completeJob(job, makeOutput(content)); }); } @@ -256,14 +243,12 @@ void WebFetchTool::execute( void WebFetchTool::cancel() { for (auto* job : m_jobs) { job->timer->stop(); - if (job->reply) - job->reply->abort(); + if (job->reply) job->reply->abort(); } } QString WebFetchTool::decodeEntities(const QString& text) { - if (!text.contains(QLatin1Char('&'))) - return text; + if (!text.contains(QLatin1Char('&'))) return text; QString out; out.reserve(text.size()); for (qsizetype i = 0; i < text.size(); ++i) { @@ -292,10 +277,11 @@ QString WebFetchTool::decodeEntities(const QString& text) { replacement = QLatin1Char(' '); else { bool ok = false; - const quint32 codePoint = entity.startsWith(QLatin1String("#x")) || - entity.startsWith(QLatin1String("#X")) - ? entity.mid(2).toUInt(&ok, 16) - : entity.toUInt(&ok); + const quint32 codePoint = + entity.startsWith(QLatin1String("#x")) || + entity.startsWith(QLatin1String("#X")) + ? entity.mid(2).toUInt(&ok, 16) + : entity.toUInt(&ok); if (ok && codePoint != 0) { const char32_t ucs4[2] = { static_cast(codePoint), @@ -326,12 +312,18 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) { QStringLiteral("style"), }; static const QSet kVoidTags = { - QStringLiteral("area"), QStringLiteral("base"), - QStringLiteral("br"), QStringLiteral("col"), - QStringLiteral("embed"), QStringLiteral("hr"), - QStringLiteral("img"), QStringLiteral("input"), - QStringLiteral("link"), QStringLiteral("meta"), - QStringLiteral("source"), QStringLiteral("track"), + QStringLiteral("area"), + QStringLiteral("base"), + QStringLiteral("br"), + QStringLiteral("col"), + QStringLiteral("embed"), + QStringLiteral("hr"), + QStringLiteral("img"), + QStringLiteral("input"), + QStringLiteral("link"), + QStringLiteral("meta"), + QStringLiteral("source"), + QStringLiteral("track"), QStringLiteral("wbr"), }; @@ -342,55 +334,43 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) { while (i < html.size()) { const qsizetype open = html.indexOf(QLatin1Char('<'), i); if (open < 0) { - if (skipDepth == 0) - text += html.mid(i); + if (skipDepth == 0) text += html.mid(i); break; } - if (skipDepth == 0) - text += html.mid(i, open - i); + if (skipDepth == 0) text += html.mid(i, open - i); const qsizetype close = html.indexOf(QLatin1Char('>'), open); - if (close < 0) - break; + if (close < 0) break; const QString tag = html.mid(open + 1, close - open - 1).trimmed().toLower(); i = close + 1; if (tag.startsWith(QLatin1Char('!')) || - tag.startsWith(QLatin1Char('?'))) + tag.startsWith(QLatin1Char('?'))) continue; QString name = tag; if (name.startsWith(QLatin1Char('/'))) { - if (skipDepth > 0) - --skipDepth; + if (skipDepth > 0) --skipDepth; continue; } qsizetype j = 0; - while (j < name.size() && - (name.at(j).isLetterOrNumber() || - name.at(j) == QLatin1Char(':') || - name.at(j) == QLatin1Char('-'))) + while (j < name.size() && (name.at(j).isLetterOrNumber() || + name.at(j) == QLatin1Char(':') || + name.at(j) == QLatin1Char('-'))) ++j; name = name.left(j); if (kRawTags.contains(name)) { - // Raw-text element: swallow everything up to its close tag. - const qsizetype rawEnd = - html.indexOf(QStringLiteral("'), rawEnd); - if (rawClose < 0) - break; + if (rawClose < 0) break; i = rawClose + 1; continue; } - if (kVoidTags.contains(name)) - continue; + if (kVoidTags.contains(name)) continue; if (skipDepth > 0) { - // Browsers implicitly close at ; malformed pages - // without a would otherwise swallow the whole page. if (name == QLatin1String("body")) { skipDepth = 0; continue; @@ -402,7 +382,6 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) { ++skipDepth; continue; } - // Normal tag: replace with a space so words do not merge. text += QLatin1Char(' '); } @@ -411,8 +390,7 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) { for (const QString& line : out.split(QLatin1Char('\n'))) { const QString flat = line.simplified(); if (flat.isEmpty()) { - if (!lines.isEmpty() && lines.last().isEmpty()) - continue; + if (!lines.isEmpty() && lines.last().isEmpty()) continue; lines.append(QString()); } else { lines.append(flat); diff --git a/Plugins/ZShell/Llm/webfetchtool.hpp b/Plugins/ZShell/Llm/webfetchtool.hpp index 70046d1..b7bfd6b 100644 --- a/Plugins/ZShell/Llm/webfetchtool.hpp +++ b/Plugins/ZShell/Llm/webfetchtool.hpp @@ -13,11 +13,6 @@ class QTimer; namespace ZShell::llm { -// Fetches an http(s) URL and returns its content as plain text or raw -// HTML. Read-only. Mirrors opencode's webfetch tool, without markdown -// conversion and the permission prompt. Concurrent fetches are -// supported; results are always delivered on a later event loop -// iteration, never synchronously from execute(). class WebFetchTool : public LlmTool { Q_OBJECT @@ -25,8 +20,6 @@ class WebFetchTool : public LlmTool { static constexpr int MaxResponseBytes = 5 * 1024 * 1024; static constexpr int DefaultTimeoutSeconds = 30; static constexpr int MaxTimeoutSeconds = 120; - // Caps the characters handed to the model so a large page cannot - // blow out the context. static constexpr int MaxOutputChars = 64 * 1024; explicit WebFetchTool(QObject* parent = nullptr); @@ -40,8 +33,6 @@ class WebFetchTool : public LlmTool { std::function done) override; void cancel() override; - // Strips tags (skipping script/style/noscript/iframe/object/embed/ - // head) and decodes common entities. static QString extractTextFromHtml(const QString& html); static QString decodeEntities(const QString& text); From 14b54206b1c3302a286518cefac7187a105c9c2b Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 31 Aug 2026 19:02:26 +0200 Subject: [PATCH 33/90] chore: format llm c files --- Plugins/ZShell/Config/config.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Plugins/ZShell/Config/config.cpp b/Plugins/ZShell/Config/config.cpp index 8e0515d..1cdc832 100644 --- a/Plugins/ZShell/Config/config.cpp +++ b/Plugins/ZShell/Config/config.cpp @@ -91,8 +91,7 @@ Config* Config::instance() { } Config* Config::create(QQmlEngine*, QJSEngine*) { - if (!s_instance) - s_instance = new Config(); + if (!s_instance) s_instance = new Config(); return s_instance; } From 79bedd5119312311c3aa27baa8759a374258b28e Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 31 Aug 2026 22:33:55 +0200 Subject: [PATCH 34/90] chore: format python codeicons --- scripts/codeicons.py | 818 ++++++++++++++++++++----------------------- 1 file changed, 374 insertions(+), 444 deletions(-) diff --git a/scripts/codeicons.py b/scripts/codeicons.py index 4fdd751..e2a183f 100644 --- a/scripts/codeicons.py +++ b/scripts/codeicons.py @@ -8,452 +8,392 @@ from urllib.error import HTTPError from urllib.request import urlopen from xml.etree import ElementTree as ET -# --------------------------------------------------------------------------- -# Configuration -# --------------------------------------------------------------------------- - DEVICON_VERSION = "v2.17.0" ICONS = { - "c": "c", - "cpp": "cplusplus", - "csharp": "csharp", - "python": "python", - "rust": "rust", - "javascript": "javascript", - "typescript": "typescript", - "bash": "bash", - "zsh": "zsh", - "powershell": "powershell", - "cmake": "cmake", - "lua": "lua", - "java": "java", - "kotlin": "kotlin", - "swift": "swift", - "go": "go", - "dart": "dart", - "php": "php", - "ruby": "ruby", - "scala": "scala", - "haskell": "haskell", - "elixir": "elixir", - "erlang": "erlang", - "clojure": "clojure", - "r": "r", - "perl": "perl", - "zig": "zig", - "nim": "nim", - "ocaml": "ocaml", - "fsharp": "fsharp", - "visualbasic": "visualbasic", - "fortran": "fortran", - "crystal": "crystal", - "gleam": "gleam", - "julia": "julia", - "objectivec": "objectivec", - "vala": "vala", - "groovy": "groovy", - "racket": "racket", - "haxe": "haxe", - "purescript": "purescript", - "delphi": "delphi", - "coffeescript": "coffeescript", - "elm": "elm", - "awk": "awk", - "matlab": "matlab", - "solidity": "solidity", - "wasm": "wasm", - "vim": "vim", - "sql": "sqlite", - "json": "json", - "yaml": "yaml", - "xml": "xml", - "html": "html5", - "css": "css3", - "sass": "sass", - "markdown": "markdown", - "docker": "docker", - "latex": "latex", - "graphql": "graphql", + "c": "c", + "cpp": "cplusplus", + "csharp": "csharp", + "python": "python", + "rust": "rust", + "javascript": "javascript", + "typescript": "typescript", + "bash": "bash", + "zsh": "zsh", + "powershell": "powershell", + "cmake": "cmake", + "lua": "lua", + "java": "java", + "kotlin": "kotlin", + "swift": "swift", + "go": "go", + "dart": "dart", + "php": "php", + "ruby": "ruby", + "scala": "scala", + "haskell": "haskell", + "elixir": "elixir", + "erlang": "erlang", + "clojure": "clojure", + "r": "r", + "perl": "perl", + "zig": "zig", + "nim": "nim", + "ocaml": "ocaml", + "fsharp": "fsharp", + "visualbasic": "visualbasic", + "fortran": "fortran", + "crystal": "crystal", + "gleam": "gleam", + "julia": "julia", + "objectivec": "objectivec", + "vala": "vala", + "groovy": "groovy", + "racket": "racket", + "haxe": "haxe", + "purescript": "purescript", + "delphi": "delphi", + "coffeescript": "coffeescript", + "elm": "elm", + "awk": "awk", + "matlab": "matlab", + "solidity": "solidity", + "wasm": "wasm", + "vim": "vim", + "sql": "sqlite", + "json": "json", + "yaml": "yaml", + "xml": "xml", + "html": "html5", + "css": "css3", + "sass": "sass", + "markdown": "markdown", + "docker": "docker", + "latex": "latex", + "graphql": "graphql", } -# Markdown / syntax-highlighter aliases -> Devicon icon name. ALIASES = { - "cpp": "cpp", - "cc": "cpp", - "cxx": "cpp", - - "cs": "csharp", - - "js": "javascript", - "jsx": "javascript", - - "ts": "typescript", - "tsx": "typescript", - - "py": "python", - - "sh": "bash", - "shell": "bash", - - "ps1": "powershell", - - "vb": "visualbasic", - - "objective-c": "objectivec", - "obj-c": "objectivec", - - "groovyscript": "groovy", - - "pascal": "delphi", - - "coffee": "coffeescript", - - "mysql": "sql", - "postgres": "sql", - "postgresql": "sql", - "sqlite": "sql", - - "yml": "yaml", - - "htm": "html", - - "scss": "sass", - - "md": "markdown", - - "dockerfile": "docker", - "docker-compose": "docker", - - "tex": "latex", - - "gql": "graphql", + "cpp": "cpp", + "cc": "cpp", + "cxx": "cpp", + "cs": "csharp", + "js": "javascript", + "jsx": "javascript", + "ts": "typescript", + "tsx": "typescript", + "py": "python", + "sh": "bash", + "shell": "bash", + "ps1": "powershell", + "vb": "visualbasic", + "objective-c": "objectivec", + "obj-c": "objectivec", + "groovyscript": "groovy", + "pascal": "delphi", + "coffee": "coffeescript", + "mysql": "sql", + "postgres": "sql", + "postgresql": "sql", + "sqlite": "sql", + "yml": "yaml", + "htm": "html", + "scss": "sass", + "md": "markdown", + "dockerfile": "docker", + "docker-compose": "docker", + "tex": "latex", + "gql": "graphql", } -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - ICON_VARIANTS = ( - "plain", - "original", + "plain", + "original", ) def fetch_svg(devicon_name: str) -> str: - for variant in ICON_VARIANTS: - url = ( - f"https://raw.githubusercontent.com/devicons/devicon/" - f"{DEVICON_VERSION}/icons/{devicon_name}/" - f"{devicon_name}-{variant}.svg" - ) + for variant in ICON_VARIANTS: + url = ( + f"https://raw.githubusercontent.com/devicons/devicon/" + f"{DEVICON_VERSION}/icons/{devicon_name}/" + f"{devicon_name}-{variant}.svg" + ) - print(f"Fetching {devicon_name} ({variant}): {url}") + print(f"Fetching {devicon_name} ({variant}): {url}") - try: - with urlopen(url) as response: - return response.read().decode("utf-8") - except HTTPError as error: - if error.code != 404: - raise + try: + with urlopen(url) as response: + return response.read().decode("utf-8") + except HTTPError as error: + if error.code != 404: + raise - raise RuntimeError( - f"No usable SVG variant found for '{devicon_name}'." - ) + raise RuntimeError(f"No usable SVG variant found for '{devicon_name}'.") def element_to_path(element) -> str: - """Convert a basic SVG shape element to equivalent path data.""" - tag = element.tag.rsplit("}", 1)[-1] - attrib = element.attrib + """Convert a basic SVG shape element to equivalent path data.""" + tag = element.tag.rsplit("}", 1)[-1] + attrib = element.attrib - if tag == "path": - return attrib.get("d", "") + if tag == "path": + return attrib.get("d", "") - if tag == "circle": - cx = float(attrib["cx"]) - cy = float(attrib["cy"]) - r = float(attrib["r"]) - return ( - f"M {cx - r},{cy} " - f"a {r},{r} 0 1,0 {2 * r},0 " - f"a {r},{r} 0 1,0 {-2 * r},0 Z" - ) + if tag == "circle": + cx = float(attrib["cx"]) + cy = float(attrib["cy"]) + r = float(attrib["r"]) + return ( + f"M {cx - r},{cy} " + f"a {r},{r} 0 1,0 {2 * r},0 " + f"a {r},{r} 0 1,0 {-2 * r},0 Z" + ) - if tag == "ellipse": - cx = float(attrib["cx"]) - cy = float(attrib["cy"]) - rx = float(attrib["rx"]) - ry = float(attrib["ry"]) - return ( - f"M {cx - rx},{cy} " - f"a {rx},{ry} 0 1,0 {2 * rx},0 " - f"a {rx},{ry} 0 1,0 {-2 * rx},0 Z" - ) + if tag == "ellipse": + cx = float(attrib["cx"]) + cy = float(attrib["cy"]) + rx = float(attrib["rx"]) + ry = float(attrib["ry"]) + return ( + f"M {cx - rx},{cy} " + f"a {rx},{ry} 0 1,0 {2 * rx},0 " + f"a {rx},{ry} 0 1,0 {-2 * rx},0 Z" + ) - if tag == "rect": - x = float(attrib["x"]) - y = float(attrib["y"]) - width = float(attrib["width"]) - height = float(attrib["height"]) - return f"M {x},{y} h {width} v {height} h {-width} Z" + if tag == "rect": + x = float(attrib["x"]) + y = float(attrib["y"]) + width = float(attrib["width"]) + height = float(attrib["height"]) + return f"M {x},{y} h {width} v {height} h {-width} Z" - if tag in ("polygon", "polyline"): - points = attrib["points"].split() - commands = [ - f"{float(points[i])},{float(points[i + 1])}" - for i in range(0, len(points) - 1, 2) - ] - data = "M " + " L ".join(commands) - if tag == "polygon": - data += " Z" - return data + if tag in ("polygon", "polyline"): + points = attrib["points"].split() + commands = [ + f"{float(points[i])},{float(points[i + 1])}" + for i in range(0, len(points) - 1, 2) + ] + data = "M " + " L ".join(commands) + if tag == "polygon": + data += " Z" + return data - if tag == "line": - return ( - f"M {float(attrib['x1'])},{float(attrib['y1'])} " - f"L {float(attrib['x2'])},{float(attrib['y2'])}" - ) + if tag == "line": + return ( + f"M {float(attrib['x1'])},{float(attrib['y1'])} " + f"L {float(attrib['x2'])},{float(attrib['y2'])}" + ) - return "" + return "" -# --------------------------------------------------------------------------- -# SVG path data normalization -# -# Devicon path data crams arc flags against the following coordinate -# (e.g. "a28.78 28.78 0 00-2.65-7.58"), which Qt's PathSvg parser handles -# unreliably. Parse every path and re-emit it with canonical spacing. -# --------------------------------------------------------------------------- - PATH_COMMANDS = "MmLlHhVvCcSsQqTtAaZz" _NUMBER = re.compile(r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?") def _skip_separators(data: str, i: int) -> int: - while i < len(data) and data[i] in " \t\r\n,": - i += 1 - return i + while i < len(data) and data[i] in " \t\r\n,": + i += 1 + return i def _read_number(data: str, i: int) -> tuple[str, int]: - i = _skip_separators(data, i) - match = _NUMBER.match(data, i) - if not match: - raise ValueError( - f"Expected number in path data at {i}: {data[i:i + 24]!r}" - ) - return match.group(0), match.end() + i = _skip_separators(data, i) + match = _NUMBER.match(data, i) + if not match: + raise ValueError( + f"Expected number in path data at {i}: {data[i : i + 24]!r}" + ) + return match.group(0), match.end() def parse_path(data: str) -> list[tuple[str, list[str]]]: - """Parse SVG path data into (command, parameters) tuples.""" - commands: list[tuple[str, list[str]]] = [] - i, length = 0, len(data) + """Parse SVG path data into (command, parameters) tuples.""" + commands: list[tuple[str, list[str]]] = [] + i, length = 0, len(data) - while i < length: - i = _skip_separators(data, i) - if i >= length: - break + while i < length: + i = _skip_separators(data, i) + if i >= length: + break - command = data[i] - if command not in PATH_COMMANDS: - raise ValueError( - f"Unexpected character {command!r} at {i} in {data!r}" - ) - i += 1 + command = data[i] + if command not in PATH_COMMANDS: + raise ValueError( + f"Unexpected character {command!r} at {i} in {data!r}" + ) + i += 1 - if command in "Zz": - commands.append((command, [])) - continue + if command in "Zz": + commands.append((command, [])) + continue - implicit = command - while i < length: - i = _skip_separators(data, i) - if i >= length or data[i] in PATH_COMMANDS: - break + implicit = command + while i < length: + i = _skip_separators(data, i) + if i >= length or data[i] in PATH_COMMANDS: + break - if implicit in "Mm": - x, i = _read_number(data, i) - y, i = _read_number(data, i) - commands.append((implicit, [x, y])) - # Subsequent coordinate pairs after a moveto are linetos. - implicit = "L" if implicit == "M" else "l" - elif implicit in "LlTt": - x, i = _read_number(data, i) - y, i = _read_number(data, i) - commands.append((implicit, [x, y])) - elif implicit in "Hh": - x, i = _read_number(data, i) - commands.append((implicit, [x])) - elif implicit in "Vv": - y, i = _read_number(data, i) - commands.append((implicit, [y])) - elif implicit in "Cc": - # Cubic bezier: x1 y1 x2 y2 x y - p = [] - for _ in range(6): - value, i = _read_number(data, i) - p.append(value) - commands.append((implicit, p)) - elif implicit in "SsQq": - # Smooth cubic / quadratic: x1 y1 x y - p = [] - for _ in range(4): - value, i = _read_number(data, i) - p.append(value) - commands.append((implicit, p)) - elif implicit in "Aa": - rx, i = _read_number(data, i) - ry, i = _read_number(data, i) - rotation, i = _read_number(data, i) - # Arc flags are single digits, possibly crammed against - # the following coordinate, so read them positionally. - i = _skip_separators(data, i) - large_arc = data[i] - i += 1 - i = _skip_separators(data, i) - sweep = data[i] - i += 1 - if large_arc not in "01" or sweep not in "01": - raise ValueError( - f"Invalid arc flags near {i - 2} in {data!r}" - ) - x, i = _read_number(data, i) - y, i = _read_number(data, i) - commands.append( - (implicit, [rx, ry, rotation, large_arc, sweep, x, y]) - ) - else: - raise ValueError(f"Unhandled path command {implicit!r}") + if implicit in "Mm": + x, i = _read_number(data, i) + y, i = _read_number(data, i) + commands.append((implicit, [x, y])) + implicit = "L" if implicit == "M" else "l" + elif implicit in "LlTt": + x, i = _read_number(data, i) + y, i = _read_number(data, i) + commands.append((implicit, [x, y])) + elif implicit in "Hh": + x, i = _read_number(data, i) + commands.append((implicit, [x])) + elif implicit in "Vv": + y, i = _read_number(data, i) + commands.append((implicit, [y])) + elif implicit in "Cc": + p = [] + for _ in range(6): + value, i = _read_number(data, i) + p.append(value) + commands.append((implicit, p)) + elif implicit in "SsQq": + p = [] + for _ in range(4): + value, i = _read_number(data, i) + p.append(value) + commands.append((implicit, p)) + elif implicit in "Aa": + rx, i = _read_number(data, i) + ry, i = _read_number(data, i) + rotation, i = _read_number(data, i) + i = _skip_separators(data, i) + large_arc = data[i] + i += 1 + i = _skip_separators(data, i) + sweep = data[i] + i += 1 + if large_arc not in "01" or sweep not in "01": + raise ValueError( + f"Invalid arc flags near {i - 2} in {data!r}" + ) + x, i = _read_number(data, i) + y, i = _read_number(data, i) + commands.append( + (implicit, [rx, ry, rotation, large_arc, sweep, x, y]) + ) + else: + raise ValueError(f"Unhandled path command {implicit!r}") - return commands + return commands def normalize_path(data: str) -> str: - """Re-emit path data with canonical spacing (Qt PathSvg friendly).""" - return " ".join( - command if not params else command + " " + " ".join(params) - for command, params in parse_path(data) - ) + """Re-emit path data with canonical spacing (Qt PathSvg friendly).""" + return " ".join( + command if not params else command + " " + " ".join(params) + for command, params in parse_path(data) + ) def extract_paths(svg: str) -> str: - root = ET.fromstring(svg) + root = ET.fromstring(svg) - paths = [] + paths = [] - for element in root.iter(): - path = element_to_path(element) - if not path: - continue + for element in root.iter(): + path = element_to_path(element) + if not path: + continue - normalized = normalize_path(path) + normalized = normalize_path(path) - # Guard against a tokenizer that changes the path it normalizes. - if parse_path(normalized) != parse_path(path): - raise RuntimeError( - "Path normalization round-trip mismatch:\n" - f" in : {path[:80]}\n out: {normalized[:80]}" - ) + if parse_path(normalized) != parse_path(path): + raise RuntimeError( + "Path normalization round-trip mismatch:\n" + f" in : {path[:80]}\n out: {normalized[:80]}" + ) - paths.append(normalized) + paths.append(normalized) - if not paths: - raise RuntimeError("SVG contains no drawable shape elements") + if not paths: + raise RuntimeError("SVG contains no drawable shape elements") - # Multiple path elements are valid SVG path data when concatenated. - return " ".join(paths) + return " ".join(paths) def qml_string(value: str) -> str: - # JSON string escaping is valid for the string syntax we need in QML. - return json.dumps(value, ensure_ascii=False) + return json.dumps(value, ensure_ascii=False) def qml_identifier(name: str) -> str: - name = re.sub(r"[^A-Za-z0-9_]", "_", name) + name = re.sub(r"[^A-Za-z0-9_]", "_", name) - if name and name[0].isdigit(): - name = "_" + name + if name and name[0].isdigit(): + name = "_" + name - return name + return name -# --------------------------------------------------------------------------- -# Generation -# --------------------------------------------------------------------------- - def generate(output: Path) -> None: - icons = {} + icons = {} - for name, devicon_name in ICONS.items(): - svg = fetch_svg(devicon_name) - icons[name] = extract_paths(svg) + for name, devicon_name in ICONS.items(): + svg = fetch_svg(devicon_name) + icons[name] = extract_paths(svg) - output.parent.mkdir(parents=True, exist_ok=True) + output.parent.mkdir(parents=True, exist_ok=True) - with output.open("w", encoding="utf-8") as file: - file.write( - """pragma Singleton + with output.open("w", encoding="utf-8") as file: + file.write( + """pragma Singleton import Quickshell import QtQuick Singleton { """ - ) + ) - # ------------------------------------------------------------------- - # Icon path properties - # ------------------------------------------------------------------- + for icon, path in icons.items(): + identifier = qml_identifier(icon) - for icon, path in icons.items(): - identifier = qml_identifier(icon) + file.write( + f" readonly property string {identifier}: " + f"{qml_string(path)}\n" + ) - file.write( - f" readonly property string {identifier}: " - f"{qml_string(path)}\n" - ) + file.write("\n") - file.write("\n") - - # ------------------------------------------------------------------- - # Language -> icon lookup - # ------------------------------------------------------------------- - - file.write( - """ function path(language) { + file.write( + """ function path(language) { const key = language.toLowerCase() switch (key) { """ - ) + ) - for icon in icons: - identifier = qml_identifier(icon) + for icon in icons: + identifier = qml_identifier(icon) - file.write( - f' case "{icon}":\n' - f" return {identifier}\n" - ) + file.write( + f' case "{icon}":\n return {identifier}\n' + ) - for alias, icon in ALIASES.items(): - if icon not in icons: - raise RuntimeError( - f"Alias '{alias}' points to unavailable icon '{icon}'" - ) + for alias, icon in ALIASES.items(): + if icon not in icons: + raise RuntimeError( + f"Alias '{alias}' points to unavailable icon '{icon}'" + ) - identifier = qml_identifier(icon) + identifier = qml_identifier(icon) - file.write( - f' case "{alias}":\n' - f" return {identifier}\n" - ) + file.write( + f' case "{alias}":\n return {identifier}\n' + ) - file.write( - """ default: + file.write( + """ default: return "" } } @@ -461,112 +401,102 @@ Singleton { function name(language) { switch (language.toLowerCase()) { """ - ) + ) - # Human-readable names can initially be handled here. - # Add/remove these as your codeblock UI evolves. - names = { - "c": "C", - "cpp": "C++", - "csharp": "C#", - "python": "Python", - "rust": "Rust", - "javascript": "JavaScript", - "typescript": "TypeScript", - "qml": "QML", - "bash": "Bash", - "zsh": "Zsh", - "powershell": "PowerShell", - "cmake": "CMake", - "lua": "Lua", - "java": "Java", - "kotlin": "Kotlin", - "swift": "Swift", - "go": "Go", - "dart": "Dart", - "php": "PHP", - "ruby": "Ruby", - "scala": "Scala", - "haskell": "Haskell", - "elixir": "Elixir", - "erlang": "Erlang", - "clojure": "Clojure", - "r": "R", - "perl": "Perl", - "zig": "Zig", - "nim": "Nim", - "ocaml": "OCaml", - "fsharp": "F#", - "visualbasic": "Visual Basic", - "fortran": "Fortran", - "crystal": "Crystal", - "gleam": "Gleam", - "julia": "Julia", - "objectivec": "Objective-C", - "vala": "Vala", - "groovy": "Groovy", - "racket": "Racket", - "haxe": "Haxe", - "purescript": "PureScript", - "delphi": "Delphi", - "coffeescript": "CoffeeScript", - "elm": "Elm", - "awk": "AWK", - "matlab": "MATLAB", - "solidity": "Solidity", - "wasm": "Wasm", - "vim": "Vim", - "sql": "SQL", - "json": "JSON", - "yaml": "YAML", - "xml": "XML", - "html": "HTML", - "css": "CSS", - "sass": "Sass", - "markdown": "Markdown", - "docker": "Docker", - "latex": "LaTeX", - "graphql": "GraphQL", - } + names = { + "c": "C", + "cpp": "C++", + "csharp": "C#", + "python": "Python", + "rust": "Rust", + "javascript": "JavaScript", + "typescript": "TypeScript", + "qml": "QML", + "bash": "Bash", + "zsh": "Zsh", + "powershell": "PowerShell", + "cmake": "CMake", + "lua": "Lua", + "java": "Java", + "kotlin": "Kotlin", + "swift": "Swift", + "go": "Go", + "dart": "Dart", + "php": "PHP", + "ruby": "Ruby", + "scala": "Scala", + "haskell": "Haskell", + "elixir": "Elixir", + "erlang": "Erlang", + "clojure": "Clojure", + "r": "R", + "perl": "Perl", + "zig": "Zig", + "nim": "Nim", + "ocaml": "OCaml", + "fsharp": "F#", + "visualbasic": "Visual Basic", + "fortran": "Fortran", + "crystal": "Crystal", + "gleam": "Gleam", + "julia": "Julia", + "objectivec": "Objective-C", + "vala": "Vala", + "groovy": "Groovy", + "racket": "Racket", + "haxe": "Haxe", + "purescript": "PureScript", + "delphi": "Delphi", + "coffeescript": "CoffeeScript", + "elm": "Elm", + "awk": "AWK", + "matlab": "MATLAB", + "solidity": "Solidity", + "wasm": "Wasm", + "vim": "Vim", + "sql": "SQL", + "json": "JSON", + "yaml": "YAML", + "xml": "XML", + "html": "HTML", + "css": "CSS", + "sass": "Sass", + "markdown": "Markdown", + "docker": "Docker", + "latex": "LaTeX", + "graphql": "GraphQL", + } - for icon in icons: - if icon not in names: - continue + for icon in icons: + if icon not in names: + continue - name = names[icon] + name = names[icon] - file.write( - f' case "{icon}":\n' - f' return "{name}"\n' - ) + file.write(f' case "{icon}":\n return "{name}"\n') - for alias, icon in ALIASES.items(): - if icon not in names: - continue + for alias, icon in ALIASES.items(): + if icon not in names: + continue - file.write( - f' case "{alias}":\n' - f' return "{names[icon]}"\n' - ) + file.write( + f' case "{alias}":\n return "{names[icon]}"\n' + ) - file.write( - """ default: + file.write( + """ default: return language } } } """ - ) + ) - print(f"Generated {output}") + print(f"Generated {output}") -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - if len(sys.argv) != 2: - print(f"Usage: {sys.argv[0]} OUTPUT.qml", file=sys.stderr) - sys.exit(1) + print(f"Usage: {sys.argv[0]} OUTPUT.qml", file=sys.stderr) + sys.exit(1) generate(Path(sys.argv[1])) From 5ec682d780d9045d4188333a7cd377bc21a1061a Mon Sep 17 00:00:00 2001 From: AramJonghu Date: Mon, 31 Aug 2026 21:48:40 +0200 Subject: [PATCH 35/90] fix(Docker): add required deps to dockerfile --- ci/Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ci/Dockerfile b/ci/Dockerfile index e080260..d1b3965 100644 --- a/ci/Dockerfile +++ b/ci/Dockerfile @@ -1,6 +1,6 @@ FROM archlinux:latest -RUN pacman -Syu --noconfirm +RUN pacman -Syyu --noconfirm # Build toolchain RUN pacman -S --noconfirm \ @@ -10,6 +10,9 @@ RUN pacman -S --noconfirm \ clang \ lld \ nodejs \ + tree-sitter \ + jkqtplotter \ + cmark-gfm \ git # Qt6 From d5d2311c24112515520aa176211700dcc1858cd3 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 31 Aug 2026 22:54:44 +0200 Subject: [PATCH 36/90] fix: font family --- Components/TextEditBase.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/Components/TextEditBase.qml b/Components/TextEditBase.qml index 1dd7776..8eb49a3 100644 --- a/Components/TextEditBase.qml +++ b/Components/TextEditBase.qml @@ -12,6 +12,7 @@ TextEdit { color: Colors.palette.m3onSurface cursorVisible: !readOnly font.pointSize: Tokens.font.size.small + font.family: Config.appearance.font.family.sans renderType: TextField.NativeRendering selectedTextColor: color selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4) From e58440fea48e000e6dd889d4293636b336d83003 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 1 Sep 2026 14:30:04 +0200 Subject: [PATCH 37/90] chore: split cmake into multiple files and fix settings searching regex + add enabled option to llm --- CMakeLists.txt | 30 +- .../Sidebar/Chat/Content/BubbleEdit.qml | 1 - Modules/Notifications/Sidebar/Content.qml | 52 +- Modules/Settings/Common/PageBase.qml | 23 +- Modules/Settings/NavPane/NavLocations.qml | 31 +- .../Pages/Panels/Sidebar/SidebarLlm.qml | 14 + .../Settings/Pages/Panels/SidebarPanel.qml | 1 + Modules/Settings/SettingsSearcher.qml | 143 +++--- Plugins/CMakeLists.txt | 23 + Plugins/ZShell/CMakeLists.txt | 60 +-- Plugins/ZShell/Config/llm.hpp | 1 + Plugins/ZShell/zutils.cpp | 88 +++- Plugins/ZShell/zutils.hpp | 21 +- Plugins/cmake/pch.cmake | 14 + Plugins/cmake/qml-module.cmake | 45 ++ Plugins/cmake/sensorslib.cmake | 10 + Plugins/include/util/metaenum.hpp | 31 ++ scripts/build-settings-index.py | 452 ------------------ scripts/settings-indexer.js | 450 +++++++++++++++++ 19 files changed, 849 insertions(+), 641 deletions(-) create mode 100644 Plugins/cmake/pch.cmake create mode 100644 Plugins/cmake/qml-module.cmake create mode 100644 Plugins/cmake/sensorslib.cmake create mode 100644 Plugins/include/util/metaenum.hpp delete mode 100644 scripts/build-settings-index.py create mode 100644 scripts/settings-indexer.js 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 ebb3445..d97d5be 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 9aef799..2cb07e5 100644 --- a/Modules/Settings/Pages/Panels/SidebarPanel.qml +++ b/Modules/Settings/Pages/Panels/SidebarPanel.qml @@ -116,6 +116,7 @@ PageBase { NavRow { text: qsTr("AI chat") + settingAnchor: "panels-sidebar-llm" icon: "robot_2" first: true last: true 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 a20edf1..b5c4dc2 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, + }; +} From d3ac675492d0e4703ca71515ef2e8f06d67ced60 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 1 Sep 2026 14:31:44 +0200 Subject: [PATCH 38/90] fix: uncomment settings index cache path --- Modules/Settings/SettingsSearcher.qml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Modules/Settings/SettingsSearcher.qml b/Modules/Settings/SettingsSearcher.qml index 7894570..d49c911 100644 --- a/Modules/Settings/SettingsSearcher.qml +++ b/Modules/Settings/SettingsSearcher.qml @@ -112,14 +112,14 @@ Singleton { 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 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; From 0d4c08cfbb5ef07f474dc72471e31d553c7fdaa3 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 1 Sep 2026 15:03:42 +0200 Subject: [PATCH 39/90] fix: cmake install dir --- CMakeLists.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6d58d8e..0026ae2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,10 +50,10 @@ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") set(CMAKE_EXPORT_COMPILE_COMMANDS ON) 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") -set(INSTALL_QSCONFDIR "etc/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir") -set(INSTALL_GREETERCONFDIR "etc/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir") +set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/lib/ZShell" CACHE STRING "Library install dir") +set(INSTALL_QMLDIR "${CMAKE_INSTALL_LIBDIR}/lib/qt6/qml" CACHE STRING "QML install dir") +set(INSTALL_QSCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir") +set(INSTALL_GREETERCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir") set(CMAKE_INSTALL_MESSAGE NEVER) From be541d8f8d66415c90020fa6093af9065205fdf0 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 1 Sep 2026 15:03:42 +0200 Subject: [PATCH 40/90] fix: cmake install dir --- CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0026ae2..35368f4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,8 +50,8 @@ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(ENABLE_MODULES "plugin;shell;cli;m3shapes" CACHE STRING "Modules to build/install") -set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/lib/ZShell" CACHE STRING "Library install dir") -set(INSTALL_QMLDIR "${CMAKE_INSTALL_LIBDIR}/lib/qt6/qml" CACHE STRING "QML install dir") +set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/ZShell" CACHE STRING "Library install dir") +set(INSTALL_QMLDIR "${CMAKE_INSTALL_LIBDIR}/qt6/qml" CACHE STRING "QML install dir") set(INSTALL_QSCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir") set(INSTALL_GREETERCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir") From 15769ba78e47e52564661c712632b8fd5fd4e51a Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 1 Sep 2026 15:28:25 +0200 Subject: [PATCH 41/90] fix: add gnuinstalldirs include --- CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 35368f4..fc9f50a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,8 @@ set(VERSION "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}") project(ZShell VERSION ${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}) message(STATUS "ZShell version: ${VERSION}") +include(GNUInstallDirs) + set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) From 57a6e48dcb2efb53301b835a6c58d0b635c43d2d Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 1 Sep 2026 15:42:27 +0200 Subject: [PATCH 42/90] fix: property link modules --- Plugins/cmake/qml-module.cmake | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Plugins/cmake/qml-module.cmake b/Plugins/cmake/qml-module.cmake index f45a6a7..82257e5 100644 --- a/Plugins/cmake/qml-module.cmake +++ b/Plugins/cmake/qml-module.cmake @@ -2,6 +2,9 @@ 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") + + set_source_files_properties(${arg_QML_SINGLETONS} PROPERTIES QT_QML_SINGLETON_TYPE TRUE) + qt_add_qml_module(${arg_TARGET} URI ${arg_URI} SOURCES ${arg_SOURCES} @@ -38,7 +41,14 @@ function(qml_module arg_TARGET) 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}) + target_link_libraries(${arg_TARGET} PRIVATE zshell-pch Qt::Core Qt::Qml ${arg_LIBRARIES}) + + if(arg_INCLUDE_PREFIX) + set(include_dir "${CMAKE_CURRENT_BINARY_DIR}/include") + file(MAKE_DIRECTORY "${include_dir}") + file(CREATE_LINK "${CMAKE_CURRENT_SOURCE_DIR}" "${include_dir}/${arg_INCLUDE_PREFIX}" SYMBOLIC) + target_include_directories(${arg_TARGET} PUBLIC "${include_dir}") + endif() 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}") From bc927b16afbc68593fd1102dedf25896e6d45911 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 1 Sep 2026 15:49:21 +0200 Subject: [PATCH 43/90] feat: implement findChild* methods --- Plugins/ZShell/zutils.cpp | 68 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/Plugins/ZShell/zutils.cpp b/Plugins/ZShell/zutils.cpp index b6de6e0..4ae359b 100644 --- a/Plugins/ZShell/zutils.cpp +++ b/Plugins/ZShell/zutils.cpp @@ -214,6 +214,74 @@ QString ZUtils::enumToString( return QString::fromUtf8(key); } +namespace { + +template +QQuickItem* findChildDfs(QQuickItem* root, Predicate&& match) { + const auto children = root->childItems(); + for (QQuickItem* const child : children) { + if (match(child)) { + return child; + } + if (QQuickItem* const found = findChildDfs(child, match)) { + return found; + } + } + return nullptr; +} + +template +void findChildrenDfs( + QQuickItem* root, Predicate&& match, QList& out) { + const auto children = root->childItems(); + for (QQuickItem* const child : children) { + if (match(child)) { + out.append(child); + } + findChildrenDfs(child, match, out); + } +} + +} // namespace + +QQuickItem* ZUtils::findChild(QQuickItem* root, const QString& name) { + if (!root) { + return nullptr; + } + + return findChildDfs(root, [&name](const QQuickItem* item) { + return item->objectName() == name; + }); +} + +QList ZUtils::findChildren(QQuickItem* root, const QString& name) { + QList children; + if (root) { + findChildrenDfs( + root, + [&name](const QQuickItem* item) { + return item->objectName() == name; + }, + children); + } + return children; +} + +QList ZUtils::findChildrenMatching( + QQuickItem* root, const QString& pattern) { + QList children; + if (root) { + const QRegularExpression re(pattern); + findChildrenDfs( + root, + [&re](const QQuickItem* item) { + return re.match(item->objectName()).hasMatch(); + }, + children); + } + return children; +} + #ifndef ZSHELL_VERSION #define ZSHELL_VERSION "" #endif From a8fdc31b8dc1a4b23bfab1740bb8310b7920e4f8 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 1 Sep 2026 15:49:21 +0200 Subject: [PATCH 44/90] feat: implement findChild* methods --- Plugins/ZShell/zutils.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/Plugins/ZShell/zutils.cpp b/Plugins/ZShell/zutils.cpp index 4ae359b..8a81a39 100644 --- a/Plugins/ZShell/zutils.cpp +++ b/Plugins/ZShell/zutils.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include From e700a4f96b6c2b4dde4e9caf54ec1c94b07001d1 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 1 Sep 2026 23:14:21 +0200 Subject: [PATCH 45/90] use blobgroup for window chat interface + slim sidebar --- .../Sidebar/Chat/ChatContent.qml | 2 +- .../Sidebar/Chat/ChatDelegate.qml | 24 +- .../Notifications/Sidebar/Chat/ChatList.qml | 10 + .../Notifications/Sidebar/Chat/ChatState.qml | 1 + .../Sidebar/Chat/Content/ChatHost.qml | 211 +++++++++--------- .../Sidebar/Chat/SidebarView.qml | 98 ++++++-- 6 files changed, 224 insertions(+), 122 deletions(-) diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 85e2a6d..4ae1718 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -161,7 +161,7 @@ Item { scrollAnim.start(); } - cacheBuffer: Math.max(height * 20, 0) + cacheBuffer: Math.max(height * 2, 0) clip: true fadeAmount: 0.05 fadeThreshold: Tokens.padding.medium diff --git a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml index 1bcb78f..5a2613f 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml @@ -11,6 +11,7 @@ CustomClippingRect { property bool expanded: false property bool highlighted: false + property bool slim: false required property ChatSession modelData signal clicked(content: ChatSession) @@ -20,6 +21,9 @@ CustomClippingRect { implicitHeight: { let h = 0; + if (slim) + return chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2; + h += infoContainer.implicitHeight; if (expanded) @@ -85,6 +89,14 @@ CustomClippingRect { return h; } + opacity: root.slim ? 0 : 1 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + CustomRect { id: title @@ -246,7 +258,7 @@ CustomClippingRect { color: Colors.palette.m3onSurfaceVariant font.pointSize: Tokens.font.size.small maximumLineCount: 1 - opacity: !root.expanded ? 1 : 0 + opacity: !root.expanded && !root.slim ? 1 : 0 text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet") Behavior on y { @@ -347,6 +359,16 @@ CustomClippingRect { radius: Tokens.rounding.full color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3) + opacity: root.slim ? 0 : 1 + visible: opacity > 0 + enabled: visible + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + StateLayer { onClicked: root.expanded = !root.expanded } diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml index 541eb14..7f0c912 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatList.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -14,6 +14,7 @@ Item { property alias list: list property alias model: list.model property bool highlight: false + property bool slim: false signal deleteChatRequest(content: ChatSession) signal loadChatRequest(content: ChatSession, index: int) @@ -79,6 +80,7 @@ Item { implicitWidth: ListView.view.width highlighted: root.highlight && ChatState.chatSession === modelData + slim: root.slim onHighlightedChanged: if (highlighted) list.currentIndex = index @@ -248,6 +250,14 @@ Item { font.pointSize: Math.round(18 * 1.2) icon: "add" isRound: ChatState.fabExpanded + opacity: root.slim ? 0 : 1 + visible: opacity > 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } label.transform: Rotation { origin.y: fabRoot.label.height / 2 diff --git a/Modules/Notifications/Sidebar/Chat/ChatState.qml b/Modules/Notifications/Sidebar/Chat/ChatState.qml index c24e59a..1847b35 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatState.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatState.qml @@ -14,6 +14,7 @@ Singleton { property bool inChat: false property bool fabExpanded: false property bool isWindow: false + property bool windowIsWide: false property ShellScreen screen property bool sidebarVisible: Visibilities.getForActive().sidebar diff --git a/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml b/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml index 0a75bb3..9869855 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml @@ -6,127 +6,138 @@ import qs.Services import qs.Modules.Notifications.Sidebar.Chat Item { - id: root + id: root - property int animOff - property Item currentItem - property bool loading: false - property int lastIdx: -1 + property int animOff + property Item currentItem + property bool noAnim: false + property bool loading: false + property int lastIdx: -1 - readonly property Component conversationComp: ChatContent {} + readonly property Component conversationComp: ChatContent {} - function loadConversation(chat): void { - if (currentItem) { - currentItem.destroy(); - currentItem = null; - } + signal requestClose - if (!chat) - return; + function loadConversation(chat): void { + if (currentItem) { + currentItem.destroy(); + currentItem = null; + } - root.loading = true; + if (!chat) + return; - const incubator = root.conversationComp.incubateObject(container, { - chatData: chat - }); + root.loading = true; - const attach = () => { - incubator.object.anchors.fill = container; - currentItem = incubator.object; - root.loading = false; - enterAnim.start(); - }; + const incubator = root.conversationComp.incubateObject(container, { + chatData: chat + }); - if (incubator.status === Component.Ready) - attach(); - else - incubator.onStatusChanged = status => { - if (status === Component.Ready) - attach(); - }; - } + const attach = () => { + incubator.object.anchors.fill = container; + incubator.object.requestClose.connect(root.requestClose); + currentItem = incubator.object; + root.loading = false; + if (!noAnim) + enterAnim.start(); + }; - Item { - id: container + if (incubator.status === Component.Ready) + attach(); + else + incubator.onStatusChanged = status => { + if (status === Component.Ready) + attach(); + }; + } - anchors.fill: parent - layer.enabled: opacity < 1 - objectName: "ConversationContainer" + Item { + id: container - Component.onCompleted: { - if (ChatState.chatSession) - root.loadConversation(ChatState.chatSession); - } - } + anchors.fill: parent + layer.enabled: opacity < 1 + objectName: "ConversationContainer" - LoadingIndicator { - anchors.centerIn: parent - implicitSize: Tokens.font.size.extraLarge * 4 - opacity: root.loading ? 1 : 0 - visible: opacity > 0 + Component.onCompleted: { + if (ChatState.chatSession) + root.loadConversation(ChatState.chatSession); + } + } - Behavior on opacity { - Anim {} - } - } + LoadingIndicator { + anchors.centerIn: parent + implicitSize: Tokens.font.size.extraLarge * 4 + opacity: root.loading ? 1 : 0 + visible: opacity > 0 - Connections { - function onChatSessionChanged(): void { - exitAnim.complete(); - enterAnim.complete(); - root.animOff = Tokens.padding.small * (ChatState.currentIdx > root.lastIdx ? 1 : -1); - root.lastIdx = ChatState.currentIdx; - exitAnim.start(); - } + Behavior on opacity { + Anim {} + } + } - target: ChatState - } + Connections { + function onChatSessionChanged(): void { + exitAnim.complete(); + enterAnim.complete(); - SequentialAnimation { - id: exitAnim + if (root.noAnim) { + root.loadConversation(ChatState.chatSession); + return; + } - Anim { - property: "opacity" - target: container - to: 0 - type: Anim.DefaultEffects - } + root.animOff = Tokens.padding.small * (ChatState.currentIdx > root.lastIdx ? 1 : -1); + root.lastIdx = ChatState.currentIdx; + exitAnim.start(); + } - ScriptAction { - script: root.loadConversation(ChatState.chatSession) - } - } + target: ChatState + } - SequentialAnimation { - id: enterAnim + SequentialAnimation { + id: exitAnim - PropertyAction { - property: "topMargin" - target: container.anchors - value: root.animOff - } + Anim { + property: "opacity" + target: container + to: 0 + type: Anim.DefaultEffects + } - PropertyAction { - property: "bottomMargin" - target: container.anchors - value: -root.animOff - } + ScriptAction { + script: root.loadConversation(ChatState.chatSession) + } + } - ParallelAnimation { - Anim { - from: 0 - property: "opacity" - target: container - to: 1 - type: Anim.SlowEffects - } + SequentialAnimation { + id: enterAnim - Anim { - properties: "topMargin,bottomMargin" - target: container.anchors - to: 0 - type: Anim.SlowEffects - } - } - } + PropertyAction { + property: "topMargin" + target: container.anchors + value: root.animOff + } + + PropertyAction { + property: "bottomMargin" + target: container.anchors + value: -root.animOff + } + + ParallelAnimation { + Anim { + from: 0 + property: "opacity" + target: container + to: 1 + type: Anim.SlowEffects + } + + Anim { + properties: "topMargin,bottomMargin" + target: container.anchors + to: 0 + type: Anim.SlowEffects + } + } + } } diff --git a/Modules/Notifications/Sidebar/Chat/SidebarView.qml b/Modules/Notifications/Sidebar/Chat/SidebarView.qml index 1ffdb0c..210d780 100644 --- a/Modules/Notifications/Sidebar/Chat/SidebarView.qml +++ b/Modules/Notifications/Sidebar/Chat/SidebarView.qml @@ -4,38 +4,65 @@ import QtQuick import QtQuick.Layouts import QtQuick.Controls import Quickshell +import ZShell.Blobs import ZShell.Config import ZShell.Llm import qs.Components import qs.Modules.Notifications.Sidebar.Chat.Content import qs.Services -RowLayout { +Item { id: root - property int breakpoint: 700 + property int breakpoint: Math.round(Config.sidebar.sizes.width * 2) + Tokens.spacing.medium * 2 + Tokens.padding.medium * 3 + property color blobColor: Colors.tPalette.m3surfaceContainerLow + readonly property int iconSize: Math.round(Tokens.font.size.extraLarge * (96 / 72)) + Tokens.padding.large * 2 property alias conversationModel: sidebar.model + property bool chatOpen: ChatState.chatSession !== null readonly property bool isWide: root.width >= root.breakpoint - property bool narrowShowsSidebar: true + + readonly property bool showList: isWide || !chatOpen + readonly property bool showContent: !isWide && chatOpen function openConversation(conv: ChatSession, index: int): void { } + BlobGroup { + id: blobGroup + + color: root.blobColor + smoothing: Tokens.rounding.medium + } + + BlobInvertedRect { + anchors.fill: parent + borderBottom: Tokens.padding.small + borderLeft: sidebar.implicitWidth + sidebar.anchors.margins + Tokens.spacing.medium + borderRight: Tokens.padding.small + borderTop: Tokens.padding.small + group: blobGroup + opacity: root.blobColor.a + radius: Tokens.rounding.largeIncreased + } + ChatList { id: sidebar - Layout.fillHeight: true - Layout.margins: Tokens.padding.medium - Layout.rightMargin: Tokens.spacing.medium - Layout.maximumWidth: Config.sidebar.sizes.width - Layout.preferredWidth: root.width / 4 - Layout.minimumWidth: Config.sidebar.sizes.width / 2 + anchors.top: parent.top + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.margins: Tokens.padding.medium + implicitWidth: root.isWide ? Config.sidebar.sizes.width : root.iconSize highlight: true + slim: !root.isWide Behavior on implicitWidth { - Anim {} + Anim { + type: Anim.DefaultEffects + } } + model: ScriptModel { values: Chat.chats.values } @@ -46,9 +73,11 @@ RowLayout { } onDeleteChatRequest: chat => { + if (chat === ChatState.chatSession) { + ChatState.chatSession = null; + ChatState.currentIdx = -1; + } Chat.chats.remove(chat); - ChatState.currentIdx = -1; - ChatState.chatSession = null; } onNewChatRequest: { const data = Chat.chats.insert(); @@ -58,12 +87,34 @@ RowLayout { } Item { - Layout.margins: Tokens.padding.extraLarge - Layout.leftMargin: Tokens.spacing.extraLarge - Layout.topMargin: Tokens.padding.large - Layout.preferredWidth: Config.sidebar.sizes.width * 2 - Layout.fillWidth: true - Layout.fillHeight: true + id: contentArea + + property int leftMargin: sidebar.implicitWidth + + z: 1 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: Tokens.padding.medium * 2 + anchors.right: parent.right + implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 - anchors.margins + + states: State { + name: "wide" + when: !root.isWide + + PropertyChanges { + contentArea.leftMargin: root.iconSize + } + } + + transitions: Transition { + to: "wide" + + Anim { + type: Anim.DefaultEffects + property: "leftMargin" + } + } ChatHost { id: convHost @@ -71,10 +122,11 @@ RowLayout { anchors.top: parent.top anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter - implicitWidth: Math.min(parent.width, 800) + implicitWidth: Math.max(Math.min(parent.width, 800), Config.sidebar.sizes.width) clip: true - onImplicitWidthChanged: console.log(implicitWidth) + onLoadingChanged: if (!loading) + root.chatOpen = true } ColumnLayout { @@ -102,6 +154,12 @@ RowLayout { } } + Binding { + target: ChatState + property: "windowIsWide" + value: root.showList + } + Component { id: conversationView From e2786a3f1ed76bd2997501347f8f3cb5b207595f Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 3 Sep 2026 22:46:46 +0200 Subject: [PATCH 46/90] fix: truncate webfetch content to fit context --- Components/CustomRect.qml | 3 +- Components/CustomScrollBar.qml | 6 +- Modules/Launcher/AppList.qml | 19 +- Modules/Launcher/Content.qml | 2 + Modules/Launcher/Items/ActionItem.qml | 2 +- Modules/Launcher/Items/AppItem.qml | 2 +- Modules/Launcher/Items/CalcItem.qml | 5 +- Modules/Launcher/Items/FilesItem.qml | 141 ++++++++ Modules/Launcher/Items/VariantItem.qml | 2 +- Modules/Launcher/Services/Files.qml | 194 +++++++++++ .../Sidebar/Chat/ChatContent.qml | 2 +- .../Notifications/Sidebar/Chat/ChatList.qml | 35 +- .../Notifications/Sidebar/Chat/ChatState.qml | 4 + .../Sidebar/Chat/Content/ContentBubble.qml | 53 ++- .../Sidebar/Chat/Content/MessageDelegate.qml | 2 + .../Sidebar/Chat/Content/ProcessBlock.qml | 227 ++++++++++++- .../Sidebar/Chat/SidebarView.qml | 81 ++++- .../Sidebar/Utils/Cards/RecordingList.qml | 34 +- Plugins/ZShell/Llm/CMakeLists.txt | 1 + Plugins/ZShell/Llm/chat.cpp | 3 + Plugins/ZShell/Llm/filetool.cpp | 151 +++++++++ Plugins/ZShell/Llm/filetool.hpp | 26 ++ Plugins/ZShell/Llm/generation.cpp | 49 ++- Plugins/ZShell/Llm/generation.hpp | 20 ++ Plugins/ZShell/Llm/llmclient.cpp | 303 ++++++++++++++---- Plugins/ZShell/Llm/llmclient.hpp | 19 +- Plugins/ZShell/Llm/segment.hpp | 4 +- Plugins/ZShell/Llm/tool.cpp | 22 ++ Plugins/ZShell/Llm/tool.hpp | 25 ++ Plugins/ZShell/Llm/webfetchtool.cpp | 74 ++++- Plugins/ZShell/Llm/webfetchtool.hpp | 10 +- 31 files changed, 1386 insertions(+), 135 deletions(-) create mode 100644 Modules/Launcher/Items/FilesItem.qml create mode 100644 Modules/Launcher/Services/Files.qml create mode 100644 Plugins/ZShell/Llm/filetool.cpp create mode 100644 Plugins/ZShell/Llm/filetool.hpp diff --git a/Components/CustomRect.qml b/Components/CustomRect.qml index 2f5da64..b163d99 100644 --- a/Components/CustomRect.qml +++ b/Components/CustomRect.qml @@ -6,7 +6,6 @@ Rectangle { color: "transparent" Behavior on color { - CAnim { - } + CAnim {} } } diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index 6df6539..a70e9a8 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -12,7 +12,7 @@ ScrollBar { readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root readonly property real axisSize: isHorizontal ? flickable.width : flickable.height readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight - readonly property real axisContentPos: isHorizontal ? (flickable.contentX - flickable.originX) : (flickable.contentY - flickable.originY) + readonly property real axisContentPos: isHorizontal ? (reversed ? flickable.contentX : (flickable.contentX - flickable.originX)) : (reversed ? flickable.contentY : (flickable.contentY - flickable.originY)) readonly property real axisLength: isHorizontal ? root.width : root.height readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize) readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize) @@ -102,9 +102,9 @@ ScrollBar { var newPos = contentPosFromThumbStart(thumbStart); if (root.isHorizontal) - root.flickable.contentX = newPos + root.flickable.originX; + root.flickable.contentX = newPos + (root.flickable.originX + (root.reversed ? root.flickable.contentWidth : 0)); else - root.flickable.contentY = newPos + root.flickable.originY; + root.flickable.contentY = newPos + (root.flickable.originY + (root.reversed ? root.flickable.contentHeight : 0)); } function visualThumbStart() { diff --git a/Modules/Launcher/AppList.qml b/Modules/Launcher/AppList.qml index 4a0e862..72025b9 100644 --- a/Modules/Launcher/AppList.qml +++ b/Modules/Launcher/AppList.qml @@ -25,6 +25,8 @@ CustomListView { return [0]; case "variant": return SchemeVariants.query(text); + case "files": + return Files.query(text); default: return Apps.search(text); } @@ -33,7 +35,7 @@ CustomListView { function stateForText(text: string): string { const prefix = Config.launcher.actionPrefix; if (text.startsWith(prefix)) { - for (const action of ["calc", "scheme", "variant"]) + for (const action of ["calc", "scheme", "variant", "files"]) if (text.startsWith(`${prefix}${action} `)) return action; @@ -163,6 +165,13 @@ CustomListView { PropertyChanges { root.delegate: variantItem } + }, + State { + name: "files" + + PropertyChanges { + root.delegate: filesItem + } } ] transitions: Transition { @@ -264,6 +273,14 @@ CustomListView { } } + Component { + id: filesItem + + FilesItem { + list: root + } + } + Connections { function onTextChanged() { root.syncDisplayText(); diff --git a/Modules/Launcher/Content.qml b/Modules/Launcher/Content.qml index 9bb4338..3d86f0a 100644 --- a/Modules/Launcher/Content.qml +++ b/Modules/Launcher/Content.qml @@ -85,6 +85,8 @@ Item { } else if (text.startsWith(Config.launcher.actionPrefix)) { if (text.startsWith(`${Config.launcher.actionPrefix}calc `)) currentItem.onClicked(); + else if (text.startsWith(`${Config.launcher.actionPrefix}files `)) + currentItem.onClicked(); else currentItem.modelData.onClicked(list.currentList); } else { diff --git a/Modules/Launcher/Items/ActionItem.qml b/Modules/Launcher/Items/ActionItem.qml index b6ee987..6ccd88b 100644 --- a/Modules/Launcher/Items/ActionItem.qml +++ b/Modules/Launcher/Items/ActionItem.qml @@ -16,7 +16,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { root.modelData?.onClicked(root.list); diff --git a/Modules/Launcher/Items/AppItem.qml b/Modules/Launcher/Items/AppItem.qml index 24d46ba..932f69a 100644 --- a/Modules/Launcher/Items/AppItem.qml +++ b/Modules/Launcher/Items/AppItem.qml @@ -19,7 +19,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { Apps.launch(root.modelData); diff --git a/Modules/Launcher/Items/CalcItem.qml b/Modules/Launcher/Items/CalcItem.qml index 59da218..282f361 100644 --- a/Modules/Launcher/Items/CalcItem.qml +++ b/Modules/Launcher/Items/CalcItem.qml @@ -24,7 +24,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { root.onClicked(); @@ -97,8 +97,7 @@ Item { text: qsTr("Open in calculator") Behavior on opacity { - Anim { - } + Anim {} } } diff --git a/Modules/Launcher/Items/FilesItem.qml b/Modules/Launcher/Items/FilesItem.qml new file mode 100644 index 0000000..4c95075 --- /dev/null +++ b/Modules/Launcher/Items/FilesItem.qml @@ -0,0 +1,141 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import ZShell.Config +import qs.Components +import qs.Helpers +import qs.Modules.Launcher.Services +import qs.Services + +Item { + id: root + + required property var list + required property var modelData + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: Config.launcher.sizes.itemHeight + + function onClicked(): void { + root.list.visibilities.launcher = false; + if (root.modelData?.path) + Files.launch(["xdg-open", root.modelData.path]); + } + + StateLayer { + id: sLayer + + radius: Tokens.rounding.medium + manualHoverOverride: openLocation.hovered + + onClicked: { + root.onClicked(); + } + } + + IconButton { + id: openLocation + + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.medium + anchors.verticalCenter: parent.verticalCenter + icon: "open_in_new" + isRound: true + + scale: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0.4 + opacity: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + Behavior on scale { + Anim { + type: Anim.DefaultEffects + } + } + + onClicked: { + root.list.visibilities.launcher = false; + if (root.modelData?.path) + Files.showInFolder(root.modelData.path); + } + } + + Item { + anchors.fill: parent + anchors.leftMargin: Tokens.padding.small + anchors.margins: Tokens.padding.small + anchors.rightMargin: Tokens.padding.small + + IconImage { + id: icon + + anchors.verticalCenter: parent.verticalCenter + implicitSize: Tokens.font.size.extraLarge + source: Quickshell.iconPath(root.modelData?.icon) ?? "" + visible: !root.modelData?.isImage + } + + Loader { + active: opacity > 0 + anchors.verticalCenter: parent.verticalCenter + asynchronous: true + opacity: root.modelData?.isImage && image.status === Image.Loading ? 1 : 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + sourceComponent: LoadingIndicator { + color: Colors.palette.m3primaryContainer + implicitSize: Tokens.font.size.extraLarge + } + } + + FadeImage { + id: image + + property int implicitSize: Tokens.font.size.extraLarge + + width: implicitSize + fillMode: Image.PreserveAspectFit + anchors.verticalCenter: parent.verticalCenter + source: root.modelData?.isImage ? root.modelData?.path ?? "" : "" + visible: root.modelData?.isImage + } + + Item { + anchors.left: icon.right + anchors.leftMargin: Tokens.spacing.medium + anchors.verticalCenter: icon.verticalCenter + implicitHeight: name.implicitHeight + desc.implicitHeight + implicitWidth: parent.width - icon.width + + CustomText { + id: name + + font.pointSize: Tokens.font.size.normal + text: root.modelData?.name ?? "" + elide: Text.ElideRight + width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2 + } + + CustomText { + id: desc + + anchors.top: name.bottom + color: Colors.palette.m3outline + elide: Text.ElideRight + font.pointSize: Tokens.font.size.small + text: root.modelData?.path ?? "" + width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2 + } + } + } +} diff --git a/Modules/Launcher/Items/VariantItem.qml b/Modules/Launcher/Items/VariantItem.qml index ca5d6cd..cb37cd3 100644 --- a/Modules/Launcher/Items/VariantItem.qml +++ b/Modules/Launcher/Items/VariantItem.qml @@ -15,7 +15,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight StateLayer { - radius: Tokens.rounding.small + radius: Tokens.rounding.medium onClicked: { root.modelData?.onClicked(root.list); diff --git a/Modules/Launcher/Services/Files.qml b/Modules/Launcher/Services/Files.qml new file mode 100644 index 0000000..aebbbb0 --- /dev/null +++ b/Modules/Launcher/Services/Files.qml @@ -0,0 +1,194 @@ +// qs/Services/Files.qml +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import ZShell.Config + +Singleton { + id: root + + property list results: [] + + property string _pendingQuery: "" + property int _generation: 0 + + readonly property var _imageExts: ({ + "png": true, + "jpg": true, + "jpeg": true, + "gif": true, + "bmp": true, + "webp": true, + "svg": true, + "tiff": true, + "tif": true, + "ico": true, + "heic": true, + "avif": true + }) + + // freedesktop mimetype-icon names; extend as needed + readonly property var _extIcons: ({ + // images + "png": "image-x-generic", + "jpg": "image-x-generic", + "jpeg": "image-x-generic", + "gif": "image-x-generic", + "bmp": "image-x-generic", + "webp": "image-x-generic", + "svg": "image-x-generic", + "tiff": "image-x-generic", + "tif": "image-x-generic", + "ico": "image-x-generic", + "heic": "image-x-generic", + "avif": "image-x-generic", + // documents + "pdf": "application-pdf", + "doc": "x-office-document", + "docx": "x-office-document", + "odt": "x-office-document", + "txt": "text-x-generic", + "md": "text-x-generic", + "xls": "x-office-spreadsheet", + "xlsx": "x-office-spreadsheet", + "ods": "x-office-spreadsheet", + "ppt": "x-office-presentation", + "pptx": "x-office-presentation", + "odp": "x-office-presentation", + // archives + "zip": "package-x-generic", + "tar": "package-x-generic", + "gz": "package-x-generic", + "xz": "package-x-generic", + "7z": "package-x-generic", + "rar": "package-x-generic", + // audio/video + "mp3": "audio-x-generic", + "flac": "audio-x-generic", + "wav": "audio-x-generic", + "ogg": "audio-x-generic", + "mp4": "video-x-generic", + "mkv": "video-x-generic", + "webm": "video-x-generic", + "avi": "video-x-generic", + "mov": "video-x-generic", + // code + "js": "text-x-script", + "ts": "text-x-script", + "py": "text-x-script", + "sh": "text-x-script", + "qml": "text-x-script", + "cpp": "text-x-c++src", + "c": "text-x-csrc", + "h": "text-x-chdr", + "json": "application-json", + "html": "text-html", + "css": "text-css" + }) + + function showInFolder(path: string): void { + const explorer = Config.general.apps.explorer; + let args = []; + + if (explorer.includes("dolphin")) + args = [...explorer, "--select", path]; + else + args = [...explorer, path]; + + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--", ...args]); + else + Quickshell.execDetached([...args]); + } + + function launch(command: list): void { + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--"].concat(command)); + else + Quickshell.execDetached(command); + } + + function transformSearch(search: string): string { + return search.slice(Config.launcher.actionPrefix.length + "files ".length); + } + + function query(search: string): list { + search = transformSearch(search); + if (search !== _pendingQuery) { + _pendingQuery = search; + debounceTimer.restart(); + } + return results; + } + + function _extOf(name: string): string { + const i = name.lastIndexOf("."); + // no dot, or dotfile with no real extension (".bashrc" -> treat as no ext) + if (i <= 0) + return ""; + return name.slice(i + 1).toLowerCase(); + } + + function _iconFor(name: string, isDir: bool): string { + if (isDir) + return "folder"; + const ext = _extOf(name); + return _extIcons[ext] ?? "text-x-generic"; + } + + function _buildEntry(path: string): var { + const name = path.split("/").pop(); + const ext = _extOf(name); + // trailing slash from plocate's directory output (if -d/dir results ever appear) + const isDir = path.endsWith("/"); + + return { + path: path, + name: name, + icon: _iconFor(name, isDir), + isImage: !isDir && (_imageExts[ext] ?? false) + }; + } + + Timer { + id: debounceTimer + interval: 120 + onTriggered: root._runQuery(root._pendingQuery) + } + + function _runQuery(search: string): void { + _generation++; + + if (!search) { + proc.running = false; + results = []; + return; + } + + if (proc.running) + proc.running = false; + + proc.buffer = []; + proc.myGeneration = _generation; + proc.command = ["plocate", "-i", "-b", "--limit", "50", search]; + proc.running = true; + } + + Process { + id: proc + + property var buffer: [] + property int myGeneration: 0 + + stdout: SplitParser { + onRead: line => proc.buffer.push(root._buildEntry(line)) + } + + onRunningChanged: { + if (!running && myGeneration === root._generation) + root.results = buffer; + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 4ae1718..85e2a6d 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -161,7 +161,7 @@ Item { scrollAnim.start(); } - cacheBuffer: Math.max(height * 2, 0) + cacheBuffer: Math.max(height * 20, 0) clip: true fadeAmount: 0.05 fadeThreshold: Tokens.padding.medium diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml index 7f0c912..d9a17fe 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatList.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -16,6 +16,7 @@ Item { property bool highlight: false property bool slim: false + signal requestExpand signal deleteChatRequest(content: ChatSession) signal loadChatRequest(content: ChatSession, index: int) signal newChatRequest @@ -112,8 +113,15 @@ Item { radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2 function prettyModelName(): string { + if (modelsList.count < 1) + return qsTr("No models found"); + const pathList = Chat.model.split("\/"); const name = pathList[pathList.length - 1]; + + if (!name) + return qsTr("No model selected"); + return name; } @@ -133,6 +141,7 @@ Item { implicitHeight: fabRoot.implicitHeight CustomText { + id: label text: modelsContainer.prettyModelName() color: Colors.palette.m3onSurfaceVariant anchors.left: parent.left @@ -148,10 +157,13 @@ Item { text: modelsContainer.expanded ? "unfold_less" : "unfold_more" animate: true font.pointSize: Tokens.font.size.large + visible: modelsList.count > 0 } StateLayer { id: modelLayer + enabled: modelsList.count > 0 + onClicked: { ChatState.fabExpanded = false; modelsContainer.expanded = !modelsContainer.expanded; @@ -248,16 +260,16 @@ Item { anchors.margins: Tokens.padding.small padding: 8 font.pointSize: Math.round(18 * 1.2) - icon: "add" + icon: root.slim ? "left_panel_open" : "add" isRound: ChatState.fabExpanded - opacity: root.slim ? 0 : 1 - visible: opacity > 0 - - Behavior on opacity { - Anim { - type: Anim.DefaultEffects - } - } + // opacity: root.slim ? 0 : 1 + // visible: opacity > 0 + // + // Behavior on opacity { + // Anim { + // type: Anim.DefaultEffects + // } + // } label.transform: Rotation { origin.y: fabRoot.label.height / 2 @@ -270,6 +282,11 @@ Item { } onClicked: { + if (root.slim && !ChatState.narrowSidebarExpanded) { + root.requestExpand(); + return; + } + modelsContainer.expanded = false; ChatState.fabExpanded = !ChatState.fabExpanded; } diff --git a/Modules/Notifications/Sidebar/Chat/ChatState.qml b/Modules/Notifications/Sidebar/Chat/ChatState.qml index 1847b35..df83c8e 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatState.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatState.qml @@ -14,6 +14,10 @@ Singleton { property bool inChat: false property bool fabExpanded: false property bool isWindow: false + property bool isWide: false + property bool settledSlim: false + property bool settledWide: false + property bool narrowSidebarExpanded: false property bool windowIsWide: false property ShellScreen screen property bool sidebarVisible: Visibilities.getForActive().sidebar diff --git a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml index 75666af..86581c4 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Layouts import ZShell.Llm import ZShell.Config +import qs.Modules.Notifications.Sidebar.Chat import qs.Components import qs.Services @@ -16,32 +17,60 @@ Item { required property ChatMessage message required property ChatGeneration current - // Widest the bubble may grow to; assistant bubbles always use it so - // the markdown blocks have a deterministic width to wrap against. readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge signal edit(text: string) - implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin + implicitHeight: bubble.implicitHeight + (actionsRow.shouldBeActive ? actionsRow.implicitHeight + actionsRow.anchors.topMargin : 0) CustomClippingRect { id: bubble + property real liveTargetWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth + property real frozenWidth: 0.0 + property bool layoutFrozen: false + property real layoutWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth + radius: Tokens.rounding.medium color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer - implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth + implicitWidth: layoutFrozen ? frozenWidth : layoutWidth implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2 anchors.right: root.isUser ? parent.right : undefined + layer.enabled: false + layer.smooth: true - // Behavior on implicitHeight { - // enabled: !root.segment.running - // - // Anim { - // type: Anim.DefaultEffects - // } - // } + transform: Scale { + id: stretchScale + origin.x: root.isUser ? bubble.width : 0 + xScale: bubble.layoutFrozen && bubble.frozenWidth > 0 ? bubble.liveTargetWidth / bubble.frozenWidth : 1.0 + } + + states: [ + State { + name: "toSlim" + when: ChatState.isWindow && !ChatState.isWide && !ChatState.settledSlim + + PropertyChanges { + bubble.layer.enabled: true + bubble.layoutFrozen: true + } + }, + State { + name: "toWide" + when: ChatState.isWindow && ChatState.isWide && !ChatState.settledWide + + PropertyChanges { + bubble.layer.enabled: true + bubble.layoutFrozen: true + } + } + ] + + onLayoutFrozenChanged: { + if (layoutFrozen) + frozenWidth = width; + } - // User messages stay a plain editable text field. TextEditBase { id: msgText diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index 7f267b1..2ad31af 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -160,6 +160,7 @@ MouseArea { anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right + spacing: Tokens.spacing.medium Repeater { id: segmentRep @@ -177,6 +178,7 @@ MouseArea { delegate: ProcessBlock { width: root.width blocks: root.blocks + current: root.current onExpandedChanged: root.handleReasoningToggle(expanded) } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml index c681bfb..9323d64 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -1,23 +1,39 @@ import QtQuick import QtQuick.Layouts +import ZShell.Blobs import ZShell.Config +import ZShell.Components import ZShell.Llm import qs.Components import qs.Services -CustomClippingRect { +Item { id: root required property int index required property var modelData required property var blocks readonly property var segments: modelData.segments + required property ChatGeneration current readonly property bool isActive: index === blocks.length - 1 property bool expanded: false readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1] readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0) + readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).every(s => current.pendingToolCalls.includes(s)) - implicitHeight: expanded ? expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin * 2 : layout.implicitHeight + Tokens.spacing.small + clip: true + implicitHeight: { + const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin; + const collapsed = layout.implicitHeight; + const approval = layout.implicitHeight + approvalPrompt.implicitHeight + approvalPrompt.anchors.topMargin; + + if (expanded) + return expand; + else if (pendingApproval) + return approval; + else + return collapsed; + } Behavior on implicitHeight { Anim { @@ -57,6 +73,7 @@ CustomClippingRect { inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline anchors.centerIn: parent opacity: root.isActive ? 0 : 1 + enabled: opacity > 0 rotation: root.expanded ? 180 : 0 type: IconButton.Text @@ -77,6 +94,9 @@ CustomClippingRect { Layout.leftMargin: Tokens.spacing.medium text: { if (root.isActive) { + if (root.pendingApproval) + return qsTr("Waiting for approval..."); + if (root.lastSegment.type === LlmSegment.Type.ToolCall) return qsTr("Using %1...").arg(root.lastSegment.name); return qsTr("Thinking..."); @@ -90,6 +110,163 @@ CustomClippingRect { } } + Item { + id: approvalPrompt + anchors.top: layout.bottom + anchors.topMargin: Tokens.spacing.small + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2 + visible: root.pendingApproval + + CustomRect { + id: approvalBg + + anchors.fill: parent + radius: Tokens.rounding.medium + color: Colors.palette.m3surface + border.width: 1 + border.color: Colors.palette.m3outlineVariant + + ColumnLayout { + id: approvalLayout + anchors.fill: parent + anchors.margins: Tokens.padding.medium + spacing: Tokens.spacing.small + + CustomText { + id: requestHeader + text: qsTr("Tool call request") + font.pointSize: Tokens.font.size.large + color: Colors.palette.m3onSurface + } + + Item { + id: toolsBg + + property bool open: false + + implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2 + Layout.fillWidth: true + + BlobGroup { + id: blobGroup + + color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer + + Behavior on color { + CAnim {} + } + } + + Item { + id: toolWrapper + + width: toolsBg.width + height: toolList.implicitHeight + toolList.anchors.margins * 2 + + BlobRect { + id: dialogBg + + anchors.fill: parent + bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + deformScale: 0 + group: blobGroup + opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) + } + + CustomRect { + id: toolBox + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + + ColumnLayout { + id: toolList + + Repeater { + model: root.current.pendingToolCalls ?? [] + delegate: RowLayout { + id: toolText + + required property LlmSegment modelData + required property int index + + spacing: Tokens.spacing.medium + + function toolTarget(seg: LlmSegment): string { + var a = null; + try { + a = JSON.parse(seg.arguments); + } catch (e) {} + if (a && typeof a === "object") { + const v = a.url ?? a.path; + if (v !== undefined && v !== null) + return String(v); + } + return String(seg.arguments); + } + + CustomText { + color: Colors.palette.m3onPrimaryContainer + text: String(toolText.index + 1) + font.pointSize: Tokens.font.size.large + } + + CustomText { + color: Colors.palette.m3onPrimaryContainer + text: qsTr("%1").arg(toolText.modelData.name) + } + + MaterialIcon { + color: Colors.palette.m3onPrimaryContainer + text: "arrow_right_alt" + } + + CustomText { + color: Colors.palette.m3onPrimaryContainer + text: qsTr("%1").arg(toolText.toolTarget(toolText.modelData)) + } + } + } + } + } + } + } + + ButtonRow { + Layout.alignment: Qt.AlignRight + spacing: Tokens.spacing.medium + + IconTextButton { + icon: "check_circle" + text: qsTr("Allow") + horizontalPadding: Tokens.padding.large + verticalPadding: Tokens.padding.small + isRound: true + shapeMorph: true + onClicked: root.current.approveTools() + } + + IconTextButton { + icon: "cancel" + text: qsTr("Deny") + isRound: true + shapeMorph: true + inactiveColor: Colors.palette.m3error + inactiveOnColor: Colors.palette.m3onError + horizontalPadding: Tokens.padding.large + verticalPadding: Tokens.padding.small + onClicked: root.current.denyTools() + } + } + } + } + } + CustomRect { id: expandedRect @@ -98,7 +275,6 @@ CustomClippingRect { anchors.top: layout.bottom anchors.bottom: parent.bottom anchors.topMargin: Tokens.spacing.small - anchors.bottomMargin: Tokens.spacing.small color: Colors.palette.m3surfaceContainerLow implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2 opacity: root.expanded ? 1 : 0 @@ -147,6 +323,49 @@ CustomClippingRect { CustomText { id: content + function toolTarget(seg: LlmSegment): string { + var a = null; + try { + a = JSON.parse(seg.arguments); + } catch (e) {} + if (a && typeof a === "object") { + const v = a.url ?? a.path; + if (v !== undefined && v !== null) + return String(v); + } + return String(seg.arguments); + } + + function prettyTool(seg: LlmSegment): string { + if (seg.name === "webfetch") { + switch (seg.status) { + case LlmSegment.Status.Running: + return qsTr("Fetching"); + case LlmSegment.Status.Pending: + return qsTr("Pending fetch"); + case LlmSegment.Status.Success: + return qsTr("Fetched"); + case LlmSegment.Status.Error: + return qsTr("Failed to fetch"); + default: + return qsTr("Fetched website"); + } + } else if (seg.name === "readfile") { + switch (seg.status) { + case LlmSegment.Status.Running: + return qsTr("Reading"); + case LlmSegment.Status.Pending: + return qsTr("Pending read"); + case LlmSegment.Status.Success: + return qsTr("Read"); + case LlmSegment.Status.Error: + return qsTr("Failed to read"); + default: + return qsTr("Read file"); + } + } + } + anchors.left: parent.left anchors.leftMargin: Tokens.padding.small anchors.top: header.bottom @@ -155,7 +374,7 @@ CustomClippingRect { color: Colors.palette.m3outline font.pointSize: Tokens.font.size.small wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere - text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("Fetched %1").arg(JSON.parse(segment.modelData.arguments)?.url ?? "website") + text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("%1 %2").arg(content.prettyTool(segment.modelData)).arg(content.toolTarget(segment.modelData)) } } } diff --git a/Modules/Notifications/Sidebar/Chat/SidebarView.qml b/Modules/Notifications/Sidebar/Chat/SidebarView.qml index 210d780..499b669 100644 --- a/Modules/Notifications/Sidebar/Chat/SidebarView.qml +++ b/Modules/Notifications/Sidebar/Chat/SidebarView.qml @@ -36,6 +36,7 @@ Item { } BlobInvertedRect { + id: invertedRect anchors.fill: parent borderBottom: Tokens.padding.small borderLeft: sidebar.implicitWidth + sidebar.anchors.margins + Tokens.spacing.medium @@ -53,9 +54,10 @@ Item { anchors.left: parent.left anchors.bottom: parent.bottom anchors.margins: Tokens.padding.medium - implicitWidth: root.isWide ? Config.sidebar.sizes.width : root.iconSize + implicitWidth: root.isWide || ChatState.narrowSidebarExpanded ? Config.sidebar.sizes.width : root.iconSize highlight: true - slim: !root.isWide + slim: !root.isWide && !ChatState.narrowSidebarExpanded + z: 1 Behavior on implicitWidth { Anim { @@ -84,6 +86,36 @@ Item { ChatState.currentIdx = 0; ChatState.chatSession = data; } + + onRequestExpand: { + ChatState.narrowSidebarExpanded = true; + } + } + + CustomClippingRect { + anchors.fill: parent + anchors.leftMargin: invertedRect.borderLeft + anchors.topMargin: invertedRect.borderTop + anchors.bottomMargin: invertedRect.borderBottom + anchors.rightMargin: invertedRect.borderRight + radius: invertedRect.radius + + children: [contentArea, dim] + } + + CustomRect { + id: dim + anchors.fill: parent + + color: Colors.palette.m3shadow + opacity: ChatState.narrowSidebarExpanded && !root.isWide ? 0.3 : 0 + z: 1 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } } Item { @@ -91,12 +123,12 @@ Item { property int leftMargin: sidebar.implicitWidth - z: 1 + z: 0 anchors.top: parent.top anchors.bottom: parent.bottom - anchors.margins: Tokens.padding.medium * 2 + anchors.margins: Tokens.padding.medium anchors.right: parent.right - implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 - anchors.margins + implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 - anchors.margins * 2 states: State { name: "wide" @@ -152,6 +184,27 @@ Item { color: Colors.tPalette.m3onSurfaceVariant } } + + MouseArea { + id: dimArea + anchors.fill: parent + preventStealing: true + enabled: !root.isWide && ChatState.narrowSidebarExpanded + cursorShape: undefined + + onClicked: { + const x = mouseX; + const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2; + + if (x > invertedRect.borderLeft - margin) + ChatState.narrowSidebarExpanded = false; + } + + HoverHandler { + cursorShape: dimArea.enabled ? Qt.ArrowCursor : undefined + enabled: dimArea.enabled + } + } } Binding { @@ -160,6 +213,24 @@ Item { value: root.showList } + Binding { + target: ChatState + property: "isWide" + value: root.isWide + } + + Binding { + target: ChatState + value: contentArea.leftMargin === root.iconSize + property: "settledSlim" + } + + Binding { + target: ChatState + value: contentArea.leftMargin === sidebar.implicitWidth + property: "settledWide" + } + Component { id: conversationView diff --git a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml index c113f97..cea59cc 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml @@ -110,7 +110,10 @@ ColumnLayout { onClicked: { root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); + else + Quickshell.execDetached([...Config.general.apps.playback, recording.modelData.path]); } } @@ -120,7 +123,10 @@ ColumnLayout { onClicked: { root.visibilities.sidebar = false; - Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); + if (Config.launcher.uwsm) + Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); + else + Quickshell.execDetached([...Config.general.apps.explorer, recording.modelData.path]); } } } @@ -135,8 +141,7 @@ ColumnLayout { } } Behavior on implicitHeight { - Anim { - } + Anim {} } model: FileSystemModel { nameFilters: ["recording_*.mp4"] @@ -162,8 +167,7 @@ ColumnLayout { opacity: list.count === 0 ? 1 : 0 Behavior on opacity { - Anim { - } + Anim {} } sourceComponent: ColumnLayout { spacing: Tokens.spacing.small @@ -178,16 +182,13 @@ ColumnLayout { text: "scan_delete" Behavior on Layout.preferredHeight { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } } @@ -203,16 +204,13 @@ ColumnLayout { text: "scan_delete" Behavior on Layout.preferredWidth { - Anim { - } + Anim {} } Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } } diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt index bca7ec0..97697aa 100644 --- a/Plugins/ZShell/Llm/CMakeLists.txt +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -288,6 +288,7 @@ qml_module(ZShell-llm chat.hpp chat.cpp chatstore.hpp chatstore.cpp codehighlighter.hpp codehighlighter.cpp + filetool.hpp filetool.cpp generation.hpp generation.cpp llmclient.hpp llmclient.cpp markdownblock.hpp diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index b7adb9b..85e8010 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -1,6 +1,7 @@ #include "chat.hpp" #include "config.hpp" +#include "filetool.hpp" #include "llm.hpp" #include "llmclient.hpp" #include "webfetchtool.hpp" @@ -17,6 +18,7 @@ Chat::Chat(QObject* parent) m_store->setLlmClient(m_client); m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); + m_client->tools()->registerTool(new FileReadTool(m_client->tools())); const auto* llm = config::Config::instance()->llm(); m_client->setEndpoint(llm->endpoint()); @@ -137,6 +139,7 @@ QString Chat::streamingChatId() const { return m_client->streamingChatId(); } + Chat* Chat::s_instance = nullptr; Chat* Chat::create(QQmlEngine*, QJSEngine*) { diff --git a/Plugins/ZShell/Llm/filetool.cpp b/Plugins/ZShell/Llm/filetool.cpp new file mode 100644 index 0000000..a8e9b81 --- /dev/null +++ b/Plugins/ZShell/Llm/filetool.cpp @@ -0,0 +1,151 @@ +#include "filetool.hpp" + +#include +#include +#include + +namespace ZShell::llm { + +namespace { + +QJsonObject makeOutput(const QString& text) { + QJsonObject obj; + obj[QStringLiteral("output")] = text; + return obj; +} + +QJsonObject makeError(const QString& message) { + QJsonObject obj; + obj[QStringLiteral("error")] = message; + return obj; +} + +} // namespace + +FileReadTool::FileReadTool(QObject* parent) : LlmTool(parent) {} + +QString FileReadTool::name() const { + return QStringLiteral("readfile"); +} + +QString FileReadTool::description() const { + return QStringLiteral( + "Read a text file from the local filesystem and return part of " + "its content. At most a limited number of characters is returned " + "per call; use offset and limit to page through larger files. " + "Binary files are not supported. Full output of webfetch calls " + "is stored under /tmp/zshell-llm/webfetch/ and can be read this " + "way. This tool is read-only."); +} + +QJsonObject FileReadTool::parameters() const { + QJsonObject path; + path[QStringLiteral("type")] = QStringLiteral("string"); + path[QStringLiteral("description")] = + QStringLiteral("Path of the file to read"); + + QJsonObject offset; + offset[QStringLiteral("type")] = QStringLiteral("integer"); + offset[QStringLiteral("minimum")] = 0; + offset[QStringLiteral("description")] = + QStringLiteral("Byte offset to start reading from. Defaults to 0."); + + QJsonObject limit; + limit[QStringLiteral("type")] = QStringLiteral("integer"); + limit[QStringLiteral("minimum")] = 1; + limit[QStringLiteral("description")] = + QStringLiteral("Maximum number of characters to return. Defaults " + "to a value that fits the model's context window."); + + QJsonObject properties; + properties[QStringLiteral("path")] = path; + properties[QStringLiteral("offset")] = offset; + properties[QStringLiteral("limit")] = limit; + + QJsonObject schema; + schema[QStringLiteral("type")] = QStringLiteral("object"); + schema[QStringLiteral("properties")] = properties; + QJsonArray required; + required.append(QStringLiteral("path")); + schema[QStringLiteral("required")] = required; + return schema; +} + +void FileReadTool::execute( + const QString& toolCallId, + const QJsonObject& args, + std::function done) { + Q_UNUSED(toolCallId); + const QString path = args[QStringLiteral("path")].toString().trimmed(); + if (path.isEmpty()) { + done(makeError(QStringLiteral("Missing required argument 'path'"))); + return; + } + const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0)); + const int budget = inlineBudgetChars(); + const int limit = + qBound(1, args[QStringLiteral("limit")].toInt(budget), budget); + + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + const QFileInfo info(path); + if (info.exists() && info.isDir()) { + done(makeError(QStringLiteral("Path is a directory, not a " + "file: %1") + .arg(path))); + } else { + done(makeError(QStringLiteral("Cannot open file: %1").arg(path))); + } + return; + } + const qint64 fileSize = file.size(); + if (fileSize == 0) { + file.close(); + done(makeOutput(QString())); + return; + } + if (offset >= fileSize) { + file.close(); + done(makeError(QStringLiteral("Offset %1 is beyond the end of the " + "file (%2 bytes)") + .arg(offset) + .arg(fileSize))); + return; + } + if (!file.seek(offset)) { + file.close(); + done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path))); + return; + } + // UTF-8 uses at most 4 bytes per character, so this many bytes always + // cover `limit` characters. + const qint64 wanted = qint64(limit) * 4 + 16; + const QByteArray chunk = file.read(qMin(wanted, fileSize - offset)); + file.close(); + + if (chunk.contains('\0')) { + done(makeError(QStringLiteral("Binary files are not supported: %1") + .arg(path))); + return; + } + const QString content = QString::fromUtf8(chunk); + if (content.size() <= limit) { + done(makeOutput(content)); + return; + } + const QString head = content.left(limit); + const int nextOffset = offset + head.toUtf8().size(); + const QString output = + head + + QStringLiteral( + "\n\n[... truncated: showing %1 characters (bytes %2-%3 of a " + "%4 byte file). Continue with offset=%5.]") + .arg(limit) + .arg(offset) + .arg(nextOffset) + .arg(fileSize) + .arg(nextOffset); + done(makeOutput(output)); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/filetool.hpp b/Plugins/ZShell/Llm/filetool.hpp new file mode 100644 index 0000000..30b6480 --- /dev/null +++ b/Plugins/ZShell/Llm/filetool.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "tool.hpp" + +namespace ZShell::llm { + +// Lets the model read text files from the local filesystem, in +// byte-offset windows small enough to fit the context. Primarily used to +// page through the full webfetch output stored under +// /tmp/zshell-llm/webfetch/. +class FileReadTool : public LlmTool { + Q_OBJECT + + public: + explicit FileReadTool(QObject* parent = nullptr); + + QString name() const override; + QString description() const override; + QJsonObject parameters() const override; + void execute( + const QString& toolCallId, + const QJsonObject& args, + std::function done) override; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/generation.cpp b/Plugins/ZShell/Llm/generation.cpp index 129de52..7082930 100644 --- a/Plugins/ZShell/Llm/generation.cpp +++ b/Plugins/ZShell/Llm/generation.cpp @@ -1,5 +1,9 @@ #include "generation.hpp" +#include "llmclient.hpp" +#include "messagemodel.hpp" +#include "session.hpp" + #include namespace ZShell::llm { @@ -46,7 +50,7 @@ QString ChatGeneration::reasoning() const { bool ChatGeneration::reasoningActive() const { if (!m_streaming) return false; if (!content().isEmpty()) return false; - return !hasRunningTool(); + return !hasRunningTool() && !hasPendingTool(); } qint64 ChatGeneration::reasoningElapsedMs() const { @@ -87,6 +91,45 @@ bool ChatGeneration::hasRunningTool() const { return false; } +bool ChatGeneration::hasPendingTool() const { + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::ToolCall && + segment->status() == LlmSegment::Status::Pending) + return true; + return false; +} + +void ChatGeneration::setApprovalPending(bool value) { + if (m_approvalPending == value) return; + m_approvalPending = value; + Q_EMIT toolApprovalPendingChanged(); +} + +QVariantList ChatGeneration::pendingToolCalls() const { + QVariantList out; + if (!m_approvalPending) return out; + for (const auto* segment : m_segments) + if (segment->type() == LlmSegment::Type::ToolCall && + segment->status() == LlmSegment::Status::Pending) + out.append(QVariant::fromValue(segment)); + return out; +} + +void ChatGeneration::approveTools() { + if (auto* llmClient = client()) llmClient->approveTools(); +} + +void ChatGeneration::denyTools() { + if (auto* llmClient = client()) llmClient->denyTools(); +} + +LlmClient* ChatGeneration::client() const { + if (auto* message = qobject_cast(parent())) + if (auto* model = qobject_cast(message->parent())) + if (auto* session = model->session()) return session->client(); + return nullptr; +} + void ChatGeneration::updateReasoningActive() { const bool active = reasoningActive(); if (m_reasoningActive == active) return; @@ -177,7 +220,9 @@ LlmSegment* ChatGeneration::beginToolCall( LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this); segment->setName(name); segment->setToolCallId(toolCallId); - segment->setStatus(LlmSegment::Status::Running); + // Awaiting user approval until LlmClient::approveTools() promotes it + // to Running; denyTools()/endStream() finalize it as an error. + segment->setStatus(LlmSegment::Status::Pending); segment->begin(); addSegment(segment); Q_EMIT toolStateChanged(); diff --git a/Plugins/ZShell/Llm/generation.hpp b/Plugins/ZShell/Llm/generation.hpp index 20b4709..e10c595 100644 --- a/Plugins/ZShell/Llm/generation.hpp +++ b/Plugins/ZShell/Llm/generation.hpp @@ -6,10 +6,13 @@ #include #include #include +#include #include namespace ZShell::llm { +class LlmClient; + class ChatGeneration : public QObject { Q_OBJECT QML_ELEMENT @@ -33,6 +36,12 @@ class ChatGeneration : public QObject { QList segments READ segments NOTIFY segmentsChanged) Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged) + Q_PROPERTY( + bool toolApprovalPending READ toolApprovalPending NOTIFY + toolApprovalPendingChanged) + Q_PROPERTY( + QVariantList pendingToolCalls READ pendingToolCalls NOTIFY + toolApprovalPendingChanged) public: explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); @@ -48,6 +57,14 @@ class ChatGeneration : public QObject { [[nodiscard]] QList segments() const { return m_segments; } [[nodiscard]] int toolCallCount() const; [[nodiscard]] bool hasRunningTool() const; + [[nodiscard]] bool hasPendingTool() const; + + [[nodiscard]] bool toolApprovalPending() const { return m_approvalPending; } + void setApprovalPending(bool value); + [[nodiscard]] QVariantList pendingToolCalls() const; + + Q_INVOKABLE void approveTools(); + Q_INVOKABLE void denyTools(); void setContent(const QString& value); void appendContent(const QString& piece); @@ -69,14 +86,17 @@ class ChatGeneration : public QObject { void streamingChanged(); void toolStateChanged(); void segmentsChanged(); + void toolApprovalPendingChanged(); private: void updateReasoningActive(); + LlmClient* client() const; QTimer m_timer; QList m_segments; bool m_reasoningActive = false; bool m_streaming = false; + bool m_approvalPending = false; qint64 m_timestamp; }; diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp index b29f92b..c85635e 100644 --- a/Plugins/ZShell/Llm/llmclient.cpp +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -17,6 +17,81 @@ namespace ZShell::llm { +namespace { + +// Minimum prompt budget kept even when the transcript alone nearly fills +// the window, so the model always sees the triggering message. +constexpr int kMinContextTokens = 512; + +qsizetype jsonValueChars(const QJsonValue& value) { + switch (value.type()) { + case QJsonValue::String: + return value.toString().size(); + case QJsonValue::Array: { + qsizetype total = 0; + for (const QJsonValue& item : value.toArray()) + total += jsonValueChars(item); + return total; + } + case QJsonValue::Object: { + qsizetype total = 0; + const QJsonObject obj = value.toObject(); + for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) + total += jsonValueChars(it.value()); + return total; + } + default: + return 2; + } +} + +int estimateMessageTokens(const QJsonObject& message) { + return int(jsonValueChars(message) / LlmTool::CharsPerToken) + 4; +} + +// One message model row and the JSON messages it produces. Trimming works +// in whole units so an assistant turn and its tool results always travel +// together. +struct ContextUnit { + QList messages; + int tokens = 0; +}; + +// Shrink the free-text fields of a unit until it fits the budget. Used +// when a single unit (e.g. one huge user message) alone exceeds it. +void shrinkUnitToBudget(QList& unit, int budgetTokens) { + static const QStringList kTextKeys = { + QStringLiteral("content"), QStringLiteral("reasoning_content")}; + for (int pass = 0; pass < 8; ++pass) { + int tokens = 0; + for (const QJsonObject& message : unit) + tokens += estimateMessageTokens(message); + if (tokens <= budgetTokens) return; + qsizetype bestMessage = -1; + QString bestKey; + qsizetype bestLength = 0; + for (qsizetype i = 0; i < unit.size(); ++i) { + const QJsonObject message = unit.at(i); + for (const QString& key : kTextKeys) { + const QJsonValue value = message.value(key); + if (value.isString() && value.toString().size() > bestLength) { + bestMessage = i; + bestKey = key; + bestLength = value.toString().size(); + } + } + } + if (bestMessage < 0) return; + QString text = unit.at(bestMessage).value(bestKey).toString(); + text.truncate(qMax(32, text.size() * 3 / 4)); + const QString marker = QStringLiteral("\n[…]"); + if (!text.endsWith(marker)) text += marker; + unit[bestMessage][bestKey] = text; + } +} + +} // namespace + QString LlmClient::completionsPath( const QString& endpoint, const QString& subpath) { QString base = endpoint.trimmed(); @@ -97,7 +172,7 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { if (m_busy || !session || !target) return; m_active = session; m_streaming = target; - m_streaming->setStreaming(true); + target->setStreaming(true); setBusy(true); setStreamingChatId(session->id()); @@ -125,6 +200,7 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { m_round = 0; m_contentMark = 0; m_reasoningMark = 0; + setApprovalPending(false); sendRound(); } @@ -146,9 +222,11 @@ void LlmClient::sendRound() { return; } - const auto* model = m_active->messagesModel(); + ChatSession* active = m_active; + ChatGeneration* streaming = m_streaming; + const auto* model = active->messagesModel(); const int targetRow = - model->rowOf(qobject_cast(m_streaming->parent())); + model->rowOf(qobject_cast(streaming->parent())); if (targetRow < 0) { fail(QStringLiteral( "Internal error: generation target is not in the " @@ -156,7 +234,13 @@ void LlmClient::sendRound() { return; } - QJsonArray messages = buildContextMessages(m_active, targetRow); + // The transcript (this generation so far) is always sent in full, so + // reserve its estimated size out of the context budget. + int transcriptTokens = 0; + for (const QJsonValue& value : m_transcript) + transcriptTokens += estimateMessageTokens(value.toObject()); + QJsonArray messages = + buildContextMessages(m_active, targetRow, transcriptTokens); for (const QJsonValue& value : m_transcript) messages.append(value); @@ -205,67 +289,103 @@ void LlmClient::sendRound() { } QJsonArray LlmClient::buildContextMessages( - ChatSession* session, int stopBeforeRow) const { + ChatSession* session, int stopBeforeRow, int extraReserveTokens) const { const auto* model = session->messagesModel(); - QJsonArray messages; + QList units; for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { const auto* message = model->at(row); const auto* generation = message->activeGeneration(); if (!generation) continue; + ContextUnit unit; if (message->role() == ChatMessage::Role::User) { QJsonObject user; user[QStringLiteral("role")] = QStringLiteral("user"); user[QStringLiteral("content")] = generation->content(); - messages.append(user); - continue; - } + unit.messages.append(user); + } else { + QList toolSegments; + for (const auto* segment : generation->segments()) + if (segment->type() == LlmSegment::Type::ToolCall) + toolSegments.append(segment); - QList toolSegments; - for (const auto* segment : generation->segments()) - if (segment->type() == LlmSegment::Type::ToolCall) - toolSegments.append(segment); - - QJsonObject assistant; - assistant[QStringLiteral("role")] = QStringLiteral("assistant"); - if (toolSegments.isEmpty()) - assistant[QStringLiteral("content")] = generation->content(); - else if (!generation->content().isEmpty()) - assistant[QStringLiteral("content")] = generation->content(); - if (!generation->reasoning().isEmpty()) - assistant[QStringLiteral("reasoning_content")] = - generation->reasoning(); - - if (toolSegments.isEmpty()) { - messages.append(assistant); - continue; - } - QJsonArray calls; - for (const auto* segment : toolSegments) { - QJsonObject function; - function[QStringLiteral("name")] = segment->name(); - function[QStringLiteral("arguments")] = segment->arguments(); - QJsonObject call; - call[QStringLiteral("id")] = segment->toolCallId(); - call[QStringLiteral("type")] = QStringLiteral("function"); - call[QStringLiteral("function")] = function; - calls.append(call); - } - assistant[QStringLiteral("tool_calls")] = calls; - messages.append(assistant); - for (const auto* segment : toolSegments) { - QJsonObject toolMessage; - toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); - toolMessage[QStringLiteral("tool_call_id")] = segment->toolCallId(); - toolMessage[QStringLiteral("content")] = segment->result(); - messages.append(toolMessage); + QJsonObject assistant; + assistant[QStringLiteral("role")] = QStringLiteral("assistant"); + if (toolSegments.isEmpty()) + assistant[QStringLiteral("content")] = generation->content(); + else if (!generation->content().isEmpty()) + assistant[QStringLiteral("content")] = generation->content(); + if (!generation->reasoning().isEmpty()) + assistant[QStringLiteral("reasoning_content")] = + generation->reasoning(); + if (!toolSegments.isEmpty()) { + QJsonArray calls; + for (const auto* segment : toolSegments) { + QJsonObject function; + function[QStringLiteral("name")] = segment->name(); + function[QStringLiteral("arguments")] = + segment->arguments(); + QJsonObject call; + call[QStringLiteral("id")] = segment->toolCallId(); + call[QStringLiteral("type")] = QStringLiteral("function"); + call[QStringLiteral("function")] = function; + calls.append(call); + } + assistant[QStringLiteral("tool_calls")] = calls; + } + unit.messages.append(assistant); + for (const auto* segment : toolSegments) { + QJsonObject toolMessage; + toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); + toolMessage[QStringLiteral("tool_call_id")] = + segment->toolCallId(); + toolMessage[QStringLiteral("content")] = segment->result(); + unit.messages.append(toolMessage); + } } + for (const QJsonObject& messageJson : unit.messages) + unit.tokens += estimateMessageTokens(messageJson); + units.append(unit); } + + if (m_contextSize > 0) { + // Keep headroom for the model's own reply so the prompt cannot + // fill the whole window. + const int completionReserve = qBound(1024, m_contextSize / 8, 8192); + int budget = m_contextSize - completionReserve - extraReserveTokens; + budget = qMax(budget, kMinContextTokens); + + int total = 0; + for (const ContextUnit& unit : units) + total += unit.tokens; + // Drop oldest units until the prompt fits the budget. + while (units.size() > 1 && total > budget) { + total -= units.first().tokens; + units.removeFirst(); + } + // A conversation cannot start with an assistant turn. + while ( + units.size() > 1 && + units.first().messages.first()[QStringLiteral("role")].toString() == + QLatin1String("assistant")) { + total -= units.first().tokens; + units.removeFirst(); + } + // A single oversized unit still gets sent, shrunk to fit. + if (units.size() == 1 && units.first().tokens > budget) + shrinkUnitToBudget(units.first().messages, budget); + } + + QJsonArray messages; + for (const ContextUnit& unit : units) + for (const QJsonObject& messageJson : unit.messages) + messages.append(messageJson); return messages; } void LlmClient::applyToolCallDelta(const QJsonObject& call) { - if (!m_streaming) return; + ChatGeneration* streaming = m_streaming; + if (!streaming) return; const int index = call[QStringLiteral("index")].toInt(-1); if (index < 0) return; while (m_callBuilders.size() <= index) @@ -281,8 +401,8 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) { if (!arguments.isEmpty()) builder.arguments += arguments; if (!builder.segment) { - m_streaming->closeOpenSegments(); - builder.segment = m_streaming->beginToolCall(builder.name, builder.id); + streaming->closeOpenSegments(); + builder.segment = streaming->beginToolCall(builder.name, builder.id); } builder.segment->setName(builder.name); builder.segment->setToolCallId(builder.id); @@ -291,6 +411,7 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) { void LlmClient::roundFinished() { if (m_roundDone || !m_streaming) return; + ChatGeneration* streaming = m_streaming; m_roundDone = true; bool hasCalls = false; @@ -306,10 +427,10 @@ void LlmClient::roundFinished() { return; } - m_streaming->closeOpenSegments(); + streaming->closeOpenSegments(); - const QString content = m_streaming->content(); - const QString reasoning = m_streaming->reasoning(); + const QString content = streaming->content(); + const QString reasoning = streaming->reasoning(); QJsonObject assistant; assistant[QStringLiteral("role")] = QStringLiteral("assistant"); if (content.size() > m_contentMark) { @@ -337,7 +458,9 @@ void LlmClient::roundFinished() { m_transcript.append(assistant); m_round++; - executeAllCalls(); + // The whole batch of this round waits for a single user decision; + // approveTools() runs all of it, denyTools() ends the turn. + setApprovalPending(true); } void LlmClient::executeAllCalls() { @@ -375,7 +498,7 @@ void LlmClient::executeAllCalls() { } ++m_pendingCalls; - tool->execute(args, [this, i, call](const QJsonObject& result) { + tool->execute(call.id, args, [this, i, call](const QJsonObject& result) { if (!m_streaming) return; const bool success = result.contains(QStringLiteral("output")); const QString content = @@ -407,12 +530,60 @@ void LlmClient::flushCallResults() { sendRound(); } +void LlmClient::setApprovalPending(bool value) { + if (m_approvalPending == value) return; + m_approvalPending = value; + // Mirror onto the streaming generation; QML reads and answers from + // there, so only the affected ProcessBlock reacts. + if (ChatGeneration* streaming = m_streaming) + streaming->setApprovalPending(value); +} + +void LlmClient::approveTools() { + if (!m_approvalPending) return; + ChatGeneration* streaming = m_streaming; + if (!streaming) return; + setApprovalPending(false); + for (auto* segment : streaming->segments()) { + if (segment->type() != LlmSegment::Type::ToolCall || + segment->status() != LlmSegment::Status::Pending) + continue; + // Restart the segment timer so elapsed time covers execution, not + // the wait for approval. + segment->setStatus(LlmSegment::Status::Running); + segment->begin(); + } + executeAllCalls(); +} + +void LlmClient::denyTools() { + if (!m_approvalPending || !m_streaming) return; + setApprovalPending(false); + finishPendingCalls(QStringLiteral("Denied by user")); + finishTurn(); +} + +void LlmClient::finishPendingCalls(const QString& resultText) { + ChatGeneration* streaming = m_streaming; + if (!streaming) return; + for (auto* segment : streaming->segments()) + if (segment->type() == LlmSegment::Type::ToolCall && + segment->status() == LlmSegment::Status::Pending) + segment->finishTool(resultText, false); +} + void LlmClient::stop() { if (!m_busy) return; + if (m_approvalPending) { + setApprovalPending(false); + finishPendingCalls(QStringLiteral("Cancelled")); + finishTurn(); + return; + } if (m_toolPhase) { m_tools->cancelAll(); - if (m_streaming) { - for (auto* segment : m_streaming->segments()) { + if (ChatGeneration* streaming = m_streaming) { + for (auto* segment : streaming->segments()) { if (segment->type() == LlmSegment::Type::ToolCall && segment->running()) segment->finishTool(QStringLiteral("Cancelled"), false); @@ -426,11 +597,19 @@ void LlmClient::stop() { void LlmClient::endStream() { if (!m_streaming) return; - auto* generation = m_streaming; - auto* session = m_active; + ChatGeneration* generation = m_streaming; + ChatSession* session = m_active; m_streaming = nullptr; m_active = nullptr; generation->setStreaming(false); + m_approvalPending = false; + generation->setApprovalPending(false); + // Finalize tool calls that never received an approval (turn cancelled, + // round limit reached, ...). + for (auto* segment : generation->segments()) + if (segment->type() == LlmSegment::Type::ToolCall && + segment->status() == LlmSegment::Status::Pending) + segment->finishTool(QStringLiteral("Cancelled"), false); if (generation->content().isEmpty() && generation->reasoning().isEmpty() && generation->toolCallCount() == 0) { if (auto* message = qobject_cast(generation->parent())) { @@ -570,6 +749,7 @@ void LlmClient::refreshModels() { void LlmClient::setContextSize(int size) { if (size <= 0 || m_contextSize == size) return; m_contextSize = size; + m_tools->setContextSize(size); Q_EMIT contextSizeChanged(); } @@ -611,12 +791,13 @@ void LlmClient::probeContextSize() { } void LlmClient::updateTokenUsage(const QJsonObject& data) { - if (!m_active || m_contextSize <= 0) return; + ChatSession* active = m_active; + if (!active || m_contextSize <= 0) return; const QJsonObject usage = data["usage"].toObject(); if (usage.isEmpty()) return; const double used = usage.value("prompt_tokens").toDouble() + usage.value("completion_tokens").toDouble(); - if (used > 0) m_active->setLastTokenCount(static_cast(used)); + if (used > 0) active->setLastTokenCount(static_cast(used)); } void LlmClient::shortRequest( diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp index 56fdfc4..f0346ff 100644 --- a/Plugins/ZShell/Llm/llmclient.hpp +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -56,6 +56,13 @@ class LlmClient : public QObject { void clearOnFinish(ChatSession* session); void sessionRemoved(ChatSession* session); + // Tool calls are executed only after the user approves the batch the + // model requested. The per-generation state (toolApprovalPending, + // pendingToolCalls) is mirrored onto ChatGeneration; these are the + // entry points QML reaches through it. + void approveTools(); + void denyTools(); + void refreshModels(); void requestTitle(ChatSession* session, const QString& userText); void requestIcon(ChatSession* session, const QString& userText); @@ -83,11 +90,13 @@ class LlmClient : public QObject { void sendRound(); QJsonArray buildContextMessages( - ChatSession* session, int stopBeforeRow) const; + ChatSession* session, int stopBeforeRow, int extraReserveTokens) const; void applyToolCallDelta(const QJsonObject& call); void roundFinished(); void executeAllCalls(); void flushCallResults(); + void setApprovalPending(bool value); + void finishPendingCalls(const QString& resultText); void finishTurn(); void fail(const QString& message); void handleLine(const QByteArray& line); @@ -109,8 +118,11 @@ class LlmClient : public QObject { ToolRegistry* m_tools = nullptr; QNetworkReply* m_reply = nullptr; QByteArray m_buffer; - ChatSession* m_active = nullptr; - ChatGeneration* m_streaming = nullptr; + // QPointer: these objects are owned by the ChatStore tree, which Qt + // destroys *before* this client on shutdown (children die in creation + // order). The pointers must self-null instead of dangling. + QPointer m_active; + QPointer m_streaming; QPointer m_pendingClear; bool m_busy = false; QString m_streamingChatId; @@ -133,6 +145,7 @@ class LlmClient : public QObject { qsizetype m_reasoningMark = 0; bool m_roundDone = false; bool m_toolPhase = false; + bool m_approvalPending = false; int m_pendingCalls = 0; static constexpr int kMaxToolRounds = 12; }; diff --git a/Plugins/ZShell/Llm/segment.hpp b/Plugins/ZShell/Llm/segment.hpp index ce125d1..589ec12 100644 --- a/Plugins/ZShell/Llm/segment.hpp +++ b/Plugins/ZShell/Llm/segment.hpp @@ -29,7 +29,9 @@ class LlmSegment : public QObject { enum class Type : int { Reasoning = 0, ToolCall, Content }; Q_ENUM(Type) - enum class Status : int { None = 0, Running, Success, Error }; + // Pending is appended last: statuses are persisted as integers in the + // chat database, so existing values must not move. + enum class Status : int { None = 0, Running, Success, Error, Pending }; Q_ENUM(Status) explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); diff --git a/Plugins/ZShell/Llm/tool.cpp b/Plugins/ZShell/Llm/tool.cpp index 05ddb35..ab8b052 100644 --- a/Plugins/ZShell/Llm/tool.cpp +++ b/Plugins/ZShell/Llm/tool.cpp @@ -6,6 +6,20 @@ LlmTool::LlmTool(QObject* parent) : QObject(parent) {} LlmTool::~LlmTool() = default; +void LlmTool::setContextSize(int value) { + if (m_contextSize == value) return; + m_contextSize = value; +} + +int LlmTool::inlineBudgetChars() const { + if (m_contextSize <= 0) return DefaultInlineChars; + // About a quarter of the context window, clamped so tiny contexts + // still get a usable result and huge ones stay sane. + const int tokens = + qBound(512, m_contextSize / 4, DefaultInlineChars / CharsPerToken); + return tokens * CharsPerToken; +} + void LlmTool::cancel() {} QJsonObject LlmTool::specification() const { @@ -30,6 +44,7 @@ void ToolRegistry::setEnabled(bool value) { void ToolRegistry::registerTool(LlmTool* tool) { if (!tool || m_tools.contains(tool)) return; tool->setParent(this); + tool->setContextSize(m_contextSize); m_tools.append(tool); } @@ -52,4 +67,11 @@ void ToolRegistry::cancelAll() { tool->cancel(); } +void ToolRegistry::setContextSize(int value) { + if (m_contextSize == value) return; + m_contextSize = value; + for (auto* tool : m_tools) + tool->setContextSize(value); +} + } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/tool.hpp b/Plugins/ZShell/Llm/tool.hpp index f8e12df..8ca95a1 100644 --- a/Plugins/ZShell/Llm/tool.hpp +++ b/Plugins/ZShell/Llm/tool.hpp @@ -21,12 +21,33 @@ class LlmTool : public QObject { [[nodiscard]] virtual QString description() const = 0; [[nodiscard]] virtual QJsonObject parameters() const = 0; + // toolCallId identifies the call within the conversation; tools may use + // it to name files they write for later retrieval. virtual void execute( + const QString& toolCallId, const QJsonObject& args, std::function done) = 0; virtual void cancel(); [[nodiscard]] QJsonObject specification() const; + + // The endpoint's context size in tokens (0 = unknown). Set by LlmClient. + [[nodiscard]] int contextSize() const { return m_contextSize; } + void setContextSize(int value); + + // Rough cap, in characters, for tool output embedded in a tool result + // message. Scales with the context size so a single result cannot + // consume the whole window. + [[nodiscard]] int inlineBudgetChars() const; + + // ~4 characters per token; a deliberately coarse estimate used for + // budgeting (never for exact accounting). + static constexpr int CharsPerToken = 4; + // Inline budget when the context size is unknown. + static constexpr int DefaultInlineChars = 64 * 1024; + + protected: + int m_contextSize = 0; }; class ToolRegistry : public QObject { @@ -45,11 +66,15 @@ class ToolRegistry : public QObject { [[nodiscard]] QJsonArray specifications() const; void cancelAll(); + // Propagated to every registered tool, including later ones. + void setContextSize(int value); + Q_SIGNALS: void enabledChanged(); private: bool m_enabled = true; + int m_contextSize = 0; QList m_tools; }; diff --git a/Plugins/ZShell/Llm/webfetchtool.cpp b/Plugins/ZShell/Llm/webfetchtool.cpp index ff86b8f..2fb799e 100644 --- a/Plugins/ZShell/Llm/webfetchtool.cpp +++ b/Plugins/ZShell/Llm/webfetchtool.cpp @@ -1,5 +1,7 @@ #include "webfetchtool.hpp" +#include +#include #include #include #include @@ -8,6 +10,9 @@ namespace ZShell::llm { +const QString WebFetchTool::StoragePath = + QStringLiteral("/tmp/zshell-llm/webfetch"); + namespace { const char* kUserAgent = @@ -46,7 +51,10 @@ QString WebFetchTool::description() const { return QStringLiteral( "Fetch content from an HTTP or HTTPS URL and return it as plain " "text or raw HTML. HTML pages are reduced to their visible text " - "by default. This tool is read-only."); + "by default. Only a limited amount of content is returned inline; " + "the full output of every call is saved under " + "/tmp/zshell-llm/webfetch/, where it can be paged through with " + "the readfile tool. This tool is read-only."); } QJsonObject WebFetchTool::parameters() const { @@ -102,9 +110,12 @@ void WebFetchTool::completeJob(Job* job, QJsonObject result) { } void WebFetchTool::execute( - const QJsonObject& args, std::function done) { + const QString& toolCallId, + const QJsonObject& args, + std::function done) { auto* job = new Job; job->done = std::move(done); + job->toolCallId = toolCallId; m_jobs.append(job); auto fail = [this, job](const QString& message) { @@ -233,13 +244,64 @@ void WebFetchTool::execute( if (mime.contains(QLatin1String("text/html")) && job->format == QLatin1String("text")) content = extractTextFromHtml(content); - if (content.size() > MaxOutputChars) - content = content.left(MaxOutputChars) + - QStringLiteral("\n[... truncated ...]"); - completeJob(job, makeOutput(content)); + + const QString savedPath = saveToFile(*job, content, mime); + + QString output = content; + const int budget = inlineBudgetChars(); + if (content.size() > budget) { + output = content.left(budget); + const QString note = QStringLiteral( + "\n\n[... truncated: showing %1 of %2 characters") + .arg(budget) + .arg(content.size()); + if (!savedPath.isEmpty()) + output += note + + QStringLiteral( + ". The full content is saved to %1; use " + "the readfile tool to read the rest.") + .arg(savedPath); + else + output += note + QStringLiteral( + ". The remaining content is not " + "available."); + } + completeJob(job, makeOutput(output)); }); } +QString WebFetchTool::saveToFile( + const Job& job, const QString& content, const QString& mime) const { + QDir dir(StoragePath); + if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) + return QString(); + + QString fileName; + fileName.reserve(job.toolCallId.size()); + for (const QChar& c : job.toolCallId) + fileName += (c.isLetterOrNumber() || c == QLatin1Char('-') || + c == QLatin1Char('_')) + ? c + : QLatin1Char('_'); + if (fileName.isEmpty()) fileName = QStringLiteral("fetch"); + + QString extension = QStringLiteral("txt"); + if (job.format == QLatin1String("html")) + extension = QStringLiteral("html"); + else if (mime.contains(QLatin1String("json"))) + extension = QStringLiteral("json"); + else if (mime.contains(QLatin1String("xml"))) + extension = QStringLiteral("xml"); + + const QString path = + dir.filePath(fileName + QLatin1Char('.') + extension); + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return QString(); + file.write(content.toUtf8()); + return path; +} + void WebFetchTool::cancel() { for (auto* job : m_jobs) { job->timer->stop(); diff --git a/Plugins/ZShell/Llm/webfetchtool.hpp b/Plugins/ZShell/Llm/webfetchtool.hpp index b7bfd6b..1c69bb9 100644 --- a/Plugins/ZShell/Llm/webfetchtool.hpp +++ b/Plugins/ZShell/Llm/webfetchtool.hpp @@ -20,7 +20,9 @@ class WebFetchTool : public LlmTool { static constexpr int MaxResponseBytes = 5 * 1024 * 1024; static constexpr int DefaultTimeoutSeconds = 30; static constexpr int MaxTimeoutSeconds = 120; - static constexpr int MaxOutputChars = 64 * 1024; + // Where the full (untruncated) output of each call is stored so the + // model can page through it with the readfile tool. + static const QString StoragePath; explicit WebFetchTool(QObject* parent = nullptr); ~WebFetchTool() override; @@ -29,6 +31,7 @@ class WebFetchTool : public LlmTool { QString description() const override; QJsonObject parameters() const override; void execute( + const QString& toolCallId, const QJsonObject& args, std::function done) override; void cancel() override; @@ -43,10 +46,15 @@ class WebFetchTool : public LlmTool { QByteArray body; bool tooLarge = false; QString format; + QString toolCallId; std::function done; }; void completeJob(Job* job, QJsonObject result); + // Writes the full output to StoragePath; returns the file path, or an + // empty string when saving failed. + QString saveToFile(const Job& job, const QString& content, + const QString& mime) const; QNetworkAccessManager m_manager; QList m_jobs; From a59e4286ad023526fea9ce672ff27757b10b5b6c Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 3 Sep 2026 23:45:07 +0200 Subject: [PATCH 47/90] fix: tool call prompt --- .../Sidebar/Chat/ChatContent.qml | 1 + .../Sidebar/Chat/Content/MessageDelegate.qml | 2 + .../Sidebar/Chat/Content/ProcessBlock.qml | 100 +++++++++++++++++- 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 85e2a6d..2b8f2d7 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -198,6 +198,7 @@ Item { } delegate: MessageDelegate { rotation: 180 + rootParent: list } displaced: Transition { diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index 2ad31af..20d4e32 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -14,6 +14,7 @@ MouseArea { property ChatGeneration current: modelData.activeGeneration required property int index readonly property bool isUser: modelData.role === ChatMessage.Role.User + required property Item rootParent required property ChatMessage modelData property bool reasoningExpanded: false readonly property var blocks: blockify(current.segments) @@ -179,6 +180,7 @@ MouseArea { width: root.width blocks: root.blocks current: root.current + rootParent: root.rootParent onExpandedChanged: root.handleReasoningToggle(expanded) } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml index 9323d64..971b2a9 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -14,12 +14,13 @@ Item { required property var modelData required property var blocks readonly property var segments: modelData.segments + required property Item rootParent required property ChatGeneration current readonly property bool isActive: index === blocks.length - 1 property bool expanded: false readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1] readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0) - readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).every(s => current.pendingToolCalls.includes(s)) + readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).some(s => current.pendingToolCalls.includes(s)) clip: true implicitHeight: { @@ -145,10 +146,21 @@ Item { id: toolsBg property bool open: false + property Item rootParent: root.rootParent + property int openHeight: toolsBg.rootParent.height - Tokens.padding.medium * 2 + property int openWidth: toolsBg.rootParent.width - Tokens.padding.medium * 2 implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2 Layout.fillWidth: true + function reparentWrapper(): void { + const newParent = open ? rootParent : toolsBg; + const pos = toolsWrapper.mapToItem(newParent, 0, 0); + toolsWrapper.parent = newParent; + toolsWrapper.x = pos.x; + toolsWrapper.y = pos.y; + } + BlobGroup { id: blobGroup @@ -159,14 +171,87 @@ Item { } } + MouseArea { + id: backdrop + + anchors.fill: parent + enabled: false + hoverEnabled: enabled + preventStealing: true + parent: toolsBg.open ? toolsBg.rootParent : toolsBg + + onClicked: toolsBg.open = false + } + Item { - id: toolWrapper + id: toolsWrapper width: toolsBg.width height: toolList.implicitHeight + toolList.anchors.margins * 2 + states: State { + name: "open" + when: toolsBg.open + + PropertyChanges { + backdrop.enabled: true + toolsBackground.bottomLeftRadius: Tokens.rounding.largeIncreased + toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased + toolsBackground.topRightRadius: Tokens.rounding.largeIncreased + toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased + // toolsContent.opacity: 1 + toolsWrapper.height: toolsBg.openHeight + toolsWrapper.width: toolsBg.openWidth + toolsWrapper.x: (toolsBg.rootParent.width - toolsBg.openWidth) / 2 + toolsWrapper.y: (toolsBg.rootParent.height - toolsBg.openHeight) / 2 + elevation.opacity: 1 + toolBox.opacity: 0 + } + } + transitions: Transition { + id: dialogTransition + + SequentialAnimation { + ScriptAction { + script: toolsBg.reparentWrapper() + } + + Anim { + properties: "x,y" + } + } + + PropertyAction { + property: "enabled" + } + + Anim { + properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius" + type: Anim.DefaultEffects + } + + Anim { + properties: "width,height" + } + } + + Elevation { + id: elevation + + anchors.fill: parent + bottomLeftRadius: toolsBackground.bottomLeftRadius + bottomRightRadius: toolsBackground.bottomRightRadius + level: 4 + opacity: 0 + radius: toolsBackground.radius + + transform: Matrix4x4 { + matrix: toolsBackground.deformMatrix + } + } + BlobRect { - id: dialogBg + id: toolsBackground anchors.fill: parent bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small @@ -180,12 +265,17 @@ Item { CustomRect { id: toolBox - anchors.fill: parent - anchors.margins: Tokens.padding.medium + radius: Tokens.rounding.small + + StateLayer { + onClicked: toolsBg.open = true + } ColumnLayout { id: toolList + anchors.fill: parent + anchors.margins: Tokens.padding.medium Repeater { model: root.current.pendingToolCalls ?? [] From 5fc6fda8049bf4eea560cda3f66ff23d769e84ac Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 4 Sep 2026 02:20:37 +0200 Subject: [PATCH 48/90] fix: truncate context logic --- .../Sidebar/Chat/Content/ProcessBlock.qml | 19 +- Modules/Notifications/Sidebar/Content.qml | 1 + Plugins/ZShell/Llm/chat.cpp | 5 +- Plugins/ZShell/Llm/chat.hpp | 4 +- Plugins/ZShell/Llm/codehighlighter.hpp | 11 +- Plugins/ZShell/Llm/filetool.cpp | 26 ++- Plugins/ZShell/Llm/filetool.hpp | 5 +- Plugins/ZShell/Llm/generation.cpp | 2 - Plugins/ZShell/Llm/llmclient.cpp | 218 ++++++++++++++---- Plugins/ZShell/Llm/llmclient.hpp | 13 +- Plugins/ZShell/Llm/segment.hpp | 2 - Plugins/ZShell/Llm/tool.cpp | 2 - Plugins/ZShell/Llm/tool.hpp | 11 +- Plugins/ZShell/Llm/webfetchtool.cpp | 35 +-- Plugins/ZShell/Llm/webfetchtool.hpp | 10 +- 15 files changed, 238 insertions(+), 126 deletions(-) diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml index 971b2a9..c8a733c 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -154,11 +154,12 @@ Item { Layout.fillWidth: true function reparentWrapper(): void { - const newParent = open ? rootParent : toolsBg; + const newParent = open ? toolsBg.rootParent : toolsBg; const pos = toolsWrapper.mapToItem(newParent, 0, 0); toolsWrapper.parent = newParent; toolsWrapper.x = pos.x; toolsWrapper.y = pos.y; + console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height); } BlobGroup { @@ -187,7 +188,7 @@ Item { id: toolsWrapper width: toolsBg.width - height: toolList.implicitHeight + toolList.anchors.margins * 2 + height: toolBox.implicitHeight states: State { name: "open" @@ -265,16 +266,24 @@ Item { CustomRect { id: toolBox - anchors.fill: parent + anchors.left: parent.left + anchors.right: parent.right + height: Math.min(implicitHeight, parent.height) + implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2 radius: Tokens.rounding.small StateLayer { - onClicked: toolsBg.open = true + onClicked: { + console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height); + toolsBg.open = true; + } } ColumnLayout { id: toolList - anchors.fill: parent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter anchors.margins: Tokens.padding.medium Repeater { diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 7261555..4e89694 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -77,6 +77,7 @@ Item { anchors.top: parent.top active: Config.llm.enabled width: parent.width + property bool tre: true sourceComponent: Item { id: chatPage diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index 85e8010..a716d21 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -40,7 +40,6 @@ Chat::Chat(QObject* parent) }); connect(m_client, &LlmClient::busyChanged, this, [this]() { - // A fresh run supersedes the previous error. if (m_client->busy() && !m_lastError.isEmpty()) { m_lastError.clear(); Q_EMIT lastErrorChanged(); @@ -161,6 +160,10 @@ void Chat::refreshModels() { m_client->refreshModels(); } +void Chat::refreshFromServer() { + m_client->refreshFromServer(); +} + void Chat::selectModel(const QString& id) { if (id.isEmpty()) return; m_client->setModel(id); diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp index 276b4a3..0d4919b 100644 --- a/Plugins/ZShell/Llm/chat.hpp +++ b/Plugins/ZShell/Llm/chat.hpp @@ -14,9 +14,6 @@ namespace ZShell::llm { class LlmClient; -// QML-facing facade. Persistence lives in ChatStore, network streaming in -// LlmClient; this class only wires them together and exposes the -// application-wide state. class Chat : public QObject { Q_OBJECT QML_ELEMENT @@ -55,6 +52,7 @@ class Chat : public QObject { Q_INVOKABLE void stop(); Q_INVOKABLE void dismissError(); Q_INVOKABLE void refreshModels(); + Q_INVOKABLE void refreshFromServer(); Q_INVOKABLE void selectModel(const QString& id); static Chat* create(QQmlEngine*, QJSEngine*); diff --git a/Plugins/ZShell/Llm/codehighlighter.hpp b/Plugins/ZShell/Llm/codehighlighter.hpp index 4f0b116..7464804 100644 --- a/Plugins/ZShell/Llm/codehighlighter.hpp +++ b/Plugins/ZShell/Llm/codehighlighter.hpp @@ -37,10 +37,10 @@ class CodeHighlighter : public QObject { private: struct State { - bool bad = false; // permanent failure, do not retry + bool bad = false; void* lib = nullptr; - const void* lang = nullptr; // const TSLanguage* - void* query = nullptr; // TSQuery* + const void* lang = nullptr; + void* query = nullptr; }; [[nodiscard]] static const QHash& aliases(); @@ -59,15 +59,14 @@ class CodeHighlighter : public QObject { const QVariantList& spans) const; struct SpanCacheEntry { - QString code; // re-compared on lookup; a hash collision can - // never deliver the wrong spans + QString code; QVariantList spans; }; mutable QHash> m_states; mutable QMutex m_stateMutex; mutable QHash m_spanCache; - mutable QStringList m_spanCacheOrder; // LRU order, oldest first + mutable QStringList m_spanCacheOrder; mutable int m_spanCacheBytes = 0; mutable QMutex m_cacheMutex; static CodeHighlighter* s_instance; diff --git a/Plugins/ZShell/Llm/filetool.cpp b/Plugins/ZShell/Llm/filetool.cpp index a8e9b81..d4c7b6b 100644 --- a/Plugins/ZShell/Llm/filetool.cpp +++ b/Plugins/ZShell/Llm/filetool.cpp @@ -53,9 +53,9 @@ QJsonObject FileReadTool::parameters() const { QJsonObject limit; limit[QStringLiteral("type")] = QStringLiteral("integer"); limit[QStringLiteral("minimum")] = 1; - limit[QStringLiteral("description")] = - QStringLiteral("Maximum number of characters to return. Defaults " - "to a value that fits the model's context window."); + limit[QStringLiteral("description")] = QStringLiteral( + "Maximum number of characters to return. Defaults " + "to a value that fits the model's context window."); QJsonObject properties; properties[QStringLiteral("path")] = path; @@ -74,6 +74,7 @@ QJsonObject FileReadTool::parameters() const { void FileReadTool::execute( const QString& toolCallId, const QJsonObject& args, + int outputBudgetChars, std::function done) { Q_UNUSED(toolCallId); const QString path = args[QStringLiteral("path")].toString().trimmed(); @@ -82,7 +83,8 @@ void FileReadTool::execute( return; } const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0)); - const int budget = inlineBudgetChars(); + int budget = inlineBudgetChars(); + if (outputBudgetChars > 0) budget = qMin(budget, outputBudgetChars); const int limit = qBound(1, args[QStringLiteral("limit")].toInt(budget), budget); @@ -90,8 +92,9 @@ void FileReadTool::execute( if (!file.open(QIODevice::ReadOnly)) { const QFileInfo info(path); if (info.exists() && info.isDir()) { - done(makeError(QStringLiteral("Path is a directory, not a " - "file: %1") + done(makeError(QStringLiteral( + "Path is a directory, not a " + "file: %1") .arg(path))); } else { done(makeError(QStringLiteral("Cannot open file: %1").arg(path))); @@ -106,8 +109,9 @@ void FileReadTool::execute( } if (offset >= fileSize) { file.close(); - done(makeError(QStringLiteral("Offset %1 is beyond the end of the " - "file (%2 bytes)") + done(makeError(QStringLiteral( + "Offset %1 is beyond the end of the " + "file (%2 bytes)") .arg(offset) .arg(fileSize))); return; @@ -117,15 +121,13 @@ void FileReadTool::execute( done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path))); return; } - // UTF-8 uses at most 4 bytes per character, so this many bytes always - // cover `limit` characters. const qint64 wanted = qint64(limit) * 4 + 16; const QByteArray chunk = file.read(qMin(wanted, fileSize - offset)); file.close(); if (chunk.contains('\0')) { - done(makeError(QStringLiteral("Binary files are not supported: %1") - .arg(path))); + done(makeError( + QStringLiteral("Binary files are not supported: %1").arg(path))); return; } const QString content = QString::fromUtf8(chunk); diff --git a/Plugins/ZShell/Llm/filetool.hpp b/Plugins/ZShell/Llm/filetool.hpp index 30b6480..cd2c06e 100644 --- a/Plugins/ZShell/Llm/filetool.hpp +++ b/Plugins/ZShell/Llm/filetool.hpp @@ -4,10 +4,6 @@ namespace ZShell::llm { -// Lets the model read text files from the local filesystem, in -// byte-offset windows small enough to fit the context. Primarily used to -// page through the full webfetch output stored under -// /tmp/zshell-llm/webfetch/. class FileReadTool : public LlmTool { Q_OBJECT @@ -20,6 +16,7 @@ class FileReadTool : public LlmTool { void execute( const QString& toolCallId, const QJsonObject& args, + int outputBudgetChars, std::function done) override; }; diff --git a/Plugins/ZShell/Llm/generation.cpp b/Plugins/ZShell/Llm/generation.cpp index 7082930..2822aa3 100644 --- a/Plugins/ZShell/Llm/generation.cpp +++ b/Plugins/ZShell/Llm/generation.cpp @@ -220,8 +220,6 @@ LlmSegment* ChatGeneration::beginToolCall( LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this); segment->setName(name); segment->setToolCallId(toolCallId); - // Awaiting user approval until LlmClient::approveTools() promotes it - // to Running; denyTools()/endStream() finalize it as an error. segment->setStatus(LlmSegment::Status::Pending); segment->begin(); addSegment(segment); diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp index c85635e..6650aaf 100644 --- a/Plugins/ZShell/Llm/llmclient.cpp +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -12,15 +12,16 @@ #include #include #include +#include #include #include +#include + namespace ZShell::llm { namespace { -// Minimum prompt budget kept even when the transcript alone nearly fills -// the window, so the model always sees the triggering message. constexpr int kMinContextTokens = 512; qsizetype jsonValueChars(const QJsonValue& value) { @@ -49,16 +50,11 @@ int estimateMessageTokens(const QJsonObject& message) { return int(jsonValueChars(message) / LlmTool::CharsPerToken) + 4; } -// One message model row and the JSON messages it produces. Trimming works -// in whole units so an assistant turn and its tool results always travel -// together. struct ContextUnit { QList messages; int tokens = 0; }; -// Shrink the free-text fields of a unit until it fits the budget. Used -// when a single unit (e.g. one huge user message) alone exceeds it. void shrinkUnitToBudget(QList& unit, int budgetTokens) { static const QStringList kTextKeys = { QStringLiteral("content"), QStringLiteral("reasoning_content")}; @@ -90,6 +86,41 @@ void shrinkUnitToBudget(QList& unit, int budgetTokens) { } } +int estimateArrayTokens(const QJsonArray& messages) { + int total = 0; + for (const QJsonValue& value : messages) + total += estimateMessageTokens(value.toObject()); + return total; +} + +constexpr int kToolResultStubChars = 512; + +constexpr double kPromptEstimateDiscount = 10.0 / 12.0; + +constexpr int kMinToolResultTokens = 128; + +int completionReserve(int contextSize) { + return qBound(2048, contextSize / 8, 8192); +} + +int promptBudgetTokens(int contextSize) { + return int( + (contextSize - completionReserve(contextSize)) * + kPromptEstimateDiscount); +} + +constexpr int kRefreshIntervalMs = 60'000; + +constexpr int kProbeTimeoutMs = 2000; + +int contextSizeFromErrorMessage(const QString& message) { + static const QRegularExpression re( + QLatin1String("available context size \\((\\d+)")); + const auto match = re.match(message); + if (match.hasMatch()) return match.captured(1).toInt(); + return 0; +} + } // namespace QString LlmClient::completionsPath( @@ -140,7 +171,12 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) { &ToolRegistry::enabledChanged, this, &LlmClient::toolsEnabledChanged); - probeContextSize(); + + m_refreshTimer.setParent(this); + m_refreshTimer.setInterval(kRefreshIntervalMs); + connect( + &m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshFromServer); + m_refreshTimer.start(); } LlmClient::~LlmClient() { @@ -153,8 +189,8 @@ void LlmClient::setEndpoint(const QString& value) { if (m_endpoint == value) return; m_endpoint = value; Q_EMIT endpointChanged(); - probeContextSize(); - if (m_model.isEmpty()) refreshModels(); + m_refreshTimer.start(); + refreshFromServer(); } void LlmClient::setModel(const QString& value) { @@ -170,6 +206,8 @@ void LlmClient::setTemperature(double value) { void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { if (m_busy || !session || !target) return; + refreshModels(); + m_contextRetryUsed = false; m_active = session; m_streaming = target; target->setStreaming(true); @@ -202,11 +240,15 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { m_reasoningMark = 0; setApprovalPending(false); - sendRound(); + probeContextSize([this, session, target]() { + if (m_active != session || m_streaming != target) return; + sendRound(); + }); } void LlmClient::sendRound() { if (!m_active || !m_streaming) return; + probeContextSize(); m_finishReason.clear(); m_callBuilders.clear(); m_callResults.clear(); @@ -234,8 +276,6 @@ void LlmClient::sendRound() { return; } - // The transcript (this generation so far) is always sent in full, so - // reserve its estimated size out of the context budget. int transcriptTokens = 0; for (const QJsonValue& value : m_transcript) transcriptTokens += estimateMessageTokens(value.toObject()); @@ -244,6 +284,30 @@ void LlmClient::sendRound() { for (const QJsonValue& value : m_transcript) messages.append(value); + if (m_contextSize > 0) { + const int promptLimit = promptBudgetTokens(m_contextSize); + while (estimateArrayTokens(messages) > promptLimit) { + bool stubbed = false; + for (qsizetype i = 0; i < messages.size(); ++i) { + QJsonObject message = messages.at(i).toObject(); + if (message.value("role").toString() != QLatin1String("tool")) + continue; + const QString content = message.value("content").toString(); + if (content.size() <= kToolResultStubChars) continue; + QString stub = content.left(kToolResultStubChars); + stub += QStringLiteral( + "\n[truncated to fit the context window; was " + "%1 characters]") + .arg(content.size()); + message["content"] = stub; + messages[i] = message; + stubbed = true; + break; + } + if (!stubbed) break; + } + } + QJsonObject body; QJsonObject streamOptions; streamOptions[QStringLiteral("include_usage")] = true; @@ -283,8 +347,21 @@ void LlmClient::sendRound() { roundFinished(); else if (error == QNetworkReply::OperationCanceledError) finishTurn(); - else - fail(serverErrorMessage(responseBody, errorString)); + else { + const QString message = + serverErrorMessage(responseBody, errorString); + const int reported = contextSizeFromErrorMessage(message); + if (reported > 0 && reported != m_contextSize && + !m_contextRetryUsed) { + qWarning() << "LlmClient: prompt overflow, server reports" + << reported << "tokens; retrying round"; + setContextSize(reported); + m_contextRetryUsed = true; + sendRound(); + return; + } + fail(message); + } }); } @@ -349,21 +426,17 @@ QJsonArray LlmClient::buildContextMessages( } if (m_contextSize > 0) { - // Keep headroom for the model's own reply so the prompt cannot - // fill the whole window. - const int completionReserve = qBound(1024, m_contextSize / 8, 8192); - int budget = m_contextSize - completionReserve - extraReserveTokens; + int budget = m_contextSize - completionReserve(m_contextSize) - + extraReserveTokens; budget = qMax(budget, kMinContextTokens); int total = 0; for (const ContextUnit& unit : units) total += unit.tokens; - // Drop oldest units until the prompt fits the budget. while (units.size() > 1 && total > budget) { total -= units.first().tokens; units.removeFirst(); } - // A conversation cannot start with an assistant turn. while ( units.size() > 1 && units.first().messages.first()[QStringLiteral("role")].toString() == @@ -371,7 +444,6 @@ QJsonArray LlmClient::buildContextMessages( total -= units.first().tokens; units.removeFirst(); } - // A single oversized unit still gets sent, shrunk to fit. if (units.size() == 1 && units.first().tokens > budget) shrinkUnitToBudget(units.first().messages, budget); } @@ -417,7 +489,21 @@ void LlmClient::roundFinished() { bool hasCalls = false; for (const auto& builder : m_callBuilders) if (builder.seen) hasCalls = true; - if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) { + if (!hasCalls) { + finishTurn(); + return; + } + if (m_finishReason != QLatin1String("tool_calls")) { + qWarning() << "LlmClient: stream ended" << m_finishReason + << "with incomplete tool calls, ending turn"; + for (const auto& builder : m_callBuilders) { + if (!builder.seen || !builder.segment) continue; + builder.segment->finishTool( + QStringLiteral( + "Stream truncated before the tool call " + "completed"), + false); + } finishTurn(); return; } @@ -458,8 +544,6 @@ void LlmClient::roundFinished() { m_transcript.append(assistant); m_round++; - // The whole batch of this round waits for a single user decision; - // approveTools() runs all of it, denyTools() ends the turn. setApprovalPending(true); } @@ -468,6 +552,18 @@ void LlmClient::executeAllCalls() { m_pendingCalls = 0; m_callResults = QList(m_callBuilders.size()); + int batchSize = 0; + for (const auto& call : m_callBuilders) + if (call.seen) ++batchSize; + int perCallChars = 0; + if (batchSize > 0 && m_contextSize > 0) { + const int available = promptBudgetTokens(m_contextSize) - + estimateArrayTokens(m_transcript); + const int perCallTokens = + qMax(available / batchSize, kMinToolResultTokens); + perCallChars = perCallTokens * LlmTool::CharsPerToken; + } + for (int i = 0; i < m_callBuilders.size(); ++i) { const auto& call = m_callBuilders.at(i); if (!call.seen) continue; @@ -498,18 +594,22 @@ void LlmClient::executeAllCalls() { } ++m_pendingCalls; - tool->execute(call.id, args, [this, i, call](const QJsonObject& result) { - if (!m_streaming) return; - const bool success = result.contains(QStringLiteral("output")); - const QString content = - success ? result[QStringLiteral("output")].toString() - : QStringLiteral("Error: ") + - result[QStringLiteral("error")].toString(); - m_callResults[i] = {content, success}; - if (LlmSegment* segment = call.segment) - segment->finishTool(content, success); - if (--m_pendingCalls == 0) flushCallResults(); - }); + tool->execute( + call.id, + args, + perCallChars, + [this, i, call](const QJsonObject& result) { + if (!m_streaming) return; + const bool success = result.contains(QStringLiteral("output")); + const QString content = + success ? result[QStringLiteral("output")].toString() + : QStringLiteral("Error: ") + + result[QStringLiteral("error")].toString(); + m_callResults[i] = {content, success}; + if (LlmSegment* segment = call.segment) + segment->finishTool(content, success); + if (--m_pendingCalls == 0) flushCallResults(); + }); } if (m_pendingCalls == 0) flushCallResults(); } @@ -533,8 +633,6 @@ void LlmClient::flushCallResults() { void LlmClient::setApprovalPending(bool value) { if (m_approvalPending == value) return; m_approvalPending = value; - // Mirror onto the streaming generation; QML reads and answers from - // there, so only the affected ProcessBlock reacts. if (ChatGeneration* streaming = m_streaming) streaming->setApprovalPending(value); } @@ -548,8 +646,6 @@ void LlmClient::approveTools() { if (segment->type() != LlmSegment::Type::ToolCall || segment->status() != LlmSegment::Status::Pending) continue; - // Restart the segment timer so elapsed time covers execution, not - // the wait for approval. segment->setStatus(LlmSegment::Status::Running); segment->begin(); } @@ -592,7 +688,10 @@ void LlmClient::stop() { finishTurn(); return; } - if (m_reply) m_reply->abort(); + if (m_reply) + m_reply->abort(); + else + endStream(); } void LlmClient::endStream() { @@ -604,8 +703,6 @@ void LlmClient::endStream() { generation->setStreaming(false); m_approvalPending = false; generation->setApprovalPending(false); - // Finalize tool calls that never received an approval (turn cancelled, - // round limit reached, ...). for (auto* segment : generation->segments()) if (segment->type() == LlmSegment::Type::ToolCall && segment->status() == LlmSegment::Status::Pending) @@ -633,6 +730,7 @@ void LlmClient::finishTurn() { m_pendingClear.clear(); } if (session) session->persist(); + refreshFromServer(); } void LlmClient::clearOnFinish(ChatSession* session) { @@ -707,6 +805,12 @@ void LlmClient::handleLine(const QByteArray& line) { } } +void LlmClient::refreshFromServer() { + if (m_endpoint.trimmed().isEmpty()) return; + probeContextSize(); + refreshModels(); +} + void LlmClient::refreshModels() { const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); @@ -742,6 +846,9 @@ void LlmClient::refreshModels() { if (m_model.isEmpty()) { m_model = models.first(); Q_EMIT modelChanged(); + } else if (!models.contains(m_model)) { + m_model = models.first(); + Q_EMIT modelChanged(); } }); } @@ -753,15 +860,22 @@ void LlmClient::setContextSize(int size) { Q_EMIT contextSizeChanged(); } -void LlmClient::probeContextSize() { +void LlmClient::probeContextSize(std::function done) { QString base = m_endpoint.trimmed(); while (base.endsWith('/')) base.chop(1); const QUrl url = QUrl::fromUserInput(base + "/props"); - if (!url.isValid() || url.host().isEmpty()) return; + if (!url.isValid() || url.host().isEmpty()) { + if (done) done(); + return; + } auto* reply = m_manager.get(QNetworkRequest(url)); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { + auto settled = std::make_shared(false); + const auto finish = [this, reply, settled, done]() { + if (*settled) return; + *settled = true; + const QNetworkReply::NetworkError error = reply->error(); const QByteArray data = reply->readAll(); reply->deleteLater(); @@ -786,7 +900,15 @@ void LlmClient::probeContextSize() { .toInt(0); } } - setContextSize(size > 0 ? size : 4096); + if (size > 0) + setContextSize(size); + else if (m_contextSize <= 0) + setContextSize(4096); + if (done) done(); + }; + connect(reply, &QNetworkReply::finished, this, finish); + QTimer::singleShot(kProbeTimeoutMs, this, [reply, settled]() { + if (!*settled) reply->abort(); }); } diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp index f0346ff..7c864b1 100644 --- a/Plugins/ZShell/Llm/llmclient.hpp +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include @@ -42,7 +43,8 @@ class LlmClient : public QObject { void setModel(const QString& value); void setTemperature(double value); void setContextSize(int size); - void probeContextSize(); + void probeContextSize(std::function done = {}); + Q_INVOKABLE void refreshFromServer(); [[nodiscard]] bool busy() const { return m_busy; } [[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); } @@ -56,10 +58,6 @@ class LlmClient : public QObject { void clearOnFinish(ChatSession* session); void sessionRemoved(ChatSession* session); - // Tool calls are executed only after the user approves the batch the - // model requested. The per-generation state (toolApprovalPending, - // pendingToolCalls) is mirrored onto ChatGeneration; these are the - // entry points QML reaches through it. void approveTools(); void denyTools(); @@ -118,9 +116,7 @@ class LlmClient : public QObject { ToolRegistry* m_tools = nullptr; QNetworkReply* m_reply = nullptr; QByteArray m_buffer; - // QPointer: these objects are owned by the ChatStore tree, which Qt - // destroys *before* this client on shutdown (children die in creation - // order). The pointers must self-null instead of dangling. + QTimer m_refreshTimer; QPointer m_active; QPointer m_streaming; QPointer m_pendingClear; @@ -146,6 +142,7 @@ class LlmClient : public QObject { bool m_roundDone = false; bool m_toolPhase = false; bool m_approvalPending = false; + bool m_contextRetryUsed = false; int m_pendingCalls = 0; static constexpr int kMaxToolRounds = 12; }; diff --git a/Plugins/ZShell/Llm/segment.hpp b/Plugins/ZShell/Llm/segment.hpp index 589ec12..ecef553 100644 --- a/Plugins/ZShell/Llm/segment.hpp +++ b/Plugins/ZShell/Llm/segment.hpp @@ -29,8 +29,6 @@ class LlmSegment : public QObject { enum class Type : int { Reasoning = 0, ToolCall, Content }; Q_ENUM(Type) - // Pending is appended last: statuses are persisted as integers in the - // chat database, so existing values must not move. enum class Status : int { None = 0, Running, Success, Error, Pending }; Q_ENUM(Status) diff --git a/Plugins/ZShell/Llm/tool.cpp b/Plugins/ZShell/Llm/tool.cpp index ab8b052..1bb8107 100644 --- a/Plugins/ZShell/Llm/tool.cpp +++ b/Plugins/ZShell/Llm/tool.cpp @@ -13,8 +13,6 @@ void LlmTool::setContextSize(int value) { int LlmTool::inlineBudgetChars() const { if (m_contextSize <= 0) return DefaultInlineChars; - // About a quarter of the context window, clamped so tiny contexts - // still get a usable result and huge ones stay sane. const int tokens = qBound(512, m_contextSize / 4, DefaultInlineChars / CharsPerToken); return tokens * CharsPerToken; diff --git a/Plugins/ZShell/Llm/tool.hpp b/Plugins/ZShell/Llm/tool.hpp index 8ca95a1..3ce0d86 100644 --- a/Plugins/ZShell/Llm/tool.hpp +++ b/Plugins/ZShell/Llm/tool.hpp @@ -21,29 +21,21 @@ class LlmTool : public QObject { [[nodiscard]] virtual QString description() const = 0; [[nodiscard]] virtual QJsonObject parameters() const = 0; - // toolCallId identifies the call within the conversation; tools may use - // it to name files they write for later retrieval. virtual void execute( const QString& toolCallId, const QJsonObject& args, + int outputBudgetChars, std::function done) = 0; virtual void cancel(); [[nodiscard]] QJsonObject specification() const; - // The endpoint's context size in tokens (0 = unknown). Set by LlmClient. [[nodiscard]] int contextSize() const { return m_contextSize; } void setContextSize(int value); - // Rough cap, in characters, for tool output embedded in a tool result - // message. Scales with the context size so a single result cannot - // consume the whole window. [[nodiscard]] int inlineBudgetChars() const; - // ~4 characters per token; a deliberately coarse estimate used for - // budgeting (never for exact accounting). static constexpr int CharsPerToken = 4; - // Inline budget when the context size is unknown. static constexpr int DefaultInlineChars = 64 * 1024; protected: @@ -66,7 +58,6 @@ class ToolRegistry : public QObject { [[nodiscard]] QJsonArray specifications() const; void cancelAll(); - // Propagated to every registered tool, including later ones. void setContextSize(int value); Q_SIGNALS: diff --git a/Plugins/ZShell/Llm/webfetchtool.cpp b/Plugins/ZShell/Llm/webfetchtool.cpp index 2fb799e..1d5e76f 100644 --- a/Plugins/ZShell/Llm/webfetchtool.cpp +++ b/Plugins/ZShell/Llm/webfetchtool.cpp @@ -112,10 +112,12 @@ void WebFetchTool::completeJob(Job* job, QJsonObject result) { void WebFetchTool::execute( const QString& toolCallId, const QJsonObject& args, + int outputBudgetChars, std::function done) { auto* job = new Job; job->done = std::move(done); job->toolCallId = toolCallId; + job->budgetChars = outputBudgetChars; m_jobs.append(job); auto fail = [this, job](const QString& message) { @@ -248,23 +250,24 @@ void WebFetchTool::execute( const QString savedPath = saveToFile(*job, content, mime); QString output = content; - const int budget = inlineBudgetChars(); + int budget = inlineBudgetChars(); + if (job->budgetChars > 0) budget = qMin(budget, job->budgetChars); if (content.size() > budget) { output = content.left(budget); - const QString note = QStringLiteral( - "\n\n[... truncated: showing %1 of %2 characters") - .arg(budget) - .arg(content.size()); + const QString note = + QStringLiteral( + "\n\n[... truncated: showing %1 of %2 characters") + .arg(budget) + .arg(content.size()); if (!savedPath.isEmpty()) - output += note + - QStringLiteral( - ". The full content is saved to %1; use " - "the readfile tool to read the rest.") - .arg(savedPath); + output += note + QStringLiteral( + ". The full content is saved to %1; use " + "the readfile tool to read the rest.") + .arg(savedPath); else output += note + QStringLiteral( - ". The remaining content is not " - "available."); + ". The remaining content is not " + "available."); } completeJob(job, makeOutput(output)); }); @@ -273,14 +276,13 @@ void WebFetchTool::execute( QString WebFetchTool::saveToFile( const Job& job, const QString& content, const QString& mime) const { QDir dir(StoragePath); - if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) - return QString(); + if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) return QString(); QString fileName; fileName.reserve(job.toolCallId.size()); for (const QChar& c : job.toolCallId) fileName += (c.isLetterOrNumber() || c == QLatin1Char('-') || - c == QLatin1Char('_')) + c == QLatin1Char('_')) ? c : QLatin1Char('_'); if (fileName.isEmpty()) fileName = QStringLiteral("fetch"); @@ -293,8 +295,7 @@ QString WebFetchTool::saveToFile( else if (mime.contains(QLatin1String("xml"))) extension = QStringLiteral("xml"); - const QString path = - dir.filePath(fileName + QLatin1Char('.') + extension); + const QString path = dir.filePath(fileName + QLatin1Char('.') + extension); QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) return QString(); diff --git a/Plugins/ZShell/Llm/webfetchtool.hpp b/Plugins/ZShell/Llm/webfetchtool.hpp index 1c69bb9..c992c77 100644 --- a/Plugins/ZShell/Llm/webfetchtool.hpp +++ b/Plugins/ZShell/Llm/webfetchtool.hpp @@ -20,8 +20,6 @@ class WebFetchTool : public LlmTool { static constexpr int MaxResponseBytes = 5 * 1024 * 1024; static constexpr int DefaultTimeoutSeconds = 30; static constexpr int MaxTimeoutSeconds = 120; - // Where the full (untruncated) output of each call is stored so the - // model can page through it with the readfile tool. static const QString StoragePath; explicit WebFetchTool(QObject* parent = nullptr); @@ -33,6 +31,7 @@ class WebFetchTool : public LlmTool { void execute( const QString& toolCallId, const QJsonObject& args, + int outputBudgetChars, std::function done) override; void cancel() override; @@ -47,14 +46,13 @@ class WebFetchTool : public LlmTool { bool tooLarge = false; QString format; QString toolCallId; + int budgetChars = 0; std::function done; }; void completeJob(Job* job, QJsonObject result); - // Writes the full output to StoragePath; returns the file path, or an - // empty string when saving failed. - QString saveToFile(const Job& job, const QString& content, - const QString& mime) const; + QString saveToFile( + const Job& job, const QString& content, const QString& mime) const; QNetworkAccessManager m_manager; QList m_jobs; From 83f6f95f6eaf6adc45d7e8898117a4c0508620cd Mon Sep 17 00:00:00 2001 From: zach Date: Sat, 12 Sep 2026 23:58:11 +0200 Subject: [PATCH 49/90] gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index de6958c..8b588fe 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ dist/ network-dev/ **/zshell.build/ **/zshell.dist/ +.opencode/ +run-agent.sh From d8a3d5732ea4da4c127682525e2a617e2fd562f9 Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 17 Sep 2026 15:20:41 +0200 Subject: [PATCH 50/90] new chatlistview component, file attachments, performance & primitive test --- Components/CustomScrollBar.qml | 7 +- Components/VerticalFadeListView.qml | 5 +- .../Sidebar/Chat/ChatContent.qml | 399 +++++- .../Sidebar/Chat/Content/CodeBlockView.qml | 70 +- .../Sidebar/Chat/Content/ErrorBubble.qml | 128 ++ .../Sidebar/Chat/Content/MathBlockView.qml | 3 +- .../Sidebar/Chat/Content/MessageDelegate.qml | 221 ++- .../Sidebar/Chat/Content/ProcessBlock.qml | 166 ++- Plugins/CMakeLists.txt | 2 +- Plugins/ZShell/Components/CMakeLists.txt | 10 +- Plugins/ZShell/Components/chatlistview.cpp | 1239 +++++++++++++++++ Plugins/ZShell/Components/chatlistview.hpp | 299 ++++ Plugins/ZShell/Config/llm.hpp | 3 +- Plugins/ZShell/Llm/CMakeLists.txt | 2 + Plugins/ZShell/Llm/attachment.cpp | 19 + Plugins/ZShell/Llm/attachment.hpp | 48 + Plugins/ZShell/Llm/chat.cpp | 48 +- Plugins/ZShell/Llm/chat.hpp | 20 +- Plugins/ZShell/Llm/chatstore.cpp | 96 ++ Plugins/ZShell/Llm/codehighlighter.cpp | 8 +- Plugins/ZShell/Llm/generation.cpp | 15 + Plugins/ZShell/Llm/generation.hpp | 9 + Plugins/ZShell/Llm/llmclient.cpp | 276 +++- Plugins/ZShell/Llm/llmclient.hpp | 13 +- Plugins/ZShell/Llm/markdownblock.hpp | 7 +- Plugins/ZShell/Llm/mathtext.cpp | 224 ++- Plugins/ZShell/Llm/mathtext.hpp | 17 +- Plugins/ZShell/Llm/message.cpp | 14 + Plugins/ZShell/Llm/message.hpp | 10 + Plugins/ZShell/Llm/segment.cpp | 2 +- Plugins/ZShell/Llm/session.cpp | 180 ++- Plugins/ZShell/Llm/session.hpp | 21 + Plugins/ZShell/Llm/shelltool.cpp | 258 ++++ Plugins/ZShell/Llm/shelltool.hpp | 57 + scripts/mock-llama-server.py | 718 ++++++++++ tests/run-qml-tests.sh | 64 + 36 files changed, 4368 insertions(+), 310 deletions(-) create mode 100644 Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml create mode 100644 Plugins/ZShell/Components/chatlistview.cpp create mode 100644 Plugins/ZShell/Components/chatlistview.hpp create mode 100644 Plugins/ZShell/Llm/attachment.cpp create mode 100644 Plugins/ZShell/Llm/attachment.hpp create mode 100644 Plugins/ZShell/Llm/shelltool.cpp create mode 100644 Plugins/ZShell/Llm/shelltool.hpp create mode 100644 scripts/mock-llama-server.py create mode 100755 tests/run-qml-tests.sh diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index a70e9a8..69339d5 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -9,6 +9,7 @@ ScrollBar { id: root required property Flickable flickable + readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root readonly property real axisSize: isHorizontal ? flickable.width : flickable.height readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight @@ -20,9 +21,11 @@ ScrollBar { readonly property real nonAnimY: root.axisContentPos / root.axisContentSize readonly property real rawTravel: Math.max(0, 1 - root.nonAnimHeight) readonly property bool reversed: isHorizontal ? (flickable instanceof ListView && flickable.layoutDirection === Qt.RightToLeft) : (flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop) - property bool shouldBeActive readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0 + property bool shouldBeActive + property bool inverted + enabled: !Visibilities.getForActive().isDrawing implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2 implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0 @@ -119,7 +122,7 @@ ScrollBar { } var delta = event.angleDelta.y > 0 ? -0.1 : 0.1; - var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta)); + var newPos = Math.max(0, Math.min(1 - root.size, root.position + (root.inverted ? -delta : delta))); root.position = newPos; } diff --git a/Components/VerticalFadeListView.qml b/Components/VerticalFadeListView.qml index 2430660..6fe91b6 100644 --- a/Components/VerticalFadeListView.qml +++ b/Components/VerticalFadeListView.qml @@ -7,6 +7,7 @@ CustomListView { id: root property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1 + property bool fadeEnabled: true property real fadeAmount: 0.1 property real fadeThreshold: 0.0 readonly property bool horizontal: orientation === ListView.Horizontal @@ -17,7 +18,9 @@ CustomListView { } function fadeShouldBeActive(isStart: bool): bool { - // When content is smaller than flickable size, hide fade when rebound starts. + if (!fadeEnabled) + return false; + if (contentSize() + marginStart() + marginEnd() < viewportSize() && rebound.running && ((isStart ? overshootStart() > 0 : overshootStart() < 0))) { return false; } diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 2b8f2d7..88a4c2d 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -1,4 +1,7 @@ +pragma ComponentBehavior: Bound + import QtQuick +import Quickshell import QtQuick.Layouts import ZShell.Components import ZShell.Config @@ -11,7 +14,6 @@ Item { id: root property ChatSession chatData - property bool following: true // qmlformat off readonly property var keys: [ Qt.Key_A, @@ -87,12 +89,33 @@ Item { signal requestClose + readonly property bool atNewestEnd: !listLoader.item || listLoader.item.contentY < 4 + + property var pendingAttachments: [] + + function attachFiles(): void { + Files.pickFiles().then(files => { + if (!files || files.length === 0) + return; + const added = chatData.addAttachments(files); + root.pendingAttachments = root.pendingAttachments.concat(added); + }); + } + function send(text: string): void { - if (text.trim() === "") + if (text.trim() === "" && root.pendingAttachments.length === 0) return; - following = true; - chatData.sendMessage(text); + root.scrollToLatest(); + chatData.sendMessage(text, root.pendingAttachments); input.text = ""; + root.pendingAttachments = []; + } + + function scrollToLatest(): void { + if (!listLoader.item || root.atNewestEnd) + return; + scrollAnim.stop(); + scrollAnim.start(); } function focusInput(): void { @@ -110,6 +133,18 @@ Item { } } + Anim { + id: scrollAnim + + target: listLoader.item + property: "contentY" + to: 0 + type: Anim.DefaultEffects + + onFinished: if (target) + target.contentY = 0 + } + RowLayout { id: header @@ -151,41 +186,112 @@ Item { anchors.topMargin: Tokens.spacing.medium active: root.chatData && root.chatData.loaded - sourceComponent: VerticalFadeListView { - id: list + sourceComponent: Flickable { + id: flick - property bool userScrolledUp: false - property real lastCHeight: 0.0 + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: list.layoutHeight - function scrollToBottom(): void { - scrollAnim.start(); + transform: Rotation { + origin.y: flick.height / 2 + origin.x: flick.width / 2 + angle: 180 } - cacheBuffer: Math.max(height * 20, 0) - clip: true - fadeAmount: 0.05 - fadeThreshold: Tokens.padding.medium - model: root.chatData.messagesModel - spacing: 0 - rotation: 180 - add: Transition { - Anim { - from: -100 - property: "y" + WheelInverter { + target: flick + } + + Anim { + id: restoreAnim + + target: flick + type: Anim.DefaultEffects + property: "contentY" + } + + ChatListView { + id: list + + width: flick.width + height: flick.height + yAnimDuration: Tokens.anim.durations.expressiveDefaultEffects + + asynchronous: true + cacheBuffer: 50 + estimatedHeight: 100 + readyDelay: 5 + removeDuration: Tokens.anim.durations.normal + spacing: 0 + + model: root.chatData.messagesModel + + useCustomViewport: true + viewport: { + tWatcher.transform; + return Qt.rect(0, flick.contentY, width, flick.height); + } + onViewportAdjustNeeded: delta => { + restoreAnim.stop(); + flick.contentY += delta; + } + onViewportRestoreRequested: target => { + restoreAnim.to = target; + restoreAnim.start(); + } + + delegate: MessageDelegate { + id: message + + rootParent: flick + chatList: list + ChatListView.preferredHeight: implicitHeight + ChatListView.trackViewport: true + ChatListView.visibleHeight: ChatListView.removing ? 0 : implicitHeight + opacity: ChatListView.removing || ChatListView.adding ? 0 : 1 + transform: Rotation { + origin.y: message.height / 2 + origin.x: message.width / 2 + angle: 180 + } + + Behavior on y { + enabled: message.ChatListView.ready + + Anim { + id: yAnim + type: Anim.DefaultEffects + } + } + + Behavior on x { + Anim { + type: Anim.DefaultEffects + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } } } CustomScrollBar.vertical: CustomScrollBar { id: scrollBar - flickable: list - parent: list.parent - anchors.top: list.top - anchors.right: list.right - anchors.bottom: list.bottom + flickable: flick + parent: flick.parent + anchors.top: flick.top + anchors.right: flick.right + anchors.bottom: flick.bottom + inverted: true transform: Rotation { - origin.y: list.height / 2 + origin.y: flick.height / 2 origin.x: scrollBar.width / 2 angle: 180 @@ -196,57 +302,15 @@ Item { } } } - delegate: MessageDelegate { - rotation: 180 - rootParent: list - } - displaced: Transition { - Anim { - property: "y" - } - } - move: Transition { - Anim { - property: "y" - } - } + TransformWatcher { + id: tWatcher - Component.onCompleted: { - positionViewAtBeginning(); - } - onAtYBeginningChanged: { - if (atYBeginning) - userScrolledUp = false; - } - onContentHeightChanged: { - if (userScrolledUp && Chat.busy) { - const delta = contentHeight - lastCHeight; - contentY += delta; - } - - lastCHeight = contentHeight; - } - onMovingChanged: { - if (moving) - userScrolledUp = !atYBeginning; - } - - Anim { - id: scrollAnim - - property: "contentY" - target: list - type: Anim.DefaultEffects - to: list.originY - } - - WheelInverter { - target: list + a: flick.contentItem + b: list } } - // Trigger the load even before we're ready to show the list. Component.onCompleted: if (root.chatData) root.chatData.messagesModel } @@ -265,7 +329,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter implicitHeight: scrollToBottom.implicitHeight implicitWidth: scrollToBottom.implicitWidth - scale: !listLoader.item.atYBeginning && listLoader.item.contentHeight > listLoader.item.height ? 1 : 0 + scale: root.atNewestEnd || !listLoader.item || listLoader.item.contentHeight <= listLoader.item.height ? 0 : 1 visible: scale > 0 Behavior on scale { @@ -289,7 +353,180 @@ Item { type: IconButton.Tonal onClicked: { - listLoader.item.scrollToBottom(); + root.scrollToLatest(); + } + } + } + + GridView { + id: attachments + + readonly property int columns: ChatState.isWindow ? 5 : 3 + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: input.top + anchors.bottomMargin: Tokens.spacing.medium + + flow: GridView.LeftToRight + verticalLayoutDirection: GridView.BottomToTop + + cellWidth: width / columns + cellHeight: cellWidth + + implicitHeight: Math.ceil(count / columns) * cellHeight + + displaced: Transition { + Anim { + property: "x" + } + Anim { + property: "y" + } + } + + add: Transition { + id: addTrans + Anim { + property: "y" + from: addTrans.ViewTransition.destination.y + attachments.cellHeight / 4 + } + Anim { + property: "opacity" + from: 0 + to: 1 + } + } + remove: Transition { + id: removeTrans + Anim { + property: "y" + to: removeTrans.ViewTransition.destination.y + attachments.cellHeight / 4 + } + Anim { + property: "opacity" + from: 1 + to: 0 + } + } + + model: ScriptModel { + objectProp: "path" + values: root.pendingAttachments + } + + delegate: Item { + id: thumb + + required property var modelData + function fileIcon(mime): string { + if (mime.startsWith("archive/") || mime === "application/zip") + return "folder_zip"; + if (mime === "application/pdf") + return "picture_as_pdf"; + if (mime.startsWith("audio/")) + return "audio_file"; + if (mime.startsWith("video/")) + return "video_file"; + if (mime.startsWith("text/")) + return "description"; + return "insert_drive_file"; + } + + width: attachments.cellWidth + height: width + opacity: modelData ? 1 : 0 + CustomClippingRect { + anchors.fill: parent + anchors.margins: Tokens.spacing.extraSmall + + radius: Tokens.rounding.medium + color: Colors.tPalette.m3surfaceContainer + + FadeImage { + id: attImage + + anchors.fill: parent + fillMode: FadeImage.PreserveAspectCrop + asynchronous: true + source: visible ? thumb.modelData?.path ?? "" : "" + visible: thumb.modelData?.isImage ?? false + } + + MaterialIcon { + anchors.centerIn: parent + font.pointSize: Tokens.font.size.extraLarge + text: thumb.fileIcon(thumb.modelData?.mimeType ?? "") + visible: !thumb.modelData?.isImage ?? false + } + + Loader { + id: attIndicatorLoader + + anchors.centerIn: parent + + opacity: 0 + active: opacity > 0 && (thumb.modelData?.isImage ?? false) + + sourceComponent: CustomRect { + implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 + implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 + + color: Colors.palette.m3primaryContainer + radius: Tokens.rounding.full + + LoadingIndicator { + id: attLoadingIndicator + + anchors.centerIn: parent + containsIcon: true + implicitSize: thumb.implicitWidth * 0.4 + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Timer { + id: attLoadDebounceTimer + + interval: 100 + onTriggered: { + if (attImage.status !== Image.Ready) + attIndicatorLoader.opacity = 1; + } + } + + MouseArea { + id: hover + + anchors.fill: parent + hoverEnabled: true + cursorShape: undefined + + IconButton { + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Tokens.padding.medium + + icon: "close" + visible: opacity > 0 + enabled: thumb.opacity > 0 + opacity: hover.containsMouse ? 1 : 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + + onClicked: root.pendingAttachments = root.pendingAttachments.filter(a => a !== thumb.modelData) + } + } } } } @@ -310,7 +547,9 @@ Item { sendIcon.icon: "arrow_upward" sendIcon.padding: Tokens.padding.extraSmall - Component.onCompleted: root.focusInput() + Component.onCompleted: { + root.focusInput(); + } Keys.onPressed: e => { if (e.key == Qt.Key_Return) { if (!(e.modifiers & Qt.ShiftModifier)) { @@ -318,6 +557,14 @@ Item { e.accepted = true; } } + + if (e.key == Qt.Key_V && e.modifiers & Qt.ControlModifier) { + if (root.chatData.clipboardHasAttachment()) { + const att = root.chatData.pasteAttachments(); + root.pendingAttachments = [...root.pendingAttachments, ...att]; + e.accepted = true; + } + } } onSendPressed: root.send(text) } diff --git a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml index e264c85..d199797 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml @@ -16,10 +16,15 @@ CustomRect { property bool copied: false property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh property color codeHeaderColor: Colors.palette.m3outline - property var codeSpans: [] + property string html: "" + property bool colored: false + property string requestedCode: "" + property string requestedLanguage: "" property int highlightToken: 0 function refresh() { + requestedCode = root.code; + requestedLanguage = root.language; const token = ++root.highlightToken; CodeHighlighter.highlight(root.code, root.language, root, token); } @@ -27,7 +32,11 @@ CustomRect { function onHighlightSpans(token, spans) { if (token !== root.highlightToken) return; - codeSpans = spans; + if (root.code !== root.requestedCode || + root.language !== root.requestedLanguage) + return; + html = spans.length ? root.highlightedHtml(root.code, spans) : ""; + colored = root.visible && html.length > 0; } function roleColor(kind) { @@ -71,6 +80,21 @@ CustomRect { return text.replace(/&/g, "&").replace(//g, ">"); } + // Mirrors the whitespace rules of the rich text rendering below (leading + // whitespace kept, mid-line runs collapsed, tabs as single spaces) so the + // plain and colored layouts are identical. + function plainForDisplay(code): string { + return code.split("\n") + .map(line => { + const i = line.search(/\S/); + if (i < 0) + return ""; + const lead = line.slice(0, i).replace(/\t/g, " "); + return lead + line.slice(i).replace(/\s+/g, " ").replace(/\s+$/, ""); + }) + .join("\n"); + } + function highlightedHtml(code, spans): string { let out; if (!spans.length) { @@ -101,10 +125,26 @@ CustomRect { radius: Tokens.rounding.medium onLanguageChanged: { - codeSpans = []; - refresh(); + html = ""; + colored = false; + if (root.visible) + refreshTimer.restart(); + } + onCodeChanged: { + colored = false; + if (root.visible) + refreshTimer.restart(); + } + onVisibleChanged: if (root.visible) + refreshTimer.restart(); + + Timer { + id: refreshTimer + + interval: 200 + repeat: false + onTriggered: refresh() } - onCodeChanged: refresh() RowLayout { id: headerRow @@ -229,21 +269,6 @@ CustomRect { implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2 color: CodeColors.active.bg - CustomText { - id: code - anchors.top: parent.top - anchors.bottom: parent.bottom - x: implicitWidth * (0 - codeFlick.visibleArea.xPosition) + Tokens.padding.small - anchors.margins: Tokens.padding.small - text: root.highlightedHtml(root.code, root.codeSpans) - textFormat: Text.RichText - color: CodeColors.active.normal - layer.enabled: true - clip: false - font.family: Config.appearance.font.family.mono - font.pointSize: Tokens.font.size.small - } - Flickable { id: codeFlick @@ -263,12 +288,11 @@ CustomRect { color: CodeColors.active.normal layer.enabled: true - leftInset: Tokens.padding.small clip: false font.family: Config.appearance.font.family.mono font.pointSize: Tokens.font.size.small - textFormat: Text.RichText - text: code.text + textFormat: root.colored ? Text.RichText : Text.PlainText + text: root.colored ? root.html : root.plainForDisplay(root.code) readOnly: true } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml new file mode 100644 index 0000000..503df55 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml @@ -0,0 +1,128 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Llm +import ZShell.Config +import qs.Components +import qs.Services + +Item { + id: root + + required property ChatGeneration current + + signal dismiss + + implicitHeight: 0 + + Component.onCompleted: { + root.implicitHeight = Qt.binding(() => errorInner.implicitHeight + errorInner.anchors.margins * 2); + enterAnim.start(); + } + + Behavior on implicitHeight { + Anim {} + } + + ParallelAnimation { + id: enterAnim + + Anim { + target: container + property: "y" + from: container.implicitHeight + to: 0 + } + + Anim { + target: container + property: "opacity" + from: 0 + to: 1 + } + } + + ParallelAnimation { + id: exitAnim + + Anim { + target: root + property: "implicitHeight" + from: root.implicitHeight + to: 0 + } + + Anim { + target: container + property: "y" + to: container.implicitHeight + from: 0 + } + + Anim { + target: container + property: "opacity" + to: 0 + from: 1 + } + + onFinished: root.dismiss() + } + + CustomRect { + id: container + + anchors.left: parent.left + anchors.right: parent.right + + radius: Tokens.rounding.medium + color: Colors.palette.m3errorContainer + implicitHeight: errorInner.implicitHeight + errorInner.anchors.margins * 2 + + ColumnLayout { + id: errorInner + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Tokens.padding.medium + + spacing: Tokens.spacing.small + + CustomText { + id: header + + Layout.fillWidth: true + + font.pointSize: Tokens.font.size.large + elide: CustomText.ElideRight + color: Colors.palette.m3onErrorContainer + + text: qsTr("An error occured") + } + + CustomText { + id: body + + Layout.fillWidth: true + + wrapMode: CustomText.WordWrap + color: Colors.palette.m3onErrorContainer + + text: root.current.errorMessage || Chat.serverError + } + } + + IconButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Tokens.padding.medium + + isRound: true + icon: "close" + type: IconButton.Text + font.pointSize: Tokens.font.size.large + + onClicked: exitAnim.start() + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml index 0a8727d..b07d49f 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml @@ -26,8 +26,7 @@ Item { id: equationImage anchors.centerIn: parent - source: math.imageUrl - asynchronous: true + source: math.imageId.length ? "image://llmmath/" + math.imageId : "" visible: math.ok fillMode: Image.PreserveAspectFit smooth: true diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index 20d4e32..af362f7 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -3,45 +3,50 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import Quickshell +import ZShell.Components import ZShell.Config import ZShell.Llm +import qs.Modules.Notifications.Sidebar.Chat import qs.Components import qs.Services MouseArea { id: root - property ChatGeneration current: modelData.activeGeneration required property int index - readonly property bool isUser: modelData.role === ChatMessage.Role.User required property Item rootParent required property ChatMessage modelData - property bool reasoningExpanded: false - readonly property var blocks: blockify(current.segments) - property real savedOffset: -1 - property bool stickActive: false + required property ChatListView chatList - function handleReasoningToggle(expanded: bool): void { - const view = root.ListView.view; - if (!view) + readonly property bool isUser: modelData.role === ChatMessage.Role.User + readonly property var blocks: blockify(current.segments, current.error) + + property ChatGeneration current: modelData.activeGeneration + property bool reasoningExpanded: false + property real savedOffset: NaN + property bool anchored: false + + function handleReasoningToggle(expanded: bool, block: Item): void { + if (!root.rootParent) return; if (expanded) { - root.savedOffset = view.contentY - root.y; - root.reasoningExpanded = true; + root.savedOffset = root.rootParent.contentY - root.y; + root.anchored = true; + chatList.anchorItem(block, ChatListView.TopEdge); + settleTimer.start(); } else { - root.reasoningExpanded = false; - root.stickActive = false; settleTimer.stop(); + root.anchored = false; + chatList.clearAnchor(); if (!isNaN(root.savedOffset)) { - restoreAnim.to = root.y + root.savedOffset; - restoreAnim.restart(); + chatList.requestRestore(root.y + root.savedOffset); root.savedOffset = NaN; } } } - function blockify(segs) { + function blockify(segs, errored) { const blocks = []; for (let i = 0; i < segs.length; i++) { const seg = segs[i]; @@ -65,35 +70,32 @@ MouseArea { } } + if (errored) + blocks.push({ + kind: "error", + id: segs.length + }); + return blocks; } - implicitWidth: ListView.view.width hoverEnabled: true preventStealing: false - implicitHeight: layout.implicitHeight + Tokens.padding.medium + implicitHeight: layout.implicitHeight + Tokens.padding.medium + (attachments.visible ? attachments.implicitHeight : 0) + onImplicitHeightChanged: if (root.anchored) + settleTimer.restart() Timer { id: settleTimer - interval: 16 + + interval: Tokens.anim.durations.expressiveDefaultEffects + repeat: false onTriggered: { - root.stickActive = false; - } - } - - onImplicitHeightChanged: { - if (root.reasoningExpanded) { - root.stickActive = true; - settleTimer.restart(); - - const view = root.ListView.view; - if (!view) - return; - const bottomEdge = root.y + root.height + Tokens.padding.large * 2; - const viewportBottom = view.contentY + view.height; - if (bottomEdge > viewportBottom) - view.contentY = bottomEdge - view.height; + if (root.anchored) { + root.anchored = false; + chatList.clearAnchor(); + } } } @@ -147,20 +149,144 @@ MouseArea { } } - Anim { - id: restoreAnim + GridView { + id: attachments - property: "contentY" - target: root.ListView.view - type: Anim.DefaultEffects + readonly property int columns: ChatState.isWindow ? 5 : 3 + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + + flow: GridView.LeftToRight + verticalLayoutDirection: GridView.BottomToTop + + cellWidth: width / columns + cellHeight: cellWidth + + implicitHeight: Math.ceil(count / columns) * cellHeight + + displaced: Transition { + Anim { + property: "x" + } + Anim { + property: "y" + } + } + + model: ScriptModel { + objectProp: "path" + values: { + const list = [...root.modelData.attachments]; + + if (list.length > 0 && list.length < attachments.columns) { + while (list.length < attachments.columns) + list.unshift(null); + } + + return list; + } + } + + delegate: Item { + id: thumb + + required property var modelData + + function fileIcon(mime): string { + if (mime.startsWith("archive/") || mime === "application/zip") + return "folder_zip"; + if (mime === "application/pdf") + return "picture_as_pdf"; + if (mime.startsWith("audio/")) + return "audio_file"; + if (mime.startsWith("video/")) + return "video_file"; + if (mime.startsWith("text/")) + return "description"; + return "insert_drive_file"; + } + + width: attachments.cellWidth + height: width + opacity: modelData ? 1 : 0 + + CustomClippingRect { + anchors.fill: parent + anchors.margins: Tokens.spacing.extraSmall + + radius: Tokens.rounding.medium + color: Colors.tPalette.m3surfaceContainer + + FadeImage { + id: attImage + + anchors.fill: parent + fillMode: FadeImage.PreserveAspectCrop + asynchronous: true + source: visible ? thumb.modelData?.path ?? "" : "" + visible: thumb.modelData?.isImage ?? false + } + + MaterialIcon { + anchors.centerIn: parent + font.pointSize: Tokens.font.size.extraLarge + text: thumb.fileIcon(thumb.modelData?.mimeType ?? "") + visible: !thumb.modelData?.isImage ?? false + } + + Loader { + id: attIndicatorLoader + + anchors.centerIn: parent + + opacity: 0 + active: opacity > 0 && (thumb.modelData?.isImage ?? false) + + sourceComponent: CustomRect { + implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 + implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2 + + color: Colors.palette.m3primaryContainer + radius: Tokens.rounding.full + + LoadingIndicator { + id: attLoadingIndicator + + anchors.centerIn: parent + containsIcon: true + implicitSize: thumb.implicitWidth * 0.4 + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Timer { + id: attLoadDebounceTimer + + interval: 100 + onTriggered: { + if (attImage.status !== Image.Ready) + attIndicatorLoader.opacity = 1; + } + } + } + } } Column { id: layout - anchors.top: parent.top + anchors.top: attachments.bottom anchors.left: parent.left anchors.right: parent.right + spacing: Tokens.spacing.medium Repeater { @@ -173,6 +299,17 @@ MouseArea { delegate: DelegateChooser { role: "kind" + DelegateChoice { + roleValue: "error" + + delegate: ErrorBubble { + width: root.width + current: root.current + + onDismiss: root.current.dismissError() + } + } + DelegateChoice { roleValue: "process" @@ -182,7 +319,7 @@ MouseArea { current: root.current rootParent: root.rootParent - onExpandedChanged: root.handleReasoningToggle(expanded) + onExpandedChanged: root.handleReasoningToggle(expanded, this) } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml index c8a733c..70bff51 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -22,7 +22,6 @@ Item { readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0) readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).some(s => current.pendingToolCalls.includes(s)) - clip: true implicitHeight: { const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin; const collapsed = layout.implicitHeight; @@ -36,6 +35,8 @@ Item { return collapsed; } + onCurrentChanged: expanded = false + Behavior on implicitHeight { Anim { type: Anim.DefaultEffects @@ -147,19 +148,30 @@ Item { property bool open: false property Item rootParent: root.rootParent - property int openHeight: toolsBg.rootParent.height - Tokens.padding.medium * 2 + property int maxHeight: rootParent.height - Tokens.padding.medium * 2 + property int openHeight: Math.min(toolsContent.item?.implicitHeight, maxHeight) property int openWidth: toolsBg.rootParent.width - Tokens.padding.medium * 2 + property real openHeightProg: openHeight / maxHeight + property int collapsedY: 0 implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2 + z: open || dialogTransition.running ? 2 : 0 Layout.fillWidth: true function reparentWrapper(): void { const newParent = open ? toolsBg.rootParent : toolsBg; - const pos = toolsWrapper.mapToItem(newParent, 0, 0); + const c = toolsWrapper.parent.mapToItem(newParent, toolsWrapper.x + toolsWrapper.width / 2, toolsWrapper.y + toolsWrapper.height / 2); + toolsBg.collapsedY = toolsWrapper.y; toolsWrapper.parent = newParent; - toolsWrapper.x = pos.x; - toolsWrapper.y = pos.y; - console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height); + toolsWrapper.x = c.x - toolsWrapper.width / 2; + toolsWrapper.y = c.y - toolsWrapper.height / 2; + } + + onOpenChanged: { + if (open) + toolsBg.rootParent.interactive = false; + else + toolsBg.rootParent.interactive = true; } BlobGroup { @@ -189,6 +201,8 @@ Item { width: toolsBg.width height: toolBox.implicitHeight + rotation: parent === toolsBg.rootParent ? 180 : 0 + z: 1 states: State { name: "open" @@ -200,11 +214,11 @@ Item { toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased toolsBackground.topRightRadius: Tokens.rounding.largeIncreased toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased - // toolsContent.opacity: 1 + toolsContent.opacity: 1 toolsWrapper.height: toolsBg.openHeight toolsWrapper.width: toolsBg.openWidth toolsWrapper.x: (toolsBg.rootParent.width - toolsBg.openWidth) / 2 - toolsWrapper.y: (toolsBg.rootParent.height - toolsBg.openHeight) / 2 + toolsWrapper.y: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium elevation.opacity: 1 toolBox.opacity: 0 } @@ -274,7 +288,6 @@ Item { StateLayer { onClicked: { - console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height); toolsBg.open = true; } } @@ -287,7 +300,10 @@ Item { anchors.margins: Tokens.padding.medium Repeater { - model: root.current.pendingToolCalls ?? [] + id: repeater + + model: root.current.pendingToolCalls?.slice(0, 4) ?? [] + delegate: RowLayout { id: toolText @@ -309,10 +325,10 @@ Item { return String(seg.arguments); } - CustomText { + MaterialIcon { color: Colors.palette.m3onPrimaryContainer - text: String(toolText.index + 1) - font.pointSize: Tokens.font.size.large + text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet" + fill: 1 } CustomText { @@ -331,12 +347,134 @@ Item { } } } + + CustomText { + color: Colors.palette.m3onPrimaryContainer + text: qsTr("And %1 more").arg(root.current.pendingToolCalls.slice(4).length) + visible: repeater.count < root.current.pendingToolCalls.length + } + } + } + + Loader { + id: toolsContent + + active: opacity > 0 + anchors.fill: parent + asynchronous: false + opacity: 0 + + sourceComponent: MouseArea { + onWheel: event => event.accepted = true + preventStealing: true + implicitHeight: innerLayout.contentHeight + innerLayout.anchors.margins * 2 + + VerticalFadeListView { + id: innerLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.small + cacheBuffer: height * 10 + fadeAmount: 0.1 + fadeEnabled: interactive + spacing: 0 + clip: true + interactive: contentHeight > toolsBg.maxHeight + + model: root.current.pendingToolCalls ?? [] + + delegate: Item { + id: toolCall + + required property LlmSegment modelData + property bool expanded: false + + implicitWidth: CustomListView.view.width + implicitHeight: collapsedRow.implicitHeight + collapsedRow.anchors.margins + (expanded ? expandedTool.implicitHeight + expandedTool.anchors.margins + expandedTool.anchors.topMargin : collapsedRow.anchors.margins) + + function toolTarget(seg: LlmSegment): string { + var a = null; + try { + a = JSON.parse(seg.arguments); + } catch (e) {} + if (a && typeof a === "object") { + const v = a.url ?? a.path; + if (v !== undefined && v !== null) + return String(v); + } + return String(seg.arguments); + } + + Behavior on implicitHeight { + Anim {} + } + + RowLayout { + id: collapsedRow + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Tokens.padding.medium + + IconButton { + id: toolExpandBtn + + font.pointSize: Tokens.font.size.large + icon: "keyboard_arrow_down" + inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline + rotation: toolCall.expanded ? 180 : 0 + type: IconButton.Text + + Behavior on rotation { + Anim {} + } + + onClicked: toolCall.expanded = !toolCall.expanded + } + + CustomText { + id: preview + + Layout.fillWidth: true + + text: toolCall.modelData.name + } + } + + CustomClippingRect { + id: expandedTool + + anchors.top: collapsedRow.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Tokens.padding.medium + anchors.topMargin: Tokens.spacing.medium + + implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2 + radius: Tokens.rounding.medium + color: Colors.palette.m3surfaceContainerLow + + CustomText { + id: fullToolCall + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + + text: toolCall.toolTarget(toolCall.modelData) + wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere + } + } + } + } } } } } ButtonRow { + id: confirmationRow Layout.alignment: Qt.AlignRight spacing: Tokens.spacing.medium @@ -366,7 +504,7 @@ Item { } } - CustomRect { + CustomClippingRect { id: expandedRect anchors.left: parent.left diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 422b784..5c302ae 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -1,4 +1,4 @@ -find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus CanvasPainter) +find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick QuickPrivate 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) diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index 5d1cd78..bd4f511 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -1,11 +1,13 @@ qml_module(ZShell-components URI ZShell.Components - SOURCES - lazylistview.hpp lazylistview.cpp + SOURCES + lazylistview.hpp lazylistview.cpp + chatlistview.hpp chatlistview.cpp wavyline.hpp wavyline.cpp buttonrow.hpp buttonrow.cpp carouselview.hpp carouselview.cpp wheelinverter.hpp - LIBRARIES - Qt::Quick + LIBRARIES + Qt::Quick + Qt::QuickPrivate ) diff --git a/Plugins/ZShell/Components/chatlistview.cpp b/Plugins/ZShell/Components/chatlistview.cpp new file mode 100644 index 0000000..56ff397 --- /dev/null +++ b/Plugins/ZShell/Components/chatlistview.cpp @@ -0,0 +1,1239 @@ +#include "chatlistview.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int ASYNC_BATCH_CREATE = 2; +constexpr int ASYNC_BATCH_DESTROY = 4; +constexpr int RESTORE_QUIET_MS = 150; +constexpr int MEASURE_BATCH = 4; +constexpr int MEASURE_SETTLE_MS = 100; +constexpr int MEASURE_DEBOUNCE_MS = 150; + +} // namespace + +namespace ZShell::components { + +ChatListViewAttached::ChatListViewAttached(QObject* parent) : QObject(parent) {} + +qreal ChatListViewAttached::preferredHeight() const { + return m_preferredHeight; +} + +void ChatListViewAttached::setPreferredHeight(qreal height) { + if (qFuzzyCompare(m_preferredHeight + 1.0, height + 1.0)) return; + m_preferredHeight = height; + emit preferredHeightChanged(); +} + +qreal ChatListViewAttached::visibleHeight() const { + return m_visibleHeight; +} + +void ChatListViewAttached::setVisibleHeight(qreal height) { + if (qFuzzyCompare(m_visibleHeight + 1.0, height + 1.0)) return; + m_visibleHeight = height; + emit visibleHeightChanged(); +} + +bool ChatListViewAttached::ready() const { + return m_ready; +} + +void ChatListViewAttached::setReady(bool ready) { + if (m_ready == ready) return; + m_ready = ready; + emit readyChanged(); +} + +bool ChatListViewAttached::adding() const { + return m_adding; +} + +void ChatListViewAttached::setAdding(bool adding) { + if (m_adding == adding) return; + m_adding = adding; + emit addingChanged(); +} + +bool ChatListViewAttached::removing() const { + return m_removing; +} + +void ChatListViewAttached::setRemoving(bool removing) { + if (m_removing == removing) return; + m_removing = removing; + emit removingChanged(); +} + +bool ChatListViewAttached::trackViewport() const { + return m_trackViewport; +} + +void ChatListViewAttached::setTrackViewport(bool track) { + if (m_trackViewport == track) return; + m_trackViewport = track; + emit trackViewportChanged(); +} + +ChatListView::ChatListView(QQuickItem* parent) : QQuickItem(parent) { + m_measureTimer = new QTimer(this); + m_measureTimer->setSingleShot(true); + m_measureTimer->setInterval(MEASURE_DEBOUNCE_MS); + connect(m_measureTimer, &QTimer::timeout, this, [this] { + buildMeasureQueue(); + polish(); + }); +} + +ChatListViewAttached* ChatListView::qmlAttachedProperties(QObject* object) { + return new ChatListViewAttached(object); +} + +ChatListView::~ChatListView() { + for (auto& entry : m_delegates) + destroyDelegate(entry); + for (auto& entry : m_dyingDelegates) + destroyDelegate(entry); +} + +QAbstractItemModel* ChatListView::model() const { + return m_model; +} + +void ChatListView::setModel(QAbstractItemModel* model) { + if (m_model == model) return; + + if (m_model) disconnectModel(); + + m_model = model; + + if (m_model) connectModel(); + + resetContent(); + emit modelChanged(); +} + +QQmlComponent* ChatListView::delegate() const { + return m_delegate; +} + +void ChatListView::setDelegate(QQmlComponent* delegate) { + if (m_delegate == delegate) return; + + m_delegate = delegate; + resetContent(); + emit delegateChanged(); +} + +qreal ChatListView::spacing() const { + return m_spacing; +} + +void ChatListView::setSpacing(qreal spacing) { + if (qFuzzyCompare(m_spacing, spacing)) return; + m_spacing = spacing; + emit spacingChanged(); + polish(); +} + +qreal ChatListView::contentHeight() const { + return m_contentHeight; +} + +qreal ChatListView::layoutHeight() const { + return m_layoutHeight; +} + +qreal ChatListView::contentY() const { + return m_contentY; +} + +void ChatListView::setContentY(qreal contentY) { + if (qFuzzyCompare(m_contentY, contentY)) return; + m_contentY = contentY; + emit contentYChanged(); + polish(); +} + +QRectF ChatListView::viewport() const { + return m_viewport; +} + +void ChatListView::setViewport(const QRectF& viewport) { + if (m_viewport == viewport) return; + m_viewport = viewport; + emit viewportChanged(); + if (m_useCustomViewport) polish(); +} + +bool ChatListView::useCustomViewport() const { + return m_useCustomViewport; +} + +void ChatListView::setUseCustomViewport(bool use) { + if (m_useCustomViewport == use) return; + m_useCustomViewport = use; + emit useCustomViewportChanged(); + polish(); +} + +qreal ChatListView::cacheBuffer() const { + return m_cacheBuffer; +} + +void ChatListView::setCacheBuffer(qreal buffer) { + if (qFuzzyCompare(m_cacheBuffer, buffer)) return; + m_cacheBuffer = buffer; + emit cacheBufferChanged(); + polish(); +} + +qreal ChatListView::estimatedHeight() const { + return m_estimatedHeight; +} + +void ChatListView::setEstimatedHeight(qreal height) { + if (qFuzzyCompare(m_estimatedHeight, height)) return; + m_estimatedHeight = height; + emit estimatedHeightChanged(); + polish(); +} + +bool ChatListView::asynchronous() const { + return m_asynchronous; +} + +void ChatListView::setAsynchronous(bool async) { + if (m_asynchronous == async) return; + m_asynchronous = async; + emit asynchronousChanged(); + polish(); +} + +int ChatListView::removeDuration() const { + return m_removeDuration; +} + +void ChatListView::setRemoveDuration(int duration) { + if (m_removeDuration == duration) return; + m_removeDuration = duration; + emit removeDurationChanged(); +} + +int ChatListView::readyDelay() const { + return m_readyDelay; +} + +void ChatListView::setReadyDelay(int delay) { + if (m_readyDelay == delay) return; + m_readyDelay = delay; + emit readyDelayChanged(); +} + +int ChatListView::yAnimDuration() const { + return m_yAnimDuration; +} + +void ChatListView::setYAnimDuration(int duration) { + if (m_yAnimDuration == duration) return; + m_yAnimDuration = duration; + emit yAnimDurationChanged(); +} + +int ChatListView::count() const { + return m_model ? m_model->rowCount() : 0; +} + +bool ChatListView::event(QEvent* e) { + // qDebug() << "ChatListView::event() runs"; + + return QQuickItem::event(e); +} + +void ChatListView::componentComplete() { + QQuickItem::componentComplete(); + m_componentComplete = true; + resetContent(); +} + +void ChatListView::geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + + if (!m_componentComplete) return; + + if (!qFuzzyCompare(newGeometry.width(), oldGeometry.width())) { + for (auto& entry : m_delegates) { + if (entry.item) entry.item->setWidth(newGeometry.width()); + } + m_measureTimer->start(); + } + + polish(); +} + +void ChatListView::updatePolish() { + if (!m_componentComplete || !m_model || !m_delegate) return; + + QList pendingIndices; + for (auto it = m_delegates.constBegin(); it != m_delegates.constEnd(); + ++it) { + if (it->pendingInsert && it->item) pendingIndices.append(it.key()); + } + + for (int pendingIdx : pendingIndices) { + auto& entry = m_delegates[pendingIdx]; + + if (m_readyDelay > 0) { + if (!entry.readyDelayStarted) { + entry.readyDelayStarted = true; + auto* item = entry.item; + QTimer::singleShot(m_readyDelay, this, [this, item] { + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) return; + const int idx = indexIt.value(); + auto it = m_delegates.find(idx); + if (it == m_delegates.end() || it->item != item || + !it->pendingInsert) + return; + + it->pendingInsert = false; + it->readyDelayStarted = false; + + if (idx >= 0 && idx < static_cast(m_layout.size())) { + qreal visualY = 0; + bool hasVisItem = false; + for (int i = 0; i < static_cast(m_layout.size()); + ++i) { + qreal h = NAN; + auto dit = m_delegates.find(i); + if (dit != m_delegates.end() && dit->item) + h = delegateVisibleHeight(dit->item); + else + h = m_layout[i].heightKnown + ? m_layout[i].height + : effectiveEstimatedHeight(); + if (h > 0) { + if (hasVisItem) visualY += m_spacing; + hasVisItem = true; + } + if (i == idx) break; + if (h > 0) visualY += h; + } + item->setY(visualY); + } + + item->setVisible(true); + auto* att = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (att) { + att->setAdding(false); + att->setReady(true); + } + + m_animateY = true; + polish(); + }); + } + continue; + } + + entry.pendingInsert = false; + entry.item->setVisible(true); + auto* att = qobject_cast( + qmlAttachedPropertiesObject(entry.item, false)); + if (att) { + att->setAdding(false); + att->setReady(true); + } + } + + relayout(); + syncDelegates(); + pumpMeasureQueue(); + + for (auto& record : m_layout) + record.isNew = false; + + const bool animate = m_animateY; + m_animateY = false; + const int layoutSize = static_cast(m_layout.size()); + const qint64 now = m_clock.elapsed(); + bool wrote = false; + for (auto& entry : m_delegates) { + if (!entry.item || entry.pendingRemoval || entry.pendingInsert) + continue; + + const int idx = entry.modelIndex; + if (idx < 0 || idx >= layoutSize) continue; + + if (m_layout[idx].heightKnown && qFuzzyIsNull(m_layout[idx].height)) + continue; + + const qreal target = m_layout[idx].targetY; + const qreal cur = entry.item->y(); + + const bool inFlight = entry.hasPropY && qAbs(cur - entry.propY) > 0.5; + const bool cooling = entry.hasPropY && + (now - entry.lastPropTime) < m_yAnimDuration + 50; + + const bool needWrite = inFlight ? qAbs(target - entry.propY) > 0.25 + : qAbs(cur - target) > 0.25; + if (!needWrite) continue; + + if (animate || inFlight || cooling) { + entry.item->setProperty("y", target); + entry.propY = target; + entry.hasPropY = true; + entry.lastPropTime = now; + } else { + entry.item->setY(target); + entry.hasPropY = false; + } + wrote = true; + } + + applyAnchor(); +} + +void ChatListView::relayout() { + qreal y = 0; + bool hasLayoutItem = false; + for (auto& record : m_layout) { + const qreal layoutH = record.heightKnown ? record.height + : effectiveEstimatedHeight(); + if (layoutH > 0) { + if (hasLayoutItem) y += m_spacing; + hasLayoutItem = true; + record.targetY = y; + y += layoutH; + } else { + record.targetY = y; + } + } + + if (!qFuzzyCompare(m_layoutHeight + 1.0, y + 1.0)) { + m_layoutHeight = y; + emit layoutHeightChanged(); + } + + qreal visY = 0; + bool hasVisItem = false; + for (int i = 0; i < static_cast(m_layout.size()); ++i) { + qreal h = NAN; + auto dit = m_delegates.find(i); + if (dit != m_delegates.end() && dit->item) + h = delegateVisibleHeight(dit->item); + else + h = m_layout[i].heightKnown ? m_layout[i].height + : effectiveEstimatedHeight(); + if (h > 0) { + if (hasVisItem) visY += m_spacing; + hasVisItem = true; + visY += h; + } + } + + for (const auto& dying : std::as_const(m_dyingDelegates)) { + if (!dying.item) continue; + const qreal dyingH = delegateVisibleHeight(dying.item); + if (dyingH > 0) visY = std::max(visY, dying.item->y() + dyingH); + } + + if (!qFuzzyCompare(m_contentHeight + 1.0, visY + 1.0)) { + m_contentHeight = visY; + emit contentHeightChanged(); + } +} + +QRectF ChatListView::effectiveViewport() const { + QRectF vp; + if (m_useCustomViewport) + vp = m_viewport; + else + vp = QRectF(0, m_contentY, width(), height()); + + if (!m_useCustomViewport && m_layoutHeight > 0) { + const qreal top = std::min(vp.y(), m_layoutHeight); + const qreal bottom = std::max(vp.y() + vp.height(), 0.0); + if (bottom > top) vp = QRectF(vp.x(), top, vp.width(), bottom - top); + } + + vp.adjust(0, -m_cacheBuffer, 0, m_cacheBuffer); + + if (m_layoutHeight > 0) { + const qreal top = std::max(vp.y(), 0.0); + const qreal bottom = std::min(vp.y() + vp.height(), m_layoutHeight); + if (top < bottom) + vp = QRectF(vp.x(), top, vp.width(), bottom - top); + else + return {}; + } + + return vp; +} + +std::pair ChatListView::computeVisibleRange() const { + if (m_layout.isEmpty()) return {-1, -1}; + + const auto vp = effectiveViewport(); + if (vp.isEmpty()) return {-1, -1}; + + const qreal vpTop = vp.y(); + const qreal vpBottom = vp.y() + vp.height(); + + int lo = 0; + int hi = static_cast(m_layout.size()) - 1; + int first = static_cast(m_layout.size()); + + while (lo <= hi) { + const int mid = lo + (hi - lo) / 2; + const auto& record = m_layout[mid]; + const qreal itemBottom = + record.targetY + + (record.heightKnown ? record.height : effectiveEstimatedHeight()); + + if (itemBottom >= vpTop) { + first = mid; + hi = mid - 1; + } else { + lo = mid + 1; + } + } + + if (first >= static_cast(m_layout.size())) return {-1, -1}; + + int last = first; + for (int i = first; i < static_cast(m_layout.size()); ++i) { + if (m_layout[i].targetY > vpBottom) break; + last = i; + } + + return {first, last}; +} + +void ChatListView::syncDelegates() { + const auto [first, last] = computeVisibleRange(); + + QSet visibleIndices; + if (first >= 0) { + for (int i = first; i <= last; ++i) + visibleIndices.insert(i); + } + + const auto vp = effectiveViewport(); + QList toRemove; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + if (it->measureOnly) continue; + if (visibleIndices.contains(it.key())) continue; + if (!it->item || vp.isEmpty()) { + toRemove.append(it.key()); + continue; + } + const qreal itemTop = it->item->y(); + const qreal itemBottom = itemTop + delegateVisibleHeight(it->item); + if (itemBottom < vp.top() || itemTop > vp.bottom()) + toRemove.append(it.key()); + } + + const int destroyBudget = m_asynchronous + ? ASYNC_BATCH_DESTROY + : static_cast(toRemove.size()); + QVector removedEntries; + removedEntries.reserve( + std::min(destroyBudget, static_cast(toRemove.size()))); + int destroyed = 0; + for (int idx : toRemove) { + if (destroyed >= destroyBudget) break; + auto entry = m_delegates.take(idx); + if (entry.item) m_itemToIndex.remove(entry.item); + removedEntries.append(std::move(entry)); + ++destroyed; + } + for (auto& entry : removedEntries) { + destroyDelegate(entry); + } + + QList toCreate; + if (first >= 0) { + for (int i = first; i <= last; ++i) { + auto existing = m_delegates.find(i); + if (existing != m_delegates.end()) { + if (existing->measureOnly) { + existing->measureOnly = false; + polish(); + } + continue; + } + toCreate.append(i); + } + } + + const int createBudget = m_asynchronous ? ASYNC_BATCH_CREATE + : static_cast(toCreate.size()); + int created = 0; + for (int i : toCreate) { + if (created >= createBudget) break; + + auto entry = createDelegate(i); + if (entry.item) { + entry.pendingInsert = true; + entry.item->setY(m_layout[i].targetY); + m_itemToIndex.insert(entry.item, i); + m_delegates.insert(i, std::move(entry)); + ++created; + } + } + + if (created > 0 || + (m_asynchronous && (destroyed < static_cast(toRemove.size()) || + created < static_cast(toCreate.size())))) + polish(); +} + +bool ChatListView::isStaleRecord(const ItemRecord& record, qreal viewWidth) { + return record.heightKnown && record.measuredWidth >= 0 && + qAbs(record.measuredWidth - viewWidth) > 1.0; +} + +void ChatListView::buildMeasureQueue() { + m_measureQueue.clear(); + if (m_layout.isEmpty()) return; + + qreal center = 0; + const auto vp = effectiveViewport(); + if (!vp.isEmpty()) + center = vp.center().y(); + else if (m_layoutHeight > 0) + center = m_layoutHeight / 2; + + for (int i = 0; i < static_cast(m_layout.size()); ++i) { + if (!isStaleRecord(m_layout[i], width())) continue; + if (m_delegates.contains(i)) continue; + m_measureQueue.append(i); + } + + const qreal c = center; + std::stable_sort( + m_measureQueue.begin(), m_measureQueue.end(), [this, c](int a, int b) { + return qAbs(m_layout[a].targetY - c) < + qAbs(m_layout[b].targetY - c); + }); +} + +void ChatListView::pumpMeasureQueue() { + const qint64 now = m_clock.elapsed(); + + QList done; + for (auto it = m_delegates.constBegin(); it != m_delegates.constEnd(); + ++it) { + if (!it->measureOnly || !it->item) continue; + if (!isDelegateReady(it->item)) continue; + if (now - it->lastHeightChange < MEASURE_SETTLE_MS) continue; + done.append(it.key()); + } + for (int idx : done) { + auto entry = m_delegates.take(idx); + if (entry.item) m_itemToIndex.remove(entry.item); + destroyDelegate(entry); + } + + int live = 0; + for (auto it = m_delegates.constBegin(); it != m_delegates.constEnd(); ++it) + if (it->measureOnly) ++live; + + bool created = false; + while (live < MEASURE_BATCH && !m_measureQueue.isEmpty()) { + const int idx = m_measureQueue.takeFirst(); + if (idx < 0 || idx >= static_cast(m_layout.size())) continue; + if (!isStaleRecord(m_layout[idx], width())) continue; + if (m_delegates.contains(idx)) continue; + + auto entry = createDelegate(idx); + if (!entry.item) continue; + + entry.measureOnly = true; + entry.pendingInsert = true; + entry.lastHeightChange = now; + entry.item->setY(m_layout[idx].targetY); + + m_itemToIndex.insert(entry.item, idx); + m_delegates.insert(idx, std::move(entry)); + ++live; + created = true; + } + + if (created || live > 0 || !m_measureQueue.isEmpty()) polish(); +} + +ChatListView::DelegateEntry ChatListView::createDelegate(int modelIndex) { + DelegateEntry entry; + entry.modelIndex = modelIndex; + + if (!m_delegate || !m_model) return entry; + + const auto roleNames = m_model->roleNames(); + + auto* compContext = m_delegate->creationContext(); + if (!compContext) compContext = qmlContext(this); + if (!compContext) return entry; + + auto* obj = m_delegate->beginCreate(compContext); + entry.item = qobject_cast(obj); + + if (!entry.item) { + if (obj) m_delegate->completeCreate(); + delete obj; + return entry; + } + + const auto index = m_model->index(modelIndex, 0); + QVariantMap initialProps; + bool hasModelData = false; + + for (auto it = roleNames.constBegin(); it != roleNames.constEnd(); ++it) { + const auto name = QString::fromUtf8(it.value()); + initialProps.insert(name, m_model->data(index, it.key())); + if (name == QStringLiteral("modelData")) hasModelData = true; + } + initialProps.insert(QStringLiteral("index"), modelIndex); + + if (!hasModelData) { + const auto role = roleNames.isEmpty() ? Qt::DisplayRole + : roleNames.constBegin().key(); + initialProps.insert( + QStringLiteral("modelData"), m_model->data(index, role)); + } + + m_delegate->setInitialProperties(entry.item, initialProps); + + entry.item->setParentItem(this); + entry.item->setWidth(width()); + + if (modelIndex < static_cast(m_layout.size()) && + m_layout[modelIndex].isNew) { + auto* addingAttached = qobject_cast( + qmlAttachedPropertiesObject(entry.item, true)); + if (addingAttached) addingAttached->setAdding(true); + } + + m_delegate->completeCreate(); + + entry.item->setVisible(false); + + auto onHeightChanged = [this, item = entry.item] { + if (!isDelegateReady(item)) return; + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) return; + const int idx = indexIt.value(); + auto delegateIt = m_delegates.find(idx); + if (delegateIt == m_delegates.end() || delegateIt->item != item) return; + const qreal h = delegateHeight(item); + if (idx < static_cast(m_layout.size()) && + !qFuzzyCompare(m_layout[idx].height + 1.0, h + 1.0)) { + const qreal oldH = m_layout[idx].height; + const bool wasKnown = m_layout[idx].heightKnown; + m_layout[idx].height = h; + m_layout[idx].heightKnown = true; + m_layout[idx].measuredWidth = width(); + if (wasKnown) untrackHeight(oldH); + trackHeight(h); + if (delegateIt->measureOnly) + delegateIt->lastHeightChange = m_clock.elapsed(); + + if (!wasKnown) { + m_animateY = true; + } + + if (wasKnown) { + auto* att = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (att && att->trackViewport() && !m_anchorItem && + m_layout[idx].targetY < m_viewport.y()) { + bool suppressed = false; + if (m_restoreActive && idx == m_restoreIndex) { + if (m_clock.elapsed() < m_restoreQuietUntil) { + suppressed = true; + m_restoreQuietUntil = + m_clock.elapsed() + RESTORE_QUIET_MS; + } else { + m_restoreActive = false; + } + } + if (!suppressed) emit viewportAdjustNeeded(h - oldH); + } + } + + if (!m_relayoutPending) { + m_relayoutPending = true; + QTimer::singleShot(0, this, [this] { + m_relayoutPending = false; + relayout(); + polish(); + }); + } + } + }; + + connect( + entry.item, &QQuickItem::implicitHeightChanged, this, onHeightChanged); + + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(entry.item, false)); + if (attached) { + connect( + attached, + &ChatListViewAttached::preferredHeightChanged, + this, + onHeightChanged); + connect( + attached, + &ChatListViewAttached::visibleHeightChanged, + this, + [this] { polish(); }); + connect( + attached, + &ChatListViewAttached::readyChanged, + this, + [this, item = entry.item] { + auto indexIt = m_itemToIndex.find(item); + if (indexIt == m_itemToIndex.end()) return; + const int idx = indexIt.value(); + if (idx >= static_cast(m_layout.size())) return; + auto* att = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (!att || !att->ready()) return; + + const qreal h = delegateHeight(item); + const qreal oldLayoutH = m_layout[idx].heightKnown + ? m_layout[idx].height + : effectiveEstimatedHeight(); + const bool wasKnown = m_layout[idx].heightKnown; + if (wasKnown) untrackHeight(m_layout[idx].height); + m_layout[idx].height = h; + m_layout[idx].heightKnown = true; + m_layout[idx].measuredWidth = width(); + trackHeight(h); + auto delegateIt = m_delegates.find(idx); + if (delegateIt != m_delegates.end() && delegateIt->measureOnly) + delegateIt->lastHeightChange = m_clock.elapsed(); + + if (!wasKnown) { + m_animateY = true; + } + + if (att->trackViewport() && !m_anchorItem && + !qFuzzyCompare(h + 1.0, oldLayoutH + 1.0) && + m_layout[idx].targetY < m_viewport.y()) { + emit viewportAdjustNeeded(h - oldLayoutH); + } + + polish(); + }); + } + + return entry; +} + +void ChatListView::destroyDelegate(DelegateEntry& entry) { + if (entry.item) { + entry.item->setParentItem(nullptr); + entry.item->setVisible(false); + entry.item->deleteLater(); + entry.item = nullptr; + } +} + +void ChatListView::updateDelegateData(DelegateEntry& entry) { + if (!m_model || !entry.item) return; + + const auto roleNames = m_model->roleNames(); + const auto index = m_model->index(entry.modelIndex, 0); + bool hasModelData = false; + + for (auto it = roleNames.constBegin(); it != roleNames.constEnd(); ++it) { + const auto name = QString::fromUtf8(it.value()); + entry.item->setProperty( + name.toUtf8().constData(), m_model->data(index, it.key())); + if (name == QStringLiteral("modelData")) hasModelData = true; + } + + entry.item->setProperty("index", entry.modelIndex); + + if (!hasModelData) { + const auto role = roleNames.isEmpty() ? Qt::DisplayRole + : roleNames.constBegin().key(); + entry.item->setProperty("modelData", m_model->data(index, role)); + } +} + +void ChatListView::connectModel() { + if (!m_model) return; + + m_modelConnections = { + connect( + m_model, + &QAbstractItemModel::rowsInserted, + this, + &ChatListView::onRowsInserted), + connect( + m_model, + &QAbstractItemModel::rowsAboutToBeRemoved, + this, + &ChatListView::onRowsAboutToBeRemoved), + connect( + m_model, + &QAbstractItemModel::rowsRemoved, + this, + &ChatListView::onRowsRemoved), + connect( + m_model, + &QAbstractItemModel::dataChanged, + this, + &ChatListView::onDataChanged), + connect( + m_model, + &QAbstractItemModel::modelReset, + this, + &ChatListView::onModelReset), + connect( + m_model, + &QAbstractItemModel::layoutChanged, + this, + [this] { + for (auto& entry : m_delegates) + updateDelegateData(entry); + polish(); + }), + connect( + m_model, + &QObject::destroyed, + this, + [this] { + m_model = nullptr; + resetContent(); + emit modelChanged(); + }), + }; +} + +void ChatListView::disconnectModel() { + for (auto& conn : m_modelConnections) + disconnect(conn); + m_modelConnections.clear(); +} + +void ChatListView::resetContent() { + for (auto& entry : m_delegates) + destroyDelegate(entry); + m_delegates.clear(); + m_itemToIndex.clear(); + + for (auto& entry : m_dyingDelegates) + destroyDelegate(entry); + m_dyingDelegates.clear(); + + m_knownHeightSum = 0; + m_knownHeightCount = 0; + + m_layout.clear(); + m_measureQueue.clear(); + if (m_model && m_componentComplete) { + const int rows = m_model->rowCount(); + m_layout.resize(rows); + for (int i = 0; i < rows; ++i) { + m_layout[i].height = 0; + m_layout[i].heightKnown = false; + m_layout[i].measuredWidth = -1; + } + emit countChanged(); + } + + polish(); +} + +void ChatListView::onRowsInserted( + const QModelIndex& parent, int first, int last) { + if (parent.isValid()) return; + + const int insertCount = last - first + 1; + m_layout.insert(first, insertCount, ItemRecord{0, 0, false, true}); + for (int& i : m_measureQueue) + if (i >= first) i += insertCount; + + QHash shifted; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + int newIdx = it.key() >= first ? it.key() + insertCount : it.key(); + auto entry = std::move(it.value()); + entry.modelIndex = newIdx; + if (entry.item) { + entry.item->setProperty("index", newIdx); + m_itemToIndex[entry.item] = newIdx; + } + shifted.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(shifted); + + emit countChanged(); + m_animateY = true; + polish(); +} + +void ChatListView::onRowsAboutToBeRemoved( + const QModelIndex& parent, int first, int last) { + if (parent.isValid()) return; + + for (int i = first; i <= last; ++i) { + if (!m_delegates.contains(i)) continue; + + auto entry = m_delegates.take(i); + if (entry.item) m_itemToIndex.remove(entry.item); + entry.pendingRemoval = true; + + if (entry.pendingInsert) { + destroyDelegate(entry); + continue; + } + + if (m_removeDuration > 0 && entry.item) { + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(entry.item, false)); + if (attached) attached->setRemoving(true); + + auto* item = entry.item; + QTimer::singleShot(m_removeDuration, this, [this, item] { + for (auto it = m_dyingDelegates.begin(); + it != m_dyingDelegates.end(); + ++it) { + if (it->item == item) { + destroyDelegate(*it); + m_dyingDelegates.erase(it); + return; + } + } + }); + m_dyingDelegates.append(std::move(entry)); + } else { + destroyDelegate(entry); + } + } +} + +void ChatListView::onRowsRemoved( + const QModelIndex& parent, int first, int last) { + if (parent.isValid()) return; + + const int removeCount = last - first + 1; + + for (int i = first; i <= last; ++i) { + if (m_layout[i].heightKnown) untrackHeight(m_layout[i].height); + } + + QVector queue; + queue.reserve(m_measureQueue.size()); + for (int i : m_measureQueue) { + if (i >= first && i <= last) continue; + if (i > last) i -= removeCount; + queue.append(i); + } + m_measureQueue = std::move(queue); + + m_layout.remove(first, removeCount); + + QHash shifted; + for (auto it = m_delegates.begin(); it != m_delegates.end(); ++it) { + int newIdx = it.key() > last ? it.key() - removeCount : it.key(); + auto entry = std::move(it.value()); + entry.modelIndex = newIdx; + if (entry.item) { + entry.item->setProperty("index", newIdx); + m_itemToIndex[entry.item] = newIdx; + } + shifted.insert(newIdx, std::move(entry)); + } + m_delegates = std::move(shifted); + + emit countChanged(); + m_animateY = true; + polish(); +} + +void ChatListView::onDataChanged( + const QModelIndex& topLeft, + const QModelIndex& bottomRight, + const QList& roles) { + Q_UNUSED(roles) + + if (topLeft.parent().isValid()) return; + + for (int i = topLeft.row(); i <= bottomRight.row(); ++i) { + if (m_delegates.contains(i)) updateDelegateData(m_delegates[i]); + } +} + +void ChatListView::onModelReset() { + if (!m_model) { + resetContent(); + return; + } + + const int newRows = m_model->rowCount(); + const int oldRows = static_cast(m_layout.size()); + + if (newRows == oldRows) { + const auto roleNames = m_model->roleNames(); + const auto role = roleNames.isEmpty() ? Qt::DisplayRole + : roleNames.constBegin().key(); + bool changed = false; + + for (auto it = m_delegates.constBegin(); it != m_delegates.constEnd(); + ++it) { + if (!it->item || it.key() >= newRows) { + changed = true; + break; + } + const auto newData = + m_model->data(m_model->index(it.key(), 0), role); + const auto oldData = it->item->property("modelData"); + if (newData != oldData) { + changed = true; + break; + } + } + + if (!changed) { + for (auto& entry : m_delegates) + updateDelegateData(entry); + return; + } + } + + resetContent(); + if (!qFuzzyIsNull(m_viewport.y())) { + emit viewportAdjustNeeded(-m_viewport.y()); + } +} + +qreal ChatListView::effectiveEstimatedHeight() const { + if (m_estimatedHeight >= 0) return m_estimatedHeight; + if (m_knownHeightCount > 0) return m_knownHeightSum / m_knownHeightCount; + return 40; +} + +void ChatListView::trackHeight(qreal height) { + m_knownHeightSum += height; + ++m_knownHeightCount; +} + +void ChatListView::untrackHeight(qreal height) { + m_knownHeightSum -= height; + --m_knownHeightCount; +} + +qreal ChatListView::delegateHeight(QQuickItem* item) { + if (!item) return 0; + + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (attached && attached->preferredHeight() >= 0) + return attached->preferredHeight(); + + return item->implicitHeight(); +} + +qreal ChatListView::delegateVisibleHeight(QQuickItem* item) { + if (!item) return 0; + + auto* attached = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + if (attached) { + if (attached->visibleHeight() >= 0) return attached->visibleHeight(); + if (attached->preferredHeight() >= 0) + return attached->preferredHeight(); + } + + return item->implicitHeight(); +} + +bool ChatListView::isDelegateReady(QQuickItem* item) { + if (!item) return false; + auto* att = qobject_cast( + qmlAttachedPropertiesObject(item, false)); + return !att || att->ready(); +} + +void ChatListView::anchorItem(QQuickItem* item, Edge edge) { + if (!item) return; + + if (m_anchorItem == item) { + if (m_anchorEdge != edge) { + m_anchorEdge = edge; + applyAnchor(); + } + return; + } + + m_anchorItem = item; + m_anchorEdge = edge; + connect(item, &QObject::destroyed, this, [this, item] { + if (m_anchorItem == item) clearAnchor(); + }); + applyAnchor(); +} + +void ChatListView::clearAnchor() { + if (!m_anchorItem) return; + for (auto* it = m_anchorItem.data(); it; it = it->parentItem()) { + auto delegateIt = m_itemToIndex.find(it); + if (delegateIt != m_itemToIndex.end()) { + m_restoreIndex = delegateIt.value(); + break; + } + } + m_anchorItem = nullptr; +} + +void ChatListView::requestRestore(qreal targetY) { + m_restoreActive = true; + m_restoreQuietUntil = m_clock.elapsed() + RESTORE_QUIET_MS; + const qreal target = + qBound(0.0, targetY, qMax(0.0, m_layoutHeight - m_viewport.height())); + if (!qFuzzyCompare(target + 1.0, m_viewport.y() + 1.0)) { + emit viewportRestoreRequested(target); + } +} + +void ChatListView::applyAnchor() { + const auto item = m_anchorItem; + if (!item || m_viewport.isNull()) return; + + const qreal a = item->mapToItem(this, 0, 0).y(); + const qreal b = item->mapToItem(this, 0, item->height()).y(); + const qreal near = std::min(a, b); + const qreal far = std::max(a, b); + + const qreal vpTop = m_viewport.y(); + const qreal vpBottom = m_viewport.y() + m_viewport.height(); + + qreal next = -1; + if (m_anchorEdge == TopEdge) { + if (far > vpBottom) next = far - m_viewport.height(); + } else { + if (near < vpTop) next = near; + } + if (next < 0) return; + + next = qBound(0.0, next, qMax(0.0, m_layoutHeight - m_viewport.height())); + if (!qFuzzyCompare(next + 1.0, vpTop + 1.0)) { + emit viewportAdjustNeeded(next - vpTop); + } +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/chatlistview.hpp b/Plugins/ZShell/Components/chatlistview.hpp new file mode 100644 index 0000000..91dc1f2 --- /dev/null +++ b/Plugins/ZShell/Components/chatlistview.hpp @@ -0,0 +1,299 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::components { + +class ChatListViewAttached : public QObject { + Q_OBJECT + + Q_PROPERTY( + qreal preferredHeight READ preferredHeight WRITE setPreferredHeight + NOTIFY preferredHeightChanged) + Q_PROPERTY( + qreal visibleHeight READ visibleHeight WRITE setVisibleHeight NOTIFY + visibleHeightChanged) + Q_PROPERTY(bool ready READ ready NOTIFY readyChanged) + Q_PROPERTY(bool adding READ adding NOTIFY addingChanged) + Q_PROPERTY(bool removing READ removing NOTIFY removingChanged) + Q_PROPERTY( + bool trackViewport READ trackViewport WRITE setTrackViewport NOTIFY + trackViewportChanged) + + public: + explicit ChatListViewAttached(QObject* parent = nullptr); + + [[nodiscard]] qreal preferredHeight() const; + void setPreferredHeight(qreal height); + + [[nodiscard]] qreal visibleHeight() const; + void setVisibleHeight(qreal height); + + [[nodiscard]] bool ready() const; + void setReady(bool ready); + + [[nodiscard]] bool adding() const; + void setAdding(bool adding); + + [[nodiscard]] bool removing() const; + void setRemoving(bool removing); + + [[nodiscard]] bool trackViewport() const; + void setTrackViewport(bool track); + + signals: + void preferredHeightChanged(); + void visibleHeightChanged(); + void readyChanged(); + void addingChanged(); + void removingChanged(); + void trackViewportChanged(); + + private: + qreal m_preferredHeight = -1; + qreal m_visibleHeight = -1; + bool m_ready = false; + bool m_adding = false; + bool m_removing = false; + bool m_trackViewport = false; +}; + +class ChatListView : public QQuickItem { + Q_OBJECT + QML_ELEMENT + QML_ATTACHED(ChatListViewAttached) + + public: + Q_PROPERTY( + QAbstractItemModel* model READ model WRITE setModel NOTIFY modelChanged) + Q_PROPERTY( + QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY + delegateChanged) + + Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged) + Q_PROPERTY( + qreal contentHeight READ contentHeight NOTIFY contentHeightChanged) + Q_PROPERTY(qreal layoutHeight READ layoutHeight NOTIFY layoutHeightChanged) + Q_PROPERTY( + qreal contentY READ contentY WRITE setContentY NOTIFY contentYChanged) + + Q_PROPERTY( + QRectF viewport READ viewport WRITE setViewport NOTIFY viewportChanged) + Q_PROPERTY( + bool useCustomViewport READ useCustomViewport WRITE setUseCustomViewport + NOTIFY useCustomViewportChanged) + Q_PROPERTY( + qreal cacheBuffer READ cacheBuffer WRITE setCacheBuffer NOTIFY + cacheBufferChanged) + + Q_PROPERTY( + qreal estimatedHeight READ estimatedHeight WRITE setEstimatedHeight + NOTIFY estimatedHeightChanged) + + Q_PROPERTY( + bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY + asynchronousChanged) + + Q_PROPERTY( + int removeDuration READ removeDuration WRITE setRemoveDuration NOTIFY + removeDurationChanged) + Q_PROPERTY( + int readyDelay READ readyDelay WRITE setReadyDelay NOTIFY + readyDelayChanged) + Q_PROPERTY( + int yAnimDuration READ yAnimDuration WRITE setYAnimDuration NOTIFY + yAnimDurationChanged) + + Q_PROPERTY(int count READ count NOTIFY countChanged) + + enum Edge { + TopEdge, + BottomEdge, + }; + Q_ENUM(Edge) + + Q_INVOKABLE void anchorItem(QQuickItem* item, Edge edge); + Q_INVOKABLE void clearAnchor(); + Q_INVOKABLE void requestRestore(qreal targetY); + + explicit ChatListView(QQuickItem* parent = nullptr); + ~ChatListView() override; + + static ChatListViewAttached* qmlAttachedProperties(QObject* object); + + [[nodiscard]] QAbstractItemModel* model() const; + void setModel(QAbstractItemModel* model); + + [[nodiscard]] QQmlComponent* delegate() const; + void setDelegate(QQmlComponent* delegate); + + [[nodiscard]] qreal spacing() const; + void setSpacing(qreal spacing); + + [[nodiscard]] qreal contentHeight() const; + [[nodiscard]] qreal layoutHeight() const; + + [[nodiscard]] qreal contentY() const; + void setContentY(qreal contentY); + + [[nodiscard]] QRectF viewport() const; + void setViewport(const QRectF& viewport); + + [[nodiscard]] bool useCustomViewport() const; + void setUseCustomViewport(bool use); + + [[nodiscard]] qreal cacheBuffer() const; + void setCacheBuffer(qreal buffer); + + [[nodiscard]] qreal estimatedHeight() const; + void setEstimatedHeight(qreal height); + + [[nodiscard]] bool asynchronous() const; + void setAsynchronous(bool async); + + [[nodiscard]] int removeDuration() const; + void setRemoveDuration(int duration); + + [[nodiscard]] int readyDelay() const; + void setReadyDelay(int delay); + + [[nodiscard]] int yAnimDuration() const; + void setYAnimDuration(int duration); + + [[nodiscard]] int count() const; + + signals: + void modelChanged(); + void delegateChanged(); + void spacingChanged(); + void contentHeightChanged(); + void layoutHeightChanged(); + void contentYChanged(); + void viewportChanged(); + void useCustomViewportChanged(); + void cacheBufferChanged(); + void estimatedHeightChanged(); + void asynchronousChanged(); + void removeDurationChanged(); + void readyDelayChanged(); + void yAnimDurationChanged(); + void countChanged(); + void viewportAdjustNeeded(qreal delta); + void viewportRestoreRequested(qreal targetY); + + protected: + void componentComplete() override; + void geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) override; + void updatePolish() override; + bool event(QEvent* e) override; + + private: + struct ItemRecord { + qreal targetY = 0; + qreal height = 0; + bool heightKnown = false; + bool isNew = false; + qreal measuredWidth = -1; + }; + + struct DelegateEntry { + int modelIndex = -1; + QQuickItem* item = nullptr; + bool pendingRemoval = false; + bool pendingInsert = false; + bool readyDelayStarted = false; + bool measureOnly = false; + qreal propY = 0; + bool hasPropY = false; + qint64 lastPropTime = 0; + qint64 lastHeightChange = 0; + }; + + void relayout(); + [[nodiscard]] std::pair computeVisibleRange() const; + [[nodiscard]] QRectF effectiveViewport() const; + [[nodiscard]] qreal effectiveEstimatedHeight() const; + [[nodiscard]] static bool isStaleRecord(const ItemRecord& record, + qreal viewWidth); + [[nodiscard]] static qreal delegateHeight(QQuickItem* item); + [[nodiscard]] static qreal delegateVisibleHeight(QQuickItem* item); + [[nodiscard]] static bool isDelegateReady(QQuickItem* item); + void trackHeight(qreal height); + void untrackHeight(qreal height); + + void syncDelegates(); + void pumpMeasureQueue(); + void buildMeasureQueue(); + DelegateEntry createDelegate(int modelIndex); + void destroyDelegate(DelegateEntry& entry); + void updateDelegateData(DelegateEntry& entry); + + void connectModel(); + void disconnectModel(); + void resetContent(); + void onRowsInserted(const QModelIndex& parent, int first, int last); + void onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last); + void onRowsRemoved(const QModelIndex& parent, int first, int last); + void onDataChanged( + const QModelIndex& topLeft, + const QModelIndex& bottomRight, + const QList& roles); + void onModelReset(); + + void applyAnchor(); + + QAbstractItemModel* m_model = nullptr; + QQmlComponent* m_delegate = nullptr; + + qreal m_spacing = 0; + qreal m_contentHeight = 0; + qreal m_layoutHeight = 0; + qreal m_contentY = 0; + + QRectF m_viewport; + bool m_useCustomViewport = false; + qreal m_cacheBuffer = 0; + + qreal m_estimatedHeight = -1; + qreal m_knownHeightSum = 0; + int m_knownHeightCount = 0; + bool m_asynchronous = false; + + int m_removeDuration = 300; + int m_readyDelay = 0; + int m_yAnimDuration = 300; + QElapsedTimer m_clock; + + QVector m_layout; + QHash m_delegates; + QHash m_itemToIndex; + QVector m_dyingDelegates; + + QVector m_measureQueue; + QTimer* m_measureTimer = nullptr; + + bool m_componentComplete = false; + bool m_relayoutPending = false; + bool m_animateY = false; + + QPointer m_anchorItem; + Edge m_anchorEdge = TopEdge; + int m_restoreIndex = -1; + bool m_restoreActive = false; + qint64 m_restoreQuietUntil = 0; + + QList m_modelConnections; +}; + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Config/llm.hpp b/Plugins/ZShell/Config/llm.hpp index 4b5e68b..b04df7d 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, sandbox, true) CFG_PROPERTY(bool, enabled, true) CONFIG_SUBOBJECT(LlmAppearance, appearance) @@ -32,4 +33,4 @@ class Llm : public ConfigObject { : ConfigObject(parent), m_appearance(new LlmAppearance(this)) {} }; -} // namespace ZShell::config +} diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt index 97697aa..6433ee0 100644 --- a/Plugins/ZShell/Llm/CMakeLists.txt +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -285,6 +285,7 @@ file(WRITE "${LM_FONTS_HPP}" "${_lm_header}") qml_module(ZShell-llm URI ZShell.Llm SOURCES + attachment.hpp attachment.cpp chat.hpp chat.cpp chatstore.hpp chatstore.cpp codehighlighter.hpp codehighlighter.cpp @@ -298,6 +299,7 @@ qml_module(ZShell-llm messagemodel.hpp messagemodel.cpp segment.hpp segment.cpp session.hpp session.cpp + shelltool.hpp shelltool.cpp tool.hpp tool.cpp webfetchtool.hpp webfetchtool.cpp LIBRARIES diff --git a/Plugins/ZShell/Llm/attachment.cpp b/Plugins/ZShell/Llm/attachment.cpp new file mode 100644 index 0000000..fca66d9 --- /dev/null +++ b/Plugins/ZShell/Llm/attachment.cpp @@ -0,0 +1,19 @@ +#include "attachment.hpp" + +namespace ZShell::llm { + +Attachment::Attachment( + const QString& name, + const QString& path, + Kind kind, + const QString& mimeType, + qint64 size, + QObject* parent) + : QObject(parent) + , m_name(name) + , m_path(path) + , m_kind(kind) + , m_mimeType(mimeType) + , m_size(size) {} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/attachment.hpp b/Plugins/ZShell/Llm/attachment.hpp new file mode 100644 index 0000000..e3c02cd --- /dev/null +++ b/Plugins/ZShell/Llm/attachment.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include + +namespace ZShell::llm { + +class Attachment : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Attachments are created by ChatSession.addAttachments") + + Q_PROPERTY(QString name READ name CONSTANT) + Q_PROPERTY(QString path READ path CONSTANT) + Q_PROPERTY(Kind kind READ kind CONSTANT) + Q_PROPERTY(bool isImage READ isImage CONSTANT) + Q_PROPERTY(QString mimeType READ mimeType CONSTANT) + Q_PROPERTY(qint64 size READ size CONSTANT) + + public: + enum class Kind : int { Image = 0, File = 1 }; + Q_ENUM(Kind) + + Attachment( + const QString& name, + const QString& path, + Kind kind, + const QString& mimeType, + qint64 size, + QObject* parent = nullptr); + + [[nodiscard]] QString name() const { return m_name; } + [[nodiscard]] QString path() const { return m_path; } + [[nodiscard]] Kind kind() const { return m_kind; } + [[nodiscard]] bool isImage() const { return m_kind == Kind::Image; } + [[nodiscard]] QString mimeType() const { return m_mimeType; } + [[nodiscard]] qint64 size() const { return m_size; } + + private: + QString m_name; + QString m_path; + Kind m_kind; + QString m_mimeType; + qint64 m_size = 0; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index a716d21..9e28bed 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -4,6 +4,7 @@ #include "filetool.hpp" #include "llm.hpp" #include "llmclient.hpp" +#include "shelltool.hpp" #include "webfetchtool.hpp" #include @@ -19,12 +20,14 @@ Chat::Chat(QObject* parent) m_store->setLlmClient(m_client); m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); m_client->tools()->registerTool(new FileReadTool(m_client->tools())); + m_client->tools()->registerTool(new ShellTool(m_client, m_client->tools())); const auto* llm = config::Config::instance()->llm(); m_client->setEndpoint(llm->endpoint()); m_client->setModel(llm->model()); m_client->setTemperature(llm->temperature()); m_client->setToolsEnabled(llm->tools()); + m_client->setSandboxEnabled(llm->sandbox()); connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { m_client->setEndpoint(llm->endpoint()); @@ -38,11 +41,16 @@ Chat::Chat(QObject* parent) connect(llm, &config::Llm::toolsChanged, this, [this, llm]() { m_client->setToolsEnabled(llm->tools()); }); + connect(llm, &config::Llm::sandboxChanged, this, [this, llm]() { + m_client->setSandboxEnabled(llm->sandbox()); + Q_EMIT sandboxEnabledChanged(); + }); connect(m_client, &LlmClient::busyChanged, this, [this]() { - if (m_client->busy() && !m_lastError.isEmpty()) { - m_lastError.clear(); - Q_EMIT lastErrorChanged(); + if (m_client->busy() && (m_hasError || !m_errorMessage.isEmpty())) { + m_hasError = false; + m_errorMessage.clear(); + Q_EMIT errorChanged(); } Q_EMIT busyChanged(); }); @@ -73,10 +81,16 @@ Chat::Chat(QObject* parent) &LlmClient::errorOccurred, this, [this](const QString& message) { - m_lastError = message; - Q_EMIT lastErrorChanged(); + m_hasError = !message.isEmpty(); + m_errorMessage = message; + Q_EMIT errorChanged(); Q_EMIT errorOccurred(message); }); + connect( + m_client, + &LlmClient::serverErrorChanged, + this, + &Chat::serverErrorChanged); connect( m_store, &ChatStore::sessionRemoved, @@ -134,11 +148,24 @@ void Chat::setToolsEnabled(bool value) { config->llm()->set_tools(value); } +bool Chat::sandboxEnabled() const { + return m_client->sandboxEnabled(); +} + +void Chat::setSandboxEnabled(bool value) { + m_client->setSandboxEnabled(value); + if (auto* config = config::Config::instance()) + config->llm()->set_sandbox(value); +} + +QString Chat::serverError() const { + return m_client->serverError(); +} + QString Chat::streamingChatId() const { return m_client->streamingChatId(); } - Chat* Chat::s_instance = nullptr; Chat* Chat::create(QQmlEngine*, QJSEngine*) { @@ -151,9 +178,10 @@ void Chat::stop() { } void Chat::dismissError() { - if (m_lastError.isEmpty()) return; - m_lastError.clear(); - Q_EMIT lastErrorChanged(); + if (!m_hasError && m_errorMessage.isEmpty()) return; + m_hasError = false; + m_errorMessage.clear(); + Q_EMIT errorChanged(); } void Chat::refreshModels() { @@ -161,7 +189,7 @@ void Chat::refreshModels() { } void Chat::refreshFromServer() { - m_client->refreshFromServer(); + m_client->refreshAllStates(); } void Chat::selectModel(const QString& id) { diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp index 0d4919b..89a37c1 100644 --- a/Plugins/ZShell/Llm/chat.hpp +++ b/Plugins/ZShell/Llm/chat.hpp @@ -29,7 +29,12 @@ class Chat : public QObject { Q_PROPERTY( bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY toolsEnabledChanged) - Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) + Q_PROPERTY( + bool sandboxEnabled READ sandboxEnabled WRITE setSandboxEnabled NOTIFY + sandboxEnabledChanged) + Q_PROPERTY(bool error READ error NOTIFY errorChanged) + Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged) + Q_PROPERTY(QString serverError READ serverError NOTIFY serverErrorChanged) Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) Q_PROPERTY( QString streamingChatId READ streamingChatId NOTIFY @@ -45,7 +50,11 @@ class Chat : public QObject { [[nodiscard]] int contextSize() const; [[nodiscard]] bool toolsEnabled() const; void setToolsEnabled(bool value); - [[nodiscard]] QString lastError() const { return m_lastError; } + [[nodiscard]] bool sandboxEnabled() const; + void setSandboxEnabled(bool value); + [[nodiscard]] bool error() const { return m_hasError; } + [[nodiscard]] QString errorMessage() const { return m_errorMessage; } + [[nodiscard]] QString serverError() const; [[nodiscard]] ChatStore* chats() const { return m_store; } [[nodiscard]] QString streamingChatId() const; @@ -64,14 +73,17 @@ class Chat : public QObject { void availableModelsChanged(); void contextSizeChanged(); void toolsEnabledChanged(); + void sandboxEnabledChanged(); void errorOccurred(const QString& message); - void lastErrorChanged(); + void errorChanged(); + void serverErrorChanged(); void streamingChatIdChanged(); private: ChatStore* m_store = nullptr; LlmClient* m_client = nullptr; - QString m_lastError; + bool m_hasError = false; + QString m_errorMessage; static Chat* s_instance; }; diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp index 3d9056f..86ef46b 100644 --- a/Plugins/ZShell/Llm/chatstore.cpp +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -65,10 +65,19 @@ struct GenerationRow { QVector segments; }; +struct AttachmentRow { + QString name; + QString path; + int kind = 1; + QString mime; + qint64 size = 0; +}; + struct MessageRow { bool user = false; qint64 timestamp = 0; QVector generations; + QVector attachments; }; } // namespace @@ -160,6 +169,20 @@ void ChatStore::openDb() { " timestamp INTEGER NOT NULL\n" ")")); } + { + QSqlQuery query(db); + query.exec(QStringLiteral( + "CREATE TABLE IF NOT EXISTS attachments (\n" + " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" + " message_id INTEGER NOT NULL REFERENCES messages " + "(id) ON DELETE CASCADE,\n" + " name TEXT NOT NULL,\n" + " path TEXT NOT NULL,\n" + " kind INTEGER NOT NULL DEFAULT 1,\n" + " mime TEXT NOT NULL DEFAULT '',\n" + " size INTEGER NOT NULL DEFAULT 0\n" + ")")); + } { QSqlQuery query(db); query.exec(QStringLiteral( @@ -171,6 +194,9 @@ void ChatStore::openDb() { query.exec(QStringLiteral( "CREATE INDEX IF NOT EXISTS idx_segments_generation " "ON segments (generation_id)")); + query.exec(QStringLiteral( + "CREATE INDEX IF NOT EXISTS idx_attachments_message " + "ON attachments (message_id)")); } } @@ -233,6 +259,7 @@ void ChatStore::removeSession(ChatSession* session) { query.bindValue(":id", session->id()); query.exec(); } + QDir(ChatSession::sandboxDirFor(session->id())).removeRecursively(); m_sessions.removeOne(session); session->deleteLater(); notify(before); @@ -330,6 +357,11 @@ bool ChatStore::saveSession(ChatSession* session) { "timestamp) VALUES (:gid, :type, :text, :name, " ":tool_call_id, :arguments, :result, :status, :elapsed_ms, " ":timestamp)"); + QSqlQuery attachmentInsert(handle); + ok = ok && attachmentInsert.prepare( + "INSERT INTO attachments (message_id, name, path, kind, " + "mime, size) VALUES (:mid, :name, :path, :kind, :mime, " + ":size)"); const auto* model = session->messagesModel(); for (int row = model->rowCount() - 1; ok && row >= 0; --row) { const auto* message = model->at(row); @@ -390,6 +422,26 @@ bool ChatStore::saveSession(ChatSession* session) { } } } + if (ok) + for (const auto* attachment : message->attachments()) { + attachmentInsert.bindValue(":mid", messageId); + attachmentInsert.bindValue( + ":name", sqlText(attachment->name())); + attachmentInsert.bindValue( + ":path", sqlText(attachment->path())); + attachmentInsert.bindValue( + ":kind", static_cast(attachment->kind())); + attachmentInsert.bindValue( + ":mime", sqlText(attachment->mimeType())); + attachmentInsert.bindValue(":size", attachment->size()); + if (!attachmentInsert.exec()) { + ok = false; + qWarning() << "ChatStore: saveSession" << id + << "attachment insert failed:" + << attachmentInsert.lastError().text(); + break; + } + } } } if (!ok || !handle.commit()) { @@ -497,6 +549,34 @@ void ChatStore::loadMessagesInto(ChatSession* session) { << messageId << ":" << generationQuery.lastError().text(); } + QSqlQuery attachmentQuery(db); + attachmentQuery.prepare( + "SELECT name, path, kind, mime, size FROM " + "attachments WHERE message_id = :mid " + "ORDER BY rowid"); + attachmentQuery.bindValue(":mid", messageId); + if (attachmentQuery.exec()) { + while (attachmentQuery.next()) { + AttachmentRow attachment; + attachment.name = + attachmentQuery.value(0).toString(); + attachment.path = + attachmentQuery.value(1).toString(); + attachment.kind = + attachmentQuery.value(2).toInt(); + attachment.mime = + attachmentQuery.value(3).toString(); + attachment.size = + attachmentQuery.value(4).toLongLong(); + message.attachments.append(attachment); + } + } else { + qWarning() + << "ChatStore: failed to load attachments " + "for message" + << messageId << ":" + << attachmentQuery.lastError().text(); + } rows.append(message); } } @@ -523,6 +603,22 @@ void ChatStore::loadMessagesInto(ChatSession* session) { row.user ? ChatMessage::Role::User : ChatMessage::Role::Assistant, row.timestamp); + if (!row.attachments.isEmpty()) { + QList attachments; + for (const AttachmentRow& attachmentRow : + row.attachments) { + attachments.append(new Attachment( + attachmentRow.name, + attachmentRow.path, + attachmentRow.kind == 0 + ? Attachment::Kind::Image + : Attachment::Kind::File, + attachmentRow.mime, + attachmentRow.size, + message)); + } + message->setAttachments(attachments); + } int activeIndex = 0; for (int i = 0; i < row.generations.size(); ++i) { const GenerationRow& generationRow = diff --git a/Plugins/ZShell/Llm/codehighlighter.cpp b/Plugins/ZShell/Llm/codehighlighter.cpp index 86e2f54..7351920 100644 --- a/Plugins/ZShell/Llm/codehighlighter.cpp +++ b/Plugins/ZShell/Llm/codehighlighter.cpp @@ -198,7 +198,7 @@ const char* CodeHighlighter::roleName(uint8_t role) { QString CodeHighlighter::resolveId(const QString& language) { const QString tag = language.trimmed().toLower(); const QString alias = aliases().value(tag); - return alias.isEmpty() ? tag : alias; // unknown tags = grammar id + return alias.isEmpty() ? tag : alias; } QString CodeHighlighter::cacheKey(const QString& id, const QString& code) { @@ -223,8 +223,8 @@ void CodeHighlighter::storeSpans( const QString& language, const QVariantList& spans) const { if (spans.isEmpty() || code.isEmpty()) return; - static constexpr int kMaxEntries = 32; - static constexpr int kMaxBytes = 1024 * 1024; + static constexpr int kMaxEntries = 64; + static constexpr int kMaxBytes = 2 * 1024 * 1024; const QString key = cacheKey(resolveId(language), code); const int bytes = static_cast(code.toUtf8().size()); QMutexLocker locker(&m_cacheMutex); @@ -369,7 +369,7 @@ QVariantList CodeHighlighter::doHighlight( if (c < 0x80) cu[b + 1] += 1; else if (c < 0xC0) - ; // continuation byte + ; else if (c < 0xF0) cu[b + 1] += 1; else diff --git a/Plugins/ZShell/Llm/generation.cpp b/Plugins/ZShell/Llm/generation.cpp index 2822aa3..407db6b 100644 --- a/Plugins/ZShell/Llm/generation.cpp +++ b/Plugins/ZShell/Llm/generation.cpp @@ -176,6 +176,21 @@ void ChatGeneration::appendReasoning(const QString& piece) { openReasoningSegment()->appendText(piece); } +void ChatGeneration::setError(const QString& value) { + const bool hasError = !value.isEmpty(); + if (m_hasError == hasError && m_errorMessage == value) return; + m_hasError = hasError; + m_errorMessage = value; + Q_EMIT errorChanged(); +} + +void ChatGeneration::dismissError() { + if (!m_hasError && m_errorMessage.isEmpty()) return; + m_hasError = false; + m_errorMessage.clear(); + Q_EMIT errorChanged(); +} + void ChatGeneration::setStreaming(bool value) { if (m_streaming == value) return; m_streaming = value; diff --git a/Plugins/ZShell/Llm/generation.hpp b/Plugins/ZShell/Llm/generation.hpp index e10c595..ac1a85c 100644 --- a/Plugins/ZShell/Llm/generation.hpp +++ b/Plugins/ZShell/Llm/generation.hpp @@ -21,6 +21,8 @@ class ChatGeneration : public QObject { Q_PROPERTY( QString content READ content WRITE setContent NOTIFY contentChanged) Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) + Q_PROPERTY(bool error READ error NOTIFY errorChanged) + Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged) Q_PROPERTY( qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) @@ -48,6 +50,8 @@ class ChatGeneration : public QObject { [[nodiscard]] QString content() const; [[nodiscard]] QString reasoning() const; + [[nodiscard]] bool error() const { return m_hasError; } + [[nodiscard]] QString errorMessage() const { return m_errorMessage; } [[nodiscard]] bool reasoningActive() const; [[nodiscard]] qint64 reasoningElapsedMs() const; [[nodiscard]] qint64 contentElapsedMs() const; @@ -65,11 +69,13 @@ class ChatGeneration : public QObject { Q_INVOKABLE void approveTools(); Q_INVOKABLE void denyTools(); + Q_INVOKABLE void dismissError(); void setContent(const QString& value); void appendContent(const QString& piece); void appendReasoning(const QString& piece); void setStreaming(bool value); + void setError(const QString& value); [[nodiscard]] LlmSegment* openContentSegment(); [[nodiscard]] LlmSegment* openReasoningSegment(); @@ -81,6 +87,7 @@ class ChatGeneration : public QObject { Q_SIGNALS: void contentChanged(); void reasoningChanged(); + void errorChanged(); void reasoningActiveChanged(); void elapsedMsChanged(); void streamingChanged(); @@ -98,6 +105,8 @@ class ChatGeneration : public QObject { bool m_streaming = false; bool m_approvalPending = false; qint64 m_timestamp; + bool m_hasError = false; + QString m_errorMessage; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp index 6650aaf..1dc831a 100644 --- a/Plugins/ZShell/Llm/llmclient.cpp +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -1,5 +1,6 @@ #include "llmclient.hpp" +#include "attachment.hpp" #include "generation.hpp" #include "message.hpp" #include "messagemodel.hpp" @@ -7,6 +8,7 @@ #include "session.hpp" #include +#include #include #include #include @@ -132,26 +134,30 @@ QString LlmClient::completionsPath( return base + subpath; } +QString LlmClient::jsonErrorMessage(const QByteArray& body) { + const QJsonDocument doc = QJsonDocument::fromJson(body); + if (!doc.isObject()) return QString(); + const QJsonValue errorValue = doc.object().value("error"); + if (errorValue.isObject()) + return errorValue.toObject().value("message").toString(); + if (errorValue.isString()) return errorValue.toString(); + return QString(); +} + QString LlmClient::serverErrorMessage( const QByteArray& body, const QString& fallback) { - const QJsonDocument doc = QJsonDocument::fromJson(body); - if (doc.isObject()) { - const QJsonObject obj = doc.object(); - if (obj.contains("error")) { - const QJsonValue errorValue = obj["error"]; - if (errorValue.isObject()) { - const QString message = - errorValue.toObject()["message"].toString(); - if (!message.isEmpty()) return message; - } else if (!errorValue.toString().isEmpty()) { - return errorValue.toString(); - } - } - } + const QString message = jsonErrorMessage(body); + if (!message.isEmpty()) return message; return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed") : fallback; } +void LlmClient::setServerError(const QString& value) { + if (m_serverError == value) return; + m_serverError = value; + Q_EMIT serverErrorChanged(); +} + void LlmClient::setBusy(bool value) { if (m_busy == value) return; m_busy = value; @@ -175,14 +181,17 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) { m_refreshTimer.setParent(this); m_refreshTimer.setInterval(kRefreshIntervalMs); connect( - &m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshFromServer); + &m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshAllStates); m_refreshTimer.start(); } LlmClient::~LlmClient() { - if (m_reply) m_reply->abort(); + if (m_reply) { + QNetworkReply* reply = m_reply; + m_reply = nullptr; + reply->abort(); + } m_tools->cancelAll(); - endStream(); } void LlmClient::setEndpoint(const QString& value) { @@ -190,7 +199,7 @@ void LlmClient::setEndpoint(const QString& value) { m_endpoint = value; Q_EMIT endpointChanged(); m_refreshTimer.start(); - refreshFromServer(); + refreshAllStates(); } void LlmClient::setModel(const QString& value) { @@ -204,9 +213,16 @@ void LlmClient::setTemperature(double value) { m_temperature = value; } +void LlmClient::setSandboxEnabled(bool value) { + m_sandboxEnabled = value; +} + +QString LlmClient::activeWorkspace() const { + return m_active ? m_active->workspacePath() : QString(); +} + void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { if (m_busy || !session || !target) return; - refreshModels(); m_contextRetryUsed = false; m_active = session; m_streaming = target; @@ -242,6 +258,7 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { probeContextSize([this, session, target]() { if (m_active != session || m_streaming != target) return; + refreshModels(); sendRound(); }); } @@ -253,6 +270,7 @@ void LlmClient::sendRound() { m_callBuilders.clear(); m_callResults.clear(); m_roundDone = false; + m_streamDone = false; m_toolPhase = false; m_pendingCalls = 0; m_buffer.clear(); @@ -308,6 +326,8 @@ void LlmClient::sendRound() { } } + appendSandboxPreamble(messages); + QJsonObject body; QJsonObject streamOptions; streamOptions[QStringLiteral("include_usage")] = true; @@ -323,15 +343,24 @@ void LlmClient::sendRound() { request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setRawHeader("Accept", "text/event-stream"); - m_reply = m_manager.post(request, QJsonDocument(body).toJson()); + QNetworkReply* reply = + m_manager.post(request, QJsonDocument(body).toJson()); + if (!reply) { + fail(QStringLiteral("Failed to start request to LLM server")); + return; + } + m_reply = reply; - connect(m_reply, &QNetworkReply::readyRead, this, [this]() { - if (m_reply) m_buffer.append(m_reply->readAll()); + connect(reply, &QNetworkReply::readyRead, this, [this, reply]() { + if (m_reply != reply) return; + m_buffer.append(reply->readAll()); drainBuffer(); }); - connect(m_reply, &QNetworkReply::finished, this, [this]() { - QNetworkReply* reply = m_reply; - if (!reply) return; + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + if (m_reply != reply) { + reply->deleteLater(); + return; + } m_reply = nullptr; const QNetworkReply::NetworkError error = reply->error(); @@ -343,25 +372,37 @@ void LlmClient::sendRound() { drainBuffer(); if (!m_streaming) return; - if (error == QNetworkReply::NoError) - roundFinished(); - else if (error == QNetworkReply::OperationCanceledError) - finishTurn(); - else { - const QString message = - serverErrorMessage(responseBody, errorString); - const int reported = contextSizeFromErrorMessage(message); - if (reported > 0 && reported != m_contextSize && - !m_contextRetryUsed) { - qWarning() << "LlmClient: prompt overflow, server reports" - << reported << "tokens; retrying round"; - setContextSize(reported); - m_contextRetryUsed = true; - sendRound(); + if (error == QNetworkReply::NoError) { + if (m_streamDone) { + roundFinished(); return; } - fail(message); + const QByteArray tail = m_buffer; + m_buffer.clear(); + const QString serverMessage = jsonErrorMessage(tail.simplified()); + fail( + serverMessage.isEmpty() + ? QStringLiteral( + "LLM server closed the stream before " + "completing the response") + : serverMessage); + return; } + if (error == QNetworkReply::OperationCanceledError) { + finishTurn(); + return; + } + const QString message = serverErrorMessage(responseBody, errorString); + const int reported = contextSizeFromErrorMessage(message); + if (reported > 0 && reported != m_contextSize && !m_contextRetryUsed) { + qWarning() << "LlmClient: prompt overflow, server reports" + << reported << "tokens; retrying round"; + setContextSize(reported); + m_contextRetryUsed = true; + sendRound(); + return; + } + fail(message); }); } @@ -373,12 +414,51 @@ QJsonArray LlmClient::buildContextMessages( const auto* message = model->at(row); const auto* generation = message->activeGeneration(); if (!generation) continue; + const bool hasAnyContent = !generation->content().isEmpty() || + !generation->reasoning().isEmpty() || + generation->toolCallCount() > 0; + if (message->role() != ChatMessage::Role::User && !hasAnyContent) + continue; ContextUnit unit; if (message->role() == ChatMessage::Role::User) { QJsonObject user; user[QStringLiteral("role")] = QStringLiteral("user"); - user[QStringLiteral("content")] = generation->content(); + const QString text = generation->content(); + const QList attachments = message->attachments(); + if (attachments.isEmpty()) { + user[QStringLiteral("content")] = text; + } else { + QJsonArray parts; + if (!text.isEmpty()) { + QJsonObject textPart; + textPart[QStringLiteral("type")] = QStringLiteral("text"); + textPart[QStringLiteral("text")] = text; + parts.append(textPart); + } + for (const auto* attachment : attachments) { + if (attachment->kind() != Attachment::Kind::Image) continue; + QFile file(attachment->path()); + if (!file.open(QIODevice::ReadOnly)) continue; + const QByteArray base64 = file.readAll().toBase64(); + file.close(); + if (base64.isEmpty()) continue; + QJsonObject imageUrl; + imageUrl[QStringLiteral("url")] = + QStringLiteral("data:%1;base64,%2") + .arg(attachment->mimeType()) + .arg(QString::fromLatin1(base64)); + QJsonObject imagePart; + imagePart[QStringLiteral("type")] = + QLatin1String("image_url"); + imagePart[QStringLiteral("image_url")] = imageUrl; + parts.append(imagePart); + } + if (parts.isEmpty()) + user[QStringLiteral("content")] = text; + else + user[QStringLiteral("content")] = parts; + } unit.messages.append(user); } else { QList toolSegments; @@ -455,6 +535,48 @@ QJsonArray LlmClient::buildContextMessages( return messages; } +void LlmClient::appendSandboxPreamble(QJsonArray& messages) { + if (!m_sandboxEnabled) return; + ChatSession* active = m_active; + if (!active) return; + const QString workspace = active->workspacePath(); + if (workspace.isEmpty()) return; + const auto* model = active->messagesModel(); + QStringList files; + QSet seenPaths; + for (int row = 0; row < model->rowCount(); ++row) { + const auto* message = model->at(row); + if (message->role() != ChatMessage::Role::User) continue; + for (const auto* attachment : message->attachments()) { + if (attachment->kind() != Attachment::Kind::File) continue; + const QString relativePath = + attachment->path().mid(workspace.size() + 1); + if (seenPaths.contains(relativePath)) continue; + seenPaths.insert(relativePath); + files.append(QStringLiteral("- %1 (available as %2)") + .arg(attachment->name(), relativePath)); + } + } + if (files.isEmpty()) return; + QJsonObject preamble; + preamble[QStringLiteral("role")] = QStringLiteral("system"); + preamble[QStringLiteral("content")] = + QStringLiteral( + "The user attached the following files to this " + "conversation. They are available in your sandbox " + "directory at %1:\n%2\nruncommand executes shell " + "commands inside a bubblewrap sandbox: the rest of the " + "filesystem is read-only, there is no network access, " + "and only the sandbox directory is writable. Use it to " + "inspect, transform, or extract attached files (for " + "example to extract archives or search across many " + "files). Files you create stay inside the sandbox " + "directory. Use the webfetch tool for anything that " + "needs the network.") + .arg(workspace, files.join(QStringLiteral("\n"))); + messages.prepend(preamble); +} + void LlmClient::applyToolCallDelta(const QJsonObject& call) { ChatGeneration* streaming = m_streaming; if (!streaming) return; @@ -688,10 +810,12 @@ void LlmClient::stop() { finishTurn(); return; } - if (m_reply) - m_reply->abort(); - else - endStream(); + if (m_reply) { + QNetworkReply* reply = m_reply; + m_reply = nullptr; + reply->abort(); + } + endStream(); } void LlmClient::endStream() { @@ -700,6 +824,11 @@ void LlmClient::endStream() { ChatSession* session = m_active; m_streaming = nullptr; m_active = nullptr; + if (m_reply) { + QNetworkReply* reply = m_reply; + m_reply = nullptr; + reply->abort(); + } generation->setStreaming(false); m_approvalPending = false; generation->setApprovalPending(false); @@ -708,7 +837,7 @@ void LlmClient::endStream() { segment->status() == LlmSegment::Status::Pending) segment->finishTool(QStringLiteral("Cancelled"), false); if (generation->content().isEmpty() && generation->reasoning().isEmpty() && - generation->toolCallCount() == 0) { + generation->toolCallCount() == 0 && !generation->error()) { if (auto* message = qobject_cast(generation->parent())) { if (message->generationCount() <= 1) { if (session) session->removeMessage(message); @@ -730,7 +859,7 @@ void LlmClient::finishTurn() { m_pendingClear.clear(); } if (session) session->persist(); - refreshFromServer(); + refreshAllStates(); } void LlmClient::clearOnFinish(ChatSession* session) { @@ -747,6 +876,7 @@ void LlmClient::sessionRemoved(ChatSession* session) { void LlmClient::fail(const QString& message) { qWarning() << "LlmClient:" << message; + if (ChatGeneration* streaming = m_streaming) streaming->setError(message); finishTurn(); Q_EMIT errorOccurred(message); } @@ -766,6 +896,7 @@ void LlmClient::handleLine(const QByteArray& line) { const QByteArray data = line.mid(5).trimmed(); if (data == "[DONE]") { + m_streamDone = true; roundFinished(); return; } @@ -776,8 +907,12 @@ void LlmClient::handleLine(const QByteArray& line) { updateTokenUsage(obj); if (obj.contains("error")) { - const QJsonObject error = obj["error"].toObject(); - const QString message = error["message"].toString(); + const QJsonValue errorValue = obj["error"]; + QString message; + if (errorValue.isObject()) + message = errorValue.toObject()["message"].toString(); + else if (errorValue.isString()) + message = errorValue.toString(); fail( message.isEmpty() ? QStringLiteral("LLM server returned an error") : message); @@ -805,25 +940,36 @@ void LlmClient::handleLine(const QByteArray& line) { } } -void LlmClient::refreshFromServer() { - if (m_endpoint.trimmed().isEmpty()) return; - probeContextSize(); - refreshModels(); +void LlmClient::refreshAllStates() { + if (m_endpoint.trimmed().isEmpty()) { + setServerError(QStringLiteral("LLM endpoint is not configured")); + return; + } + probeContextSize([this]() { refreshModels(); }); } void LlmClient::refreshModels() { const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); - if (!url.isValid() || url.host().isEmpty()) return; + if (!url.isValid() || url.host().isEmpty()) { + setServerError(QStringLiteral("LLM endpoint is not configured")); + return; + } auto* reply = m_manager.get(QNetworkRequest(url)); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QNetworkReply::NetworkError error = reply->error(); + const QString errorString = reply->errorString(); const QByteArray data = reply->readAll(); reply->deleteLater(); if (error != QNetworkReply::NoError) { - qWarning() << "LlmClient: failed to fetch models:" << error; + const QString serverMessage = jsonErrorMessage(data); + setServerError( + serverMessage.isEmpty() + ? QStringLiteral("Cannot reach LLM server: %1") + .arg(errorString) + : serverMessage); return; } const QJsonArray dataArr = @@ -838,7 +984,12 @@ void LlmClient::refreshModels() { models.append(id); } } - if (models.isEmpty()) return; + if (models.isEmpty()) { + setServerError(QStringLiteral( + "LLM server is running but has no models loaded")); + return; + } + setServerError(QString()); m_availableModels = models; Q_EMIT availableModelsChanged(); @@ -866,6 +1017,8 @@ void LlmClient::probeContextSize(std::function done) { base.chop(1); const QUrl url = QUrl::fromUserInput(base + "/props"); if (!url.isValid() || url.host().isEmpty()) { + setServerError( + QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); if (done) done(); return; } @@ -877,11 +1030,13 @@ void LlmClient::probeContextSize(std::function done) { *settled = true; const QNetworkReply::NetworkError error = reply->error(); + const QString errorString = reply->errorString(); const QByteArray data = reply->readAll(); reply->deleteLater(); int size = 0; if (error == QNetworkReply::NoError) { + setServerError(QString()); const QJsonDocument doc = QJsonDocument::fromJson(data); if (doc.isArray()) { for (const auto& value : doc.array()) { @@ -899,6 +1054,13 @@ void LlmClient::probeContextSize(std::function done) { .toObject()["n_ctx"] .toInt(0); } + } else { + const QString serverMessage = jsonErrorMessage(data); + setServerError( + serverMessage.isEmpty() + ? QStringLiteral("Cannot reach LLM server: %1") + .arg(errorString) + : serverMessage); } if (size > 0) setContextSize(size); diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp index 7c864b1..3a1b955 100644 --- a/Plugins/ZShell/Llm/llmclient.hpp +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -39,16 +39,20 @@ class LlmClient : public QObject { } [[nodiscard]] int contextSize() const { return m_contextSize; } [[nodiscard]] ToolRegistry* tools() const { return m_tools; } + [[nodiscard]] QString serverError() const { return m_serverError; } void setEndpoint(const QString& value); void setModel(const QString& value); void setTemperature(double value); void setContextSize(int size); void probeContextSize(std::function done = {}); - Q_INVOKABLE void refreshFromServer(); + void refreshAllStates(); [[nodiscard]] bool busy() const { return m_busy; } [[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); } void setToolsEnabled(bool value) { m_tools->setEnabled(value); } + [[nodiscard]] bool sandboxEnabled() const { return m_sandboxEnabled; } + void setSandboxEnabled(bool value); + [[nodiscard]] QString activeWorkspace() const; [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } [[nodiscard]] ChatSession* streamingSession() const { return m_active; } @@ -74,6 +78,7 @@ class LlmClient : public QObject { void toolsEnabledChanged(); void streamingChatIdChanged(); void errorOccurred(const QString& message); + void serverErrorChanged(); void titleSuggested(ZShell::llm::ChatSession* session, const QString& title); void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon); @@ -89,6 +94,7 @@ class LlmClient : public QObject { void sendRound(); QJsonArray buildContextMessages( ChatSession* session, int stopBeforeRow, int extraReserveTokens) const; + void appendSandboxPreamble(QJsonArray& messages); void applyToolCallDelta(const QJsonObject& call); void roundFinished(); void executeAllCalls(); @@ -102,6 +108,7 @@ class LlmClient : public QObject { void updateTokenUsage(const QJsonObject& data); void setBusy(bool value); void setStreamingChatId(const QString& id); + void setServerError(const QString& value); void shortRequest( const QString& tag, const QString& systemPrompt, @@ -109,6 +116,7 @@ class LlmClient : public QObject { std::function onResult); static QString completionsPath( const QString& endpoint, const QString& subpath); + static QString jsonErrorMessage(const QByteArray& body); static QString serverErrorMessage( const QByteArray& body, const QString& fallback); @@ -125,6 +133,7 @@ class LlmClient : public QObject { QString m_endpoint; QString m_model; QStringList m_availableModels; + QString m_serverError; double m_temperature = 0.7; int m_contextSize = 0; @@ -139,7 +148,9 @@ class LlmClient : public QObject { int m_round = 0; qsizetype m_contentMark = 0; qsizetype m_reasoningMark = 0; + bool m_sandboxEnabled = true; bool m_roundDone = false; + bool m_streamDone = false; bool m_toolPhase = false; bool m_approvalPending = false; bool m_contextRetryUsed = false; diff --git a/Plugins/ZShell/Llm/markdownblock.hpp b/Plugins/ZShell/Llm/markdownblock.hpp index 7242ed3..576a289 100644 --- a/Plugins/ZShell/Llm/markdownblock.hpp +++ b/Plugins/ZShell/Llm/markdownblock.hpp @@ -11,12 +11,7 @@ class LlmMarkdown : public QObject { QML_UNCREATABLE("Blocks are produced by LlmSegment") public: - enum class Type : int { - Text = 0, // Paragraph, list, quote, table; "text" is markdown source - Heading, // "level" + "text" (markdown source of the content) - Code, // "language" + "code" - Math // "latex" (display math, without the $$ delimiters) - }; + enum class Type : int { Text = 0, Heading, Code, Math }; Q_ENUM(Type) explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {} diff --git a/Plugins/ZShell/Llm/mathtext.cpp b/Plugins/ZShell/Llm/mathtext.cpp index 8abcede..542f142 100644 --- a/Plugins/ZShell/Llm/mathtext.cpp +++ b/Plugins/ZShell/Llm/mathtext.cpp @@ -10,9 +10,14 @@ #include #include #include +#include +#include +#include #include #include +#include + namespace ZShell::llm { namespace { @@ -71,26 +76,27 @@ const LatinModern& loadLatinModern() { return fonts; } -} // namespace - -LlmMathText::LlmMathText(QObject* parent) - : QObject(parent) - , m_renderer( - std::make_shared(nullptr, /* useFontsForGUI */ true)) { +void configureLatinModern(JKQTMathText& renderer) { const LatinModern& fonts = loadLatinModern(); if (fonts.roman) - m_renderer->setFontRomanAndMath( + renderer.setFontRomanAndMath( QStringLiteral("LMRoman10"), JKQTMathTextFontEncoding::MTFEUnicode); if (fonts.math) { - m_renderer->setFontMathRoman( + renderer.setFontMathRoman( QStringLiteral("Latin Modern Math"), JKQTMathTextFontEncoding::MTFEUnicode); - m_renderer->setFallbackFontSymbols( + renderer.setFallbackFontSymbols( QStringLiteral("Latin Modern Math"), JKQTMathTextFontEncoding::MTFEUnicode); } } +} // namespace + +LlmMathText::LlmMathText(QObject* parent) : QObject(parent) { + loadLatinModern(); +} + void LlmMathText::setLatex(const QString& value) { if (m_latex == value) return; m_latex = value; @@ -120,23 +126,88 @@ namespace { struct MathRender { bool ok = false; QImage image; - QUrl url; + QString id; qreal width = 0; qreal height = 0; }; -QHash& mathCache() { - static QHash cache; - return cache; +struct MathStore { + QMutex mutex; + QHash entries; + QStringList order; + QHash idIndex; + QHash idRefs; + qint64 bytes = 0; + quint64 nextId = 1; +}; + +MathStore& mathStore() { + static MathStore store; + return store; +} + +qint64 imageBytes(const QImage& image) { + return image.isNull() ? 0 : static_cast(image.sizeInBytes()); +} + +void retainMathId(const QString& id) { + if (id.isEmpty()) return; + auto& store = mathStore(); + QMutexLocker locker(&store.mutex); + ++store.idRefs[id]; +} + +void releaseMathId(const QString& id) { + if (id.isEmpty()) return; + auto& store = mathStore(); + QMutexLocker locker(&store.mutex); + auto it = store.idRefs.find(id); + if (it == store.idRefs.end()) return; + if (--it.value() <= 0) store.idRefs.erase(it); +} + +} // namespace + +namespace { + +constexpr qint64 kMaxCacheBytes = 64 * 1024 * 1024; + +class MathImageProvider : public QQuickImageProvider { + public: + MathImageProvider() : QQuickImageProvider(QQuickImageProvider::Image) {} + + QImage requestImage( + const QString& id, QSize* size, const QSize& requestedSize) override { + Q_UNUSED(requestedSize) + auto& store = mathStore(); + QMutexLocker locker(&store.mutex); + const auto keyIt = store.idIndex.constFind(id); + if (keyIt == store.idIndex.constEnd()) return {}; + const QString key = keyIt.value(); + const auto entryIt = store.entries.constFind(key); + if (entryIt == store.entries.constEnd()) return {}; + if (size) *size = entryIt->image.size(); + const qsizetype pos = store.order.indexOf(key); + if (pos >= 0) store.order.move(pos, store.order.size() - 1); + return entryIt->image; + } +}; + +void ensureImageProvider(QObject* owner) { + if (auto* engine = qmlEngine(owner)) + if (!engine->imageProvider(QStringLiteral("llmmath"))) + engine->addImageProvider( + QStringLiteral("llmmath"), new MathImageProvider()); } } // namespace void LlmMathText::reRender() { + ensureImageProvider(this); ++m_requestId; if (m_latex.trimmed().isEmpty()) { - m_image = QImage(); - m_imageUrl = QUrl(); + releaseMathId(m_imageId); + m_imageId.clear(); m_width = 0; m_height = 0; m_ok = false; @@ -147,12 +218,30 @@ void LlmMathText::reRender() { const QString key = m_latex + QLatin1Char(0x1f) + m_color.name() + QLatin1Char(0x1f) + QString::number(m_fontPointSize) + QLatin1Char(0x1f) + QString::number(m_devicePixelRatio); - if (auto it = mathCache().find(key); it != mathCache().end()) { - m_image = it->image; - m_imageUrl = it->url; - m_width = it->width; - m_height = it->height; - m_ok = it->ok; + bool cacheHit = false; + QString hitId; + qreal hitW = 0; + qreal hitH = 0; + bool hitOk = false; + { + auto& store = mathStore(); + QMutexLocker locker(&store.mutex); + const auto it = store.entries.constFind(key); + if (it != store.entries.constEnd()) { + cacheHit = true; + hitId = it->id; + hitW = it->width; + hitH = it->height; + hitOk = it->ok; + } + } + if (cacheHit) { + releaseMathId(m_imageId); + retainMathId(hitId); + m_imageId = hitId; + m_width = hitW; + m_height = hitH; + m_ok = hitOk; Q_EMIT changed(); return; } @@ -164,33 +253,46 @@ void LlmMathText::reRender() { const QColor color = m_color; const double pointSize = m_fontPointSize; const qreal dpr = m_devicePixelRatio; - auto renderer = m_renderer; QThreadPool::globalInstance()->start( - [this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() { + [this, id = m_requestId, key, latex, color, pointSize, dpr]() { + JKQTMathText renderer(nullptr, true); + configureLatinModern(renderer); MathRender render; - renderer->setFontPointSize(pointSize); - renderer->setFontColor(color); - if (renderer->parse( + renderer.setFontPointSize(pointSize); + renderer.setFontColor(color); + if (renderer.parse( latex, JKQTMathText::LatexParser, JKQTMathText::DefaultParseOptions)) { - const QImage image = renderer->drawIntoImage( - /* drawBoxes */ false, + { + QImage scratch(1, 1, QImage::Format_ARGB32); + QPainter painter(&scratch); + const QSizeF size = renderer.getSize(painter); + render.width = size.width() + 2 * kRenderMargin; + render.height = size.height() + 2 * kRenderMargin; + } + QPointer sizeGuard(this); + const qreal sizeW = render.width; + const qreal sizeH = render.height; + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [sizeGuard, id, sizeW, sizeH]() { + LlmMathText* self = sizeGuard; + if (!self || id != self->m_requestId) return; + self->m_width = sizeW; + self->m_height = sizeH; + Q_EMIT self->changed(); + }, + Qt::QueuedConnection); + + const QImage image = renderer.drawIntoImage( + false, QColor(Qt::transparent), kRenderMargin, dpr, kResolutionDpi); if (!image.isNull()) { - QByteArray png; - { - QBuffer buffer(&png); - buffer.open(QIODevice::WriteOnly); - image.save(&buffer, "PNG"); - } render.image = image; - render.url = QUrl( - QStringLiteral("data:image/png;base64,") + - QString::fromLatin1(png.toBase64())); render.width = image.width() / dpr; render.height = image.height() / dpr; render.ok = true; @@ -208,12 +310,46 @@ void LlmMathText::reRender() { return; } if (render.ok) { - auto& cache = mathCache(); - if (cache.size() >= kCacheLimit) cache.clear(); - cache.insert(key, render); + auto& store = mathStore(); + QMutexLocker locker(&store.mutex); + const bool isNew = !store.entries.contains(key); + if (isNew) + render.id = QString::number(store.nextId++); + else + render.id = store.entries[key].id; + store.idIndex.insert(render.id, key); + if (isNew) { + const qint64 newBytes = imageBytes(render.image); + while (store.order.size() >= kCacheLimit || + store.bytes + newBytes > kMaxCacheBytes) { + bool evicted = false; + for (int i = 0; i < store.order.size(); ++i) { + const QString oldKey = store.order.at(i); + const auto oldIt = + store.entries.constFind(oldKey); + if (oldIt == store.entries.constEnd()) { + store.order.removeAt(i); + continue; + } + if (store.idRefs.value(oldIt->id, 0) > 0) + continue; + store.order.removeAt(i); + store.bytes -= imageBytes(oldIt->image); + store.idIndex.remove(oldIt->id); + store.entries.remove(oldKey); + evicted = true; + break; + } + if (!evicted) break; + } + store.entries.insert(key, render); + store.order.append(key); + store.bytes += imageBytes(render.image); + } } - self->m_image = render.image; - self->m_imageUrl = render.url; + releaseMathId(self->m_imageId); + retainMathId(render.id); + self->m_imageId = render.id; self->m_width = render.width; self->m_height = render.height; self->m_ok = render.ok; @@ -223,4 +359,8 @@ void LlmMathText::reRender() { }); } +LlmMathText::~LlmMathText() { + releaseMathId(m_imageId); +} + } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/mathtext.hpp b/Plugins/ZShell/Llm/mathtext.hpp index 3f97a59..f0210a9 100644 --- a/Plugins/ZShell/Llm/mathtext.hpp +++ b/Plugins/ZShell/Llm/mathtext.hpp @@ -1,16 +1,10 @@ #pragma once #include -#include #include #include -#include #include -#include - -#include - namespace ZShell::llm { class LlmMathText : public QObject { @@ -25,14 +19,14 @@ class LlmMathText : public QObject { Q_PROPERTY( qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio NOTIFY changed) - Q_PROPERTY(QImage image READ image NOTIFY changed) - Q_PROPERTY(QUrl imageUrl READ imageUrl NOTIFY changed) + Q_PROPERTY(QString imageId READ imageId NOTIFY changed) Q_PROPERTY(qreal width READ width NOTIFY changed) Q_PROPERTY(qreal height READ height NOTIFY changed) Q_PROPERTY(bool ok READ ok NOTIFY changed) public: explicit LlmMathText(QObject* parent = nullptr); + ~LlmMathText() override; [[nodiscard]] QString latex() const { return m_latex; } void setLatex(const QString& value); @@ -42,8 +36,7 @@ class LlmMathText : public QObject { void setFontPointSize(double value); [[nodiscard]] qreal devicePixelRatio() const { return m_devicePixelRatio; } void setDevicePixelRatio(qreal value); - [[nodiscard]] QImage image() const { return m_image; } - [[nodiscard]] QUrl imageUrl() const { return m_imageUrl; } + [[nodiscard]] QString imageId() const { return m_imageId; } [[nodiscard]] qreal width() const { return m_width; } [[nodiscard]] qreal height() const { return m_height; } [[nodiscard]] bool ok() const { return m_ok; } @@ -54,13 +47,11 @@ class LlmMathText : public QObject { private: void reRender(); - std::shared_ptr m_renderer; QString m_latex; QColor m_color; double m_fontPointSize = 12.0; qreal m_devicePixelRatio = 1.0; - QImage m_image; - QUrl m_imageUrl; + QString m_imageId; qreal m_width = 0; qreal m_height = 0; bool m_ok = false; diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp index 070b3cb..0cf1fba 100644 --- a/Plugins/ZShell/Llm/message.cpp +++ b/Plugins/ZShell/Llm/message.cpp @@ -57,6 +57,20 @@ void ChatMessage::setActiveGeneration(int index) { setActiveInternal(index); } +void ChatMessage::setAttachments(QList attachments) { + QList stale; + for (Attachment* attachment : m_attachments) + if (!attachments.contains(attachment)) stale.append(attachment); + m_attachments.clear(); + for (Attachment* attachment : attachments) { + attachment->setParent(this); + m_attachments.append(attachment); + } + Q_EMIT attachmentsChanged(); + for (Attachment* attachment : stale) + attachment->deleteLater(); +} + void ChatMessage::edit(const QString& newContent) { if (auto* generation = activeGeneration()) generation->setContent(newContent); diff --git a/Plugins/ZShell/Llm/message.hpp b/Plugins/ZShell/Llm/message.hpp index 87310ed..df9ff5d 100644 --- a/Plugins/ZShell/Llm/message.hpp +++ b/Plugins/ZShell/Llm/message.hpp @@ -1,5 +1,6 @@ #pragma once +#include "attachment.hpp" #include "generation.hpp" #include @@ -27,6 +28,9 @@ class ChatMessage : public QObject { Q_PROPERTY( int activeGenerationIndex READ activeGenerationIndex NOTIFY activeGenerationChanged) + Q_PROPERTY( + QList attachments READ attachments NOTIFY + attachmentsChanged) public: enum class Role : int { User = 0, Assistant }; @@ -46,12 +50,16 @@ class ChatMessage : public QObject { return generation(m_active); } [[nodiscard]] int activeGenerationIndex() const { return m_active; } + [[nodiscard]] QList attachments() const { + return m_attachments; + } [[nodiscard]] ChatGeneration* generation(int index) const { if (index < 0 || index >= m_generations.size()) return nullptr; return m_generations.at(index); } Q_INVOKABLE void setActiveGeneration(int index); + void setAttachments(QList attachments); Q_INVOKABLE void edit(const QString& newContent); Q_INVOKABLE void retry(); Q_INVOKABLE void generate(); @@ -63,6 +71,7 @@ class ChatMessage : public QObject { Q_SIGNALS: void generationsChanged(); void activeGenerationChanged(); + void attachmentsChanged(); private: void setActiveInternal(int index); @@ -70,6 +79,7 @@ class ChatMessage : public QObject { Role m_role; qint64 m_timestamp; QList m_generations; + QList m_attachments; int m_active = -1; }; diff --git a/Plugins/ZShell/Llm/segment.cpp b/Plugins/ZShell/Llm/segment.cpp index e75087c..9134f7d 100644 --- a/Plugins/ZShell/Llm/segment.cpp +++ b/Plugins/ZShell/Llm/segment.cpp @@ -11,7 +11,7 @@ namespace ZShell::llm { namespace { constexpr int kMarkdownRefreshMs = 150; -} // namespace +} LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent) : QObject(parent), m_type(type), m_timestamp(timestamp) { diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp index 2f083ba..ee7e4df 100644 --- a/Plugins/ZShell/Llm/session.cpp +++ b/Plugins/ZShell/Llm/session.cpp @@ -1,16 +1,31 @@ #include "session.hpp" +#include "attachment.hpp" #include "chatstore.hpp" #include "llmclient.hpp" +#include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include namespace ZShell::llm { namespace { +constexpr qint64 kMaxImageBytes = 20 * 1024 * 1024; +constexpr qint64 kMaxFileBytes = 100 * 1024 * 1024; + QString titleFrom(const QString& content) { const QString flat = content.simplified(); if (flat.isEmpty()) return QString(); @@ -21,7 +36,7 @@ QString titleFrom(const QString& content) { } // namespace ChatSession::ChatSession(const QString& id, QObject* parent) - : QObject(parent), m_id(id) { + : QObject(parent), m_id(id), m_workspace(sandboxDirFor(id)) { m_model = new ChatMessageModel(this, this); connect( m_model, @@ -157,26 +172,179 @@ void ChatSession::startGeneration(ChatMessage* target) { persist(); } +QString ChatSession::sandboxDirFor(const QString& sessionId) { + const QString state = + QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation); + return state + QStringLiteral("/zshell/sandboxes/") + sessionId; +} + +void ChatSession::ensureWorkspace() { + QDir().mkpath(m_workspace); +} + +void ChatSession::setAttachmentError(const QString& value) { + if (m_attachmentError == value) return; + m_attachmentError = value; + Q_EMIT attachmentErrorChanged(); +} + +QVariantList ChatSession::addAttachments(const QStringList& paths) { + ensureWorkspace(); + setAttachmentError(QString()); + QVariantList result; + for (const QString& rawPath : paths) { + const QFileInfo info(rawPath); + if (!info.exists() || !info.isFile()) { + qWarning() << "ChatSession: addAttachments skipping" << rawPath + << ": file not found or not a file"; + setAttachmentError( + QStringLiteral("File not found: %1").arg(info.fileName())); + continue; + } + const QString mimeType = QMimeDatabase().mimeTypeForFile(info).name(); + const bool isImage = + mimeType.startsWith(QLatin1String("image/"), Qt::CaseInsensitive); + const qint64 cap = isImage ? kMaxImageBytes : kMaxFileBytes; + if (info.size() > cap) { + qWarning() << "ChatSession: addAttachments skipping" << rawPath + << ": too large (" << info.size() << "bytes, cap" << cap + << ")"; + setAttachmentError( + QStringLiteral("File too large: %1").arg(info.fileName())); + continue; + } + QString destination = m_workspace + QLatin1Char('/') + info.fileName(); + int suffix = 1; + while (QFile::exists(destination)) { + const QString extension = info.suffix().isEmpty() + ? QString() + : QLatin1Char('.') + info.suffix(); + destination = m_workspace + QLatin1Char('/') + + info.completeBaseName() + + QStringLiteral("_%1%2").arg(suffix).arg(extension); + ++suffix; + } + if (!QFile::copy(info.absoluteFilePath(), destination)) { + qWarning() << "ChatSession: addAttachments skipping" << rawPath + << ": copy to" << destination << "failed"; + setAttachmentError( + QStringLiteral("Could not copy file: %1").arg(info.fileName())); + continue; + } + auto* attachment = new Attachment( + info.fileName(), + destination, + isImage ? Attachment::Kind::Image : Attachment::Kind::File, + mimeType, + info.size(), + this); + result.append(QVariant::fromValue(attachment)); + } + return result; +} + +bool ChatSession::clipboardHasAttachment() { + QClipboard* clipboard = QGuiApplication::clipboard(); + if (!clipboard) return false; + const QMimeData* data = clipboard->mimeData(); + if (!data) return false; + if (data->hasImage()) return true; + for (const QUrl& url : data->urls()) + if (url.isLocalFile()) return true; + return false; +} + +QVariantList ChatSession::pasteAttachments() { + QClipboard* clipboard = QGuiApplication::clipboard(); + const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr; + if (!data) { + qWarning() << "ChatSession: pasteAttachments: no clipboard data"; + return {}; + } + + qInfo() << "ChatSession: pasteAttachments: formats =" << data->formats() + << "hasImage =" << data->hasImage() + << "hasUrls =" << data->hasUrls() << "hasText =" << data->hasText(); + + QStringList paths; + QString tempImagePath; + if (data->hasImage()) { + const QImage image = clipboard->image(); + qInfo() << "ChatSession: pasteAttachments: image" + << (image.isNull() ? "null" + : QString::number(image.width()) + "x" + + QString::number(image.height())); + if (image.isNull()) { + setAttachmentError( + QStringLiteral("Clipboard image could not be decoded")); + return {}; + } + tempImagePath = + QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png") + .arg(QDateTime::currentMSecsSinceEpoch()); + if (!image.save(tempImagePath)) { + qWarning() << "ChatSession: pasteAttachments: saving image to" + << tempImagePath << "failed"; + setAttachmentError( + QStringLiteral("Could not save the pasted image")); + return {}; + } + paths.append(tempImagePath); + } else if (data->hasUrls()) { + for (const QUrl& url : data->urls()) { + qInfo() << "ChatSession: pasteAttachments: url =" << url.toString() + << "local =" << url.isLocalFile(); + if (url.isLocalFile()) paths.append(url.toLocalFile()); + } + } + if (paths.isEmpty()) { + if (data->hasImage() || data->hasUrls()) + setAttachmentError(QStringLiteral( + "Nothing usable on the clipboard (expected an image " + "or copied files)")); + return {}; + } + const QVariantList attachments = addAttachments(paths); + if (!tempImagePath.isEmpty()) QFile::remove(tempImagePath); + qInfo() << "ChatSession: pasteAttachments: result =" << attachments.size() + << "attachment(s)"; + return attachments; +} + void ChatSession::sendMessage(const QString& text) { + sendMessage(text, QVariantList()); +} + +void ChatSession::sendMessage( + const QString& text, const QVariantList& attachments) { + QList attachmentList; + for (const QVariant& variant : attachments) + if (auto* attachment = + qobject_cast(variant.value())) + attachmentList.append(attachment); const QString trimmed = text.trimmed(); - if (trimmed.isEmpty()) return; + if (trimmed.isEmpty() && attachmentList.isEmpty()) return; if (auto* clientObject = client()) { if (clientObject->busy()) return; } const qint64 now = QDateTime::currentMSecsSinceEpoch(); - appendNewest(ChatMessage::Role::User, trimmed, now); + auto* userMessage = appendNewest(ChatMessage::Role::User, trimmed, now); + if (!attachmentList.isEmpty()) userMessage->setAttachments(attachmentList); while (m_model->rowCount() > 200) m_model->removeMessage(m_model->at(m_model->rowCount() - 1)); if (m_title.isEmpty()) { - m_title = titleFrom(trimmed); + QString titleSource = trimmed; + if (titleSource.isEmpty() && !attachmentList.isEmpty()) + titleSource = attachmentList.first()->name(); + m_title = titleFrom(titleSource); Q_EMIT titleChanged(); qInfo() << "ChatSession:" << m_id << "new conversation," << "fallback title" << m_title << "- requesting generated title and icon"; if (auto* clientObject = client()) { - clientObject->requestTitle(this, trimmed); - clientObject->requestIcon(this, trimmed); + clientObject->requestTitle(this, titleSource); + clientObject->requestIcon(this, titleSource); } } auto* assistant = diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp index a024322..f96e70f 100644 --- a/Plugins/ZShell/Llm/session.hpp +++ b/Plugins/ZShell/Llm/session.hpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include namespace ZShell::llm { @@ -27,6 +29,10 @@ class ChatSession : public QObject { Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) Q_PROPERTY( ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT) + Q_PROPERTY(QString workspace READ workspacePath CONSTANT) + Q_PROPERTY( + QString attachmentError READ attachmentError NOTIFY + attachmentErrorChanged) public: explicit ChatSession(const QString& id, QObject* parent = nullptr); @@ -55,6 +61,13 @@ class ChatSession : public QObject { [[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; } void setLastTokenCount(int value); + [[nodiscard]] QString workspacePath() const { return m_workspace; } + [[nodiscard]] QString attachmentError() const { return m_attachmentError; } + void setAttachmentError(const QString& value); + + static QString sandboxDirFor(const QString& sessionId); + void ensureWorkspace(); + void setTitle(const QString& value); void setIcon(const QString& value); void setUpdatedAt(qint64 value); @@ -71,7 +84,12 @@ class ChatSession : public QObject { void removeMessage(ChatMessage* message); void clearMessages(); + Q_INVOKABLE QVariantList addAttachments(const QStringList& paths); + Q_INVOKABLE bool clipboardHasAttachment(); + Q_INVOKABLE QVariantList pasteAttachments(); Q_INVOKABLE void sendMessage(const QString& text); + Q_INVOKABLE void sendMessage( + const QString& text, const QVariantList& attachments); Q_INVOKABLE void retry(ZShell::llm::ChatMessage* target); Q_INVOKABLE void continueFrom(ZShell::llm::ChatMessage* message); Q_INVOKABLE void clear(); @@ -83,6 +101,7 @@ class ChatSession : public QObject { void pinnedChanged(); void messageCountChanged(); void loaded(); + void attachmentErrorChanged(); private: void onModelRowsChanged(); @@ -103,6 +122,8 @@ class ChatSession : public QObject { bool m_loaded = false; bool m_clearPending = false; int m_lastTokenCount = 0; + QString m_workspace; + QString m_attachmentError; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/shelltool.cpp b/Plugins/ZShell/Llm/shelltool.cpp new file mode 100644 index 0000000..c18ceaa --- /dev/null +++ b/Plugins/ZShell/Llm/shelltool.cpp @@ -0,0 +1,258 @@ +#include "shelltool.hpp" + +#include "llmclient.hpp" +#include "session.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace ZShell::llm { + +namespace { + +QJsonObject makeOutput(const QString& text) { + QJsonObject obj; + obj[QStringLiteral("output")] = text; + return obj; +} + +QJsonObject makeError(const QString& message) { + QJsonObject obj; + obj[QStringLiteral("error")] = message; + return obj; +} + +} // namespace + +ShellTool::ShellTool(LlmClient* client, QObject* parent) + : LlmTool(parent), m_client(client) {} + +ShellTool::~ShellTool() { + for (auto* job : m_jobs) { + if (job->timer) job->timer->stop(); + if (job->process) job->process->kill(); + delete job; + } + m_jobs.clear(); +} + +QString ShellTool::name() const { + return QStringLiteral("runcommand"); +} + +QString ShellTool::description() const { + return QStringLiteral( + "Run a shell command inside a bubblewrap sandbox that contains " + "the files the user attached to the conversation. The rest of " + "the filesystem is mounted read-only, there is no network " + "access, and only the sandbox directory is writable. The " + "command is executed with /bin/sh and the sandbox directory as " + "its working directory, so relative paths refer to the sandbox. " + "Use it to explore, transform, or extract attached files (for " + "example: ls, find, grep, unzip, tar, head, wc). The combined " + "output is limited; the exit code is always reported. The call " + "only completes when every child process has exited, so do not " + "start long-lived background processes."); +} + +QJsonObject ShellTool::parameters() const { + QJsonObject command; + command[QStringLiteral("type")] = QStringLiteral("string"); + command[QStringLiteral("description")] = + QStringLiteral("The shell command to execute"); + + QJsonObject timeout; + timeout[QStringLiteral("type")] = QStringLiteral("integer"); + timeout[QStringLiteral("minimum")] = 1; + timeout[QStringLiteral("maximum")] = MaxTimeoutSeconds; + timeout[QStringLiteral("description")] = + QStringLiteral("Timeout in seconds. Defaults to %1.") + .arg(DefaultTimeoutSeconds); + + QJsonObject properties; + properties[QStringLiteral("command")] = command; + properties[QStringLiteral("timeout")] = timeout; + + QJsonObject schema; + schema[QStringLiteral("type")] = QStringLiteral("object"); + schema[QStringLiteral("properties")] = properties; + QJsonArray required; + required.append(QStringLiteral("command")); + schema[QStringLiteral("required")] = required; + return schema; +} + +QStringList ShellTool::sandboxArguments(const QString& workspace) { + QStringList args; + args << QLatin1String("--unshare-user") << QLatin1String("--uid") + << QString::number(::getuid()) << QLatin1String("--gid") + << QString::number(::getgid()) << QLatin1String("--disable-userns") + << QLatin1String("--unshare-pid") << QLatin1String("--unshare-net") + << QLatin1String("--unshare-ipc") << QLatin1String("--unshare-uts") + << QLatin1String("--hostname") << QLatin1String("zshell-sandbox"); + args << QLatin1String("--ro-bind") << QLatin1String("/") + << QLatin1String("/") << QLatin1String("--tmpfs") + << QLatin1String("/tmp") << QLatin1String("--tmpfs") + << QLatin1String("/run") << QLatin1String("--bind") << workspace + << workspace << QLatin1String("--dev") << QLatin1String("/dev") + << QLatin1String("--proc") << QLatin1String("/proc"); + args << QLatin1String("--chdir") << workspace; + args << QLatin1String("--clearenv") << QLatin1String("--setenv") + << QLatin1String("PATH") + << QLatin1String( + "/usr/local/sbin:/usr/local/bin:/usr/sbin:" + "/usr/bin:/sbin:/bin") + << QLatin1String("--setenv") << QLatin1String("HOME") << workspace + << QLatin1String("--setenv") << QLatin1String("TMPDIR") + << QLatin1String("/tmp") << QLatin1String("--setenv") + << QLatin1String("SHELL") << QLatin1String("/bin/sh"); + const QByteArray user = qgetenv("USER"); + if (!user.isEmpty()) + args << QLatin1String("--setenv") << QLatin1String("USER") + << QString::fromLocal8Bit(user) << QLatin1String("--setenv") + << QLatin1String("LOGNAME") << QString::fromLocal8Bit(user); + const QByteArray lang = qgetenv("LANG"); + if (!lang.isEmpty()) + args << QLatin1String("--setenv") << QLatin1String("LANG") + << QString::fromLocal8Bit(lang); + args << QLatin1String("--new-session") + << QLatin1String("--die-with-parent"); + return args; +} + +void ShellTool::execute( + const QString& toolCallId, + const QJsonObject& args, + int outputBudgetChars, + std::function done) { + Q_UNUSED(toolCallId); + const QString command = args[QStringLiteral("command")].toString(); + if (command.trimmed().isEmpty()) { + done(makeError(QStringLiteral("Missing required argument 'command'"))); + return; + } + LlmClient* client = m_client; + if (!client || !client->sandboxEnabled()) { + done(makeError(QStringLiteral( + "The file sandbox is disabled, so no commands can run"))); + return; + } + const QString workspace = client->activeWorkspace(); + if (workspace.isEmpty()) { + done(makeError( + QStringLiteral("No sandbox available: no chat session is active"))); + return; + } + QDir().mkpath(workspace); + + const int timeoutSeconds = qBound( + 1, + args[QStringLiteral("timeout")].toInt(DefaultTimeoutSeconds), + MaxTimeoutSeconds); + auto* job = new Job; + job->timeoutSeconds = timeoutSeconds; + job->outputBudgetChars = outputBudgetChars; + job->done = std::move(done); + m_jobs.append(job); + + QProcess* process = new QProcess(this); + process->setWorkingDirectory(workspace); + process->setProcessChannelMode(QProcess::MergedChannels); + job->process = process; + + QTimer* timer = new QTimer(this); + timer->setSingleShot(true); + connect(timer, &QTimer::timeout, this, [this, job, process]() { + job->timedOut = true; + process->kill(); + }); + timer->start(timeoutSeconds * 1000); + job->timer = timer; + + connect( + process, + QOverload::of(&QProcess::finished), + this, + [this, job](int, QProcess::ExitStatus) { finishJob(job); }); + connect( + process, + &QProcess::errorOccurred, + this, + [this, job, process](QProcess::ProcessError error) { + if (job->finished) return; + if (error == QProcess::FailedToStart || + process->state() == QProcess::NotRunning) { + job->startError = process->errorString(); + finishJob(job); + } else { + process->kill(); + } + }); + + QStringList commandArgs = sandboxArguments(workspace); + commandArgs << QStringLiteral("sh") << QStringLiteral("-c") << command; + process->start(QStringLiteral("bwrap"), commandArgs); +} + +void ShellTool::cancel() { + for (auto* job : m_jobs) { + if (job->finished || !job->process) continue; + job->cancelled = true; + job->process->kill(); + } +} + +void ShellTool::finishJob(Job* job) { + if (!job || job->finished) return; + job->finished = true; + if (job->timer) job->timer->stop(); + m_jobs.removeAll(job); + + QProcess* process = job->process; + const bool ran = process && process->state() != QProcess::NotRunning; + const int exitCode = ran ? process->exitCode() : -1; + QString output; + if (process) output = QString::fromUtf8(process->readAllStandardOutput()); + + QString note; + if (!job->startError.isEmpty()) + note = + QStringLiteral("Command failed to start: %1\n").arg(job->startError); + else if (job->cancelled) + note = QStringLiteral("Command was cancelled.\n"); + else if (job->timedOut) + note = QStringLiteral( + "Command timed out after %1 seconds and was killed.\n") + .arg(job->timeoutSeconds); + + int budget = inlineBudgetChars(); + if (job->outputBudgetChars > 0) + budget = qMin(budget, job->outputBudgetChars); + if (output.size() > budget) + output = output.left(budget) + QStringLiteral( + "\n[output truncated: %1 characters " + "total]") + .arg(output.size()); + + const QString result = + note + QStringLiteral("exit code: %1\n").arg(exitCode) + + (output.isEmpty() ? QStringLiteral("(no output)") : output); + + QJsonObject resultObject; + if (!job->cancelled && !job->timedOut && exitCode == 0) + resultObject = makeOutput(result); + else + resultObject = makeError(result); + + std::function done = std::move(job->done); + if (process) process->deleteLater(); + delete job; + if (done) done(resultObject); +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/shelltool.hpp b/Plugins/ZShell/Llm/shelltool.hpp new file mode 100644 index 0000000..de1e205 --- /dev/null +++ b/Plugins/ZShell/Llm/shelltool.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "tool.hpp" + +#include +#include +#include + +class QProcess; +class QTimer; + +namespace ZShell::llm { + +class LlmClient; + +class ShellTool : public LlmTool { + Q_OBJECT + + public: + explicit ShellTool(LlmClient* client, QObject* parent = nullptr); + ~ShellTool() override; + + QString name() const override; + QString description() const override; + QJsonObject parameters() const override; + void execute( + const QString& toolCallId, + const QJsonObject& args, + int outputBudgetChars, + std::function done) override; + void cancel() override; + + static constexpr int DefaultTimeoutSeconds = 60; + static constexpr int MaxTimeoutSeconds = 300; + + private: + struct Job { + QPointer process; + QPointer timer; + int timeoutSeconds = DefaultTimeoutSeconds; + int outputBudgetChars = 0; + std::function done; + QString startError; + bool timedOut = false; + bool cancelled = false; + bool finished = false; + }; + + static QStringList sandboxArguments(const QString& workspace); + + void finishJob(Job* job); + + QPointer m_client; + QList m_jobs; +}; + +} // namespace ZShell::llm diff --git a/scripts/mock-llama-server.py b/scripts/mock-llama-server.py new file mode 100644 index 0000000..d632cd5 --- /dev/null +++ b/scripts/mock-llama-server.py @@ -0,0 +1,718 @@ +#!/usr/bin/env python3 +"""Mock llama-server for exercising zshell's LLM error handling. + +Implements the OpenAI-compatible subset of llama-server that the C++ +backend talks to, and lets you trigger specific failure modes from the +QML chat UI by typing a keyword as your message. + +Wire format is taken from ggml-org/llama.cpp (tools/server): + - SSE events are "data: \n\n" (note the space after "data:"). + - A clean stream ends with "data: [DONE]\n\n". + - Errors are {"error": {"code", "message", "type"}}. + - A stream that dies mid-way sends the error event, then closes + WITHOUT [DONE]. + - A request that errors before the first token is a plain HTTP 4xx/5xx + with the error JSON body (not a stream). + - /props is a JSON object; n_ctx lives under + default_generation_settings.n_ctx. + +Run: + python3 scripts/mock-llama-server.py [--port 8080] + +Then point zshell's LLM endpoint at http://localhost:8080. + +Trigger words (type as the chat message, case-insensitive): + normal -> healthy stream, ends with [DONE] (also the default) + instant error -> 500 before any token ("server_error") + context overflow -> 400 "exceeds the available context size (2048 ...)"; + backend retries once at 2048, then fails + error during -> streams a few tokens, then an SSE error event, + closes without [DONE] + no done -> streams a few tokens, closes without [DONE] and + without an error (server killed mid-response) + 200 error -> HTTP 200 with a plain JSON error body (not SSE) + tool call -> streams reasoning, then a tool_calls round calling + readfile on /etc/os-release (finish_reason + "tool_calls"); after you approve (or deny) the + permission dialog, the follow-up round that + carries the tool result gets a normal summary + answer. Exercises the tool permission dialog, + tool execution and the multi-round transcript. + multiple tool -> two tool calls in one round (readfile + webfetch + calls of this mock's own / endpoint); approve both at + once + unknown tool -> a tool call for a tool the client does not have; + approving it yields an "unknown tool" error + result + +Control endpoint (for the non-chat routes / global state), via curl: + curl -XPOST localhost:8080/_mock -d '{"loading": true}' + /props, /v1/models, /health all answer 503 "Loading model" + curl -XPOST localhost:8080/_mock -d '{"no_models": true}' + /v1/models returns an empty list + curl -XPOST localhost:8080/_mock -d '{"chat_mode": "error_during"}' + force a chat behaviour regardless of the message text + (values: normal|instant_error|context_overflow|error_during| + no_done|200_error|tool_call|tool_call_multi| + tool_call_unknown, or null to go back to keywords) + curl -XPOST localhost:8080/_mock -d '{"reset": true}' +""" + +import argparse +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +MODEL_ID = "mock-model" +CTX_SIZE = 8192 + +# Set in main(); used by the tool-call behaviours so webfetch can point +# at this server itself. +_host = "127.0.0.1" +_port = 8080 + +# Mutable, shared across handler threads. +_state_lock = threading.Lock() +_state = { + "loading": False, # 503 "Loading model" on /props, /v1/models, /health + "no_models": False, # /v1/models returns an empty list + "chat_mode": None, # force a chat behaviour, overriding keywords +} + + +def _now(): + return int(time.time()) + + +def _chunk(cid, delta, finish_reason=None): + return { + "id": cid, + "object": "chat.completion.chunk", + "created": _now(), + "model": MODEL_ID, + "system_fingerprint": "mock", + "choices": [ + { + "index": 0, + "delta": delta, + "finish_reason": finish_reason, + } + ], + } + + +def _usage_chunk(cid, prompt_tokens, completion_tokens): + return { + "id": cid, + "object": "chat.completion.chunk", + "created": _now(), + "model": MODEL_ID, + "choices": [], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + }, + } + + +def _error(code, message, etype): + return {"error": {"code": code, "message": message, "type": etype}} + + +def _sse(obj): + # Exact llama.cpp format_oai_sse shape: "data: " + compact json + "\n\n". + return ("data: " + json.dumps(obj, separators=(",", ":")) + "\n\n").encode() + + +def _sse_done(): + return b"data: [DONE]\n\n" + + +# Chat behaviours. Each returns ("json", status, body_bytes) for a +# non-streaming answer, or ("sse", 200, None) and then writes to the +# provided stream writer. +def _behaviour_for(text): + t = text.lower() + if "instant error" in t: + return "instant_error" + if "context overflow" in t: + return "context_overflow" + if "error during" in t: + return "error_during" + if "no done" in t or "truncate" in t: + return "no_done" + if "200 error" in t: + return "200_error" + # Order matters: "multiple tool calls" contains "tool call". + if "two tool calls" in t or "two tool call" in t: + return "tool_call_duo" + if "some tool calls" in t: + return "tool_call_some" + if "many tool calls" in t: + return "tool_call_many" + if "unknown tool" in t: + return "tool_call_unknown" + if "tool call" in t: + return "tool_call" + return "normal" + + +_TOOL_MODES = ("tool_call", "tool_call_duo", "tool_call_some", "tool_call_many", "tool_call_unknown") + + +def _tool_call_delta(index, call_id=None, name=None, arguments=None): + call = {"index": index} + if call_id is not None: + call["id"] = call_id + if name is not None or arguments is not None: + function = {} + if name is not None: + function["name"] = name + if arguments is not None: + function["arguments"] = arguments + call["function"] = function + return {"tool_calls": [call]} + + +def _stream_tool_calls(w, cid, reasoning, calls): + # One tool round: optional reasoning, then the calls (llama.cpp + # streams the id/name with the first fragment of each call and the + # arguments as successive fragments), then finish_reason + # "tool_calls". The client concatenates the argument fragments and + # shows the permission dialog on stream end. + w(_sse(_chunk(cid, {"role": "assistant"}))) + if reasoning: + for piece in reasoning: + w(_sse(_chunk(cid, {"reasoning_content": piece}))) + time.sleep(0.05) + for i, (call_id, name, arguments) in enumerate(calls): + w(_sse(_chunk(cid, _tool_call_delta(i, call_id=call_id, name=name)))) + time.sleep(0.05) + for fragment in arguments: + w( + _sse( + _chunk(cid, _tool_call_delta(i, arguments=fragment)) + ) + ) + time.sleep(0.05) + w(_sse(_chunk(cid, {}, finish_reason="tool_calls"))) + w(_sse(_usage_chunk(cid, 24, len(reasoning or []) + len(calls) * 2))) + w(_sse_done()) + + +def _tool_result_snippets(messages, limit=160): + snippets = [] + for msg in messages or []: + if msg.get("role") != "tool": + continue + content = msg.get("content") + if isinstance(content, list): + content = " ".join( + part.get("text", "") + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ) + flat = " ".join(str(content or "").split()) + snippets.append(flat[:limit] + ("..." if len(flat) > limit else "")) + return snippets + + +def _stream_tool_round_done(w, cid, messages): + # Follow-up round: the client sent the tool results back; answer + # with a plain content summary so the turn completes. + w(_sse(_chunk(cid, {"role": "assistant"}))) + parts = ["mock:", "tool", "round", "complete."] + for i, snippet in enumerate(_tool_result_snippets(messages), 1): + parts.append(f"result {i}:") + parts.append(snippet or "(empty)") + for word in parts: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.02) + w(_sse(_chunk(cid, {}, finish_reason="stop"))) + w(_sse(_usage_chunk(cid, 40, len(parts) + 1))) + w(_sse_done()) + + +def _stream_normal(w, cid, words): + w(_sse(_chunk(cid, {"role": "assistant"}))) + for word in words: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.05) + w(_sse(_chunk(cid, {}, finish_reason="stop"))) + w(_sse(_usage_chunk(cid, 12, len(words) + 1))) + w(_sse_done()) + + +def _stream_error_during(w, cid, words): + w(_sse(_chunk(cid, {"role": "assistant"}))) + for word in words: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.05) + # Mid-stream failure: error event, then the connection closes without + # [DONE] (llama.cpp terminates the stream on a task error). + w(_sse(_error(500, "Mock: generation failed mid-stream", "server_error"))) + + +def _stream_no_done(w, cid, words): + w(_sse(_chunk(cid, {"role": "assistant"}))) + for word in words: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.05) + # Clean FIN, no [DONE], no error: server died mid-response. + + +def _last_user_text(messages): + for msg in reversed(messages or []): + if msg.get("role") == "user": + content = msg.get("content") + if isinstance(content, list): + # multimodal shape: pull out the text parts + parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append(part.get("text", "")) + content = " ".join(parts) + return content or "" + return "" + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "MockLlama/1.0" + + # --- low-level response helpers ----------------------------------- + def _send_json(self, status, obj): + body = json.dumps(obj, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + def _send_loading_error(self): + # llama.cpp middleware: server not ready -> 503 on every route. + self._send_json( + 503, _error(503, "Loading model", "unavailable_error") + ) + + def _not_ready(self): + with _state_lock: + return _state["loading"] + + def _stream(self, status, writer): + self.send_response(status) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "close") + self.end_headers() + try: + writer(self.wfile.write) + except (BrokenPipeError, ConnectionResetError): + pass + finally: + try: + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + pass + + # --- routing ------------------------------------------------------- + def do_GET(self): + path = self.path.split("?", 1)[0].rstrip("/") or "/" + if path in ("/health", "/v1/health"): + if self._not_ready(): + return self._send_loading_error() + return self._send_json(200, {"status": "ok"}) + if path in ("/props", "/v1/props"): + if self._not_ready(): + return self._send_loading_error() + return self._send_json( + 200, + { + "total_slots": 1, + "model_alias": MODEL_ID, + "model_path": "/mock/model.gguf", + "default_generation_settings": { + "params": {"n_predict": 2048}, + "n_ctx": CTX_SIZE, + }, + "is_sleeping": False, + }, + ) + if path in ("/models", "/v1/models"): + if self._not_ready(): + return self._send_loading_error() + with _state_lock: + no_models = _state["no_models"] + data = [] if no_models else [ + { + "id": MODEL_ID, + "object": "model", + "created": _now(), + "owned_by": "mock", + } + ] + return self._send_json(200, {"object": "list", "data": data}) + if path == "/": + return self._send_json(200, {"status": "ok", "mock": True}) + return self._send_json(404, _error(404, "not found", "not_found_error")) + + def do_POST(self): + path = self.path.split("?", 1)[0].rstrip("/") or "/" + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + try: + body = json.loads(raw) if raw else {} + except json.JSONDecodeError: + body = {} + + if path == "/_mock": + return self._mock_control(body) + + if path in ("/chat/completions", "/v1/chat/completions"): + if self._not_ready(): + return self._send_loading_error() + return self._chat(body) + + return self._send_json(404, _error(404, "not found", "not_found_error")) + + # --- /_mock control ------------------------------------------------ + def _mock_control(self, body): + with _state_lock: + if body.get("reset"): + _state["loading"] = False + _state["no_models"] = False + _state["chat_mode"] = None + if "loading" in body: + _state["loading"] = bool(body["loading"]) + if "no_models" in body: + _state["no_models"] = bool(body["no_models"]) + if "chat_mode" in body: + _state["chat_mode"] = body["chat_mode"] + snapshot = dict(_state) + self.log_message("control -> %s", json.dumps(snapshot)) + return self._send_json(200, {"ok": True, "state": snapshot}) + + # --- /chat/completions -------------------------------------------- + def _chat(self, body): + text = _last_user_text(body.get("messages")) + with _state_lock: + forced = _state["chat_mode"] + mode = forced or _behaviour_for(text) + stream = bool(body.get("stream", True)) + cid = "chatcmpl-mock" + words = ["mock", "says", "hello", "from", "the", "mock", "server"] + self.log_message("chat mode=%s stream=%s text=%r", mode, stream, text) + + if mode == "instant_error": + # Error before the first token: plain HTTP error, not a stream. + return self._send_json( + 500, _error(500, "Mock: instant server error", "server_error") + ) + + if mode == "context_overflow": + return self._send_json( + 400, + _error( + 400, + "request (100000 tokens) exceeds the available context " + "size (2048 tokens), try increasing it", + "exceed_context_size_error", + ), + ) + + if mode == "200_error": + # 200 with a plain JSON error body (not SSE) -- the case the + # data: filter would otherwise swallow. + return self._send_json( + 200, _error(400, "Mock: 200-with-json-error", "server_error") + ) + + if mode in _TOOL_MODES: + # Continuation round: the previous round's tool results came + # back through the client (role: "tool" messages). + if any(m.get("role") == "tool" for m in body.get("messages") or []): + return self._stream( + 200, + lambda w: _stream_tool_round_done( + w, cid, body.get("messages") + ), + ) + if mode == "tool_call": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', "os-release\"}"], + ) + ] + reasoning = [ + "The user wants a tool call. ", + "I will read /etc/os-release.", + ] + elif mode == "tool_call_duo": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', "hostname\"}"], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ] + reasoning = ["Two independent tool calls, ", + "batched together."] + elif mode == "tool_call_some": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', "hostname\"}"], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', "os-release\"}"], + ), + ( + "call_mock_4", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', "uptime\"}"], + ), + ( + "call_mock_6", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ] + reasoning = ["Two independent tool calls, ", + "batched together."] + elif mode == "tool_call_many": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', "hostname\"}"], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', "os-release\"}"], + ), + ( + "call_mock_4", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', "uptime\"}"], + ), + ( + "call_mock_6", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', "hostname\"}"], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', "os-release\"}"], + ), + ( + "call_mock_4", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', "uptime\"}"], + ), + ( + "call_mock_6", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', "hostname\"}"], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', "os-release\"}"], + ), + ( + "call_mock_4", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', "uptime\"}"], + ), + ( + "call_mock_6", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ] + reasoning = ["Two independent tool calls, ", + "batched together."] + else: # tool_call_unknown + calls = [ + ( + "call_mock_3", + "nonexistent_tool", + ['{"target": "test"}'], + ) + ] + reasoning = ["Calling a tool the client ", "does not have."] + self.log_message( + "tool mode=%s calls=%s", mode, [c[1] for c in calls] + ) + return self._stream( + 200, lambda w: _stream_tool_calls(w, cid, reasoning, calls) + ) + + if not stream: + # Non-streaming success: single chat.completion object. + content = " ".join(words) + return self._send_json( + 200, + { + "id": cid, + "object": "chat.completion", + "created": _now(), + "model": MODEL_ID, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": len(words), + "total_tokens": 12 + len(words), + }, + }, + ) + + if mode == "error_during": + return self._stream(200, lambda w: _stream_error_during(w, cid, words)) + if mode == "no_done": + return self._stream(200, lambda w: _stream_no_done(w, cid, words)) + return self._stream(200, lambda w: _stream_normal(w, cid, words)) + + def log_message(self, format, *args): + # Compact single-line logging to stdout. + print("%s - %s" % (self.log_date_time_string(), format % args), flush=True) + + +def main(): + ap = argparse.ArgumentParser( + description="Mock llama-server for exercising zshell LLM error handling" + ) + ap.add_argument("--port", type=int, default=8080) + ap.add_argument("--host", default="127.0.0.1") + args = ap.parse_args() + + global _host, _port + _host = args.host + _port = args.port + + server = ThreadingHTTPServer((args.host, args.port), Handler) + server.daemon_threads = True + print( + f"mock llama-server on http://{args.host}:{args.port} " + f"(model={MODEL_ID}, ctx={CTX_SIZE})\n" + "type trigger words in the chat UI, or curl /_mock to change state", + flush=True, + ) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/tests/run-qml-tests.sh b/tests/run-qml-tests.sh new file mode 100755 index 0000000..c24a86a --- /dev/null +++ b/tests/run-qml-tests.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DURATION="${1:-${QS_SMOKE_DURATION:-10}}" +QML_IMPORT_PATH="${QML_IMPORT_PATH:-$ROOT/build/qml}" + +if [ ! -d "$QML_IMPORT_PATH/ZShell" ]; then + echo "error: no ZShell plugin at $QML_IMPORT_PATH/ZShell" + echo "build the project first: cmake --build build" + exit 2 +fi + +OUT="$(mktemp /tmp/zshell-smoke.XXXXXX)" +trap 'rm -f "$OUT"' EXIT + +echo "booting shell for ${DURATION}s (a second bar will be visible)" +env QT_QPA_PLATFORM=wayland QML_IMPORT_PATH="$QML_IMPORT_PATH" \ + qs --no-color -p "$ROOT/shell.qml" >"$OUT" 2>&1 & +CLI_PID=$! + +IID="" +for _ in $(seq 1 100); do + IID="$(sed -nE 's|.*by-id/([a-z0-9]+)/log\.qslog.*|\1|p' "$OUT" | head -1)" + [ -n "$IID" ] && break + kill -0 "$CLI_PID" 2>/dev/null || break + sleep 0.2 +done + +if [ -z "$IID" ]; then + echo "FAIL: shell did not start" + cat "$OUT" + exit 2 +fi + +sleep "$DURATION" + +qs kill -i "$IID" >/dev/null 2>&1 +if qs list --all 2>/dev/null | grep -q "Instance $IID"; then + PID="$(qs list --all 2>/dev/null | awk -v id="$IID" ' + /^Instance /{cur = $2} + cur == id && /Process ID:/{print $3}')" + [ -n "$PID" ] && kill "$PID" 2>/dev/null +fi +wait "$CLI_PID" 2>/dev/null + +ERRORS="$(grep -aE '^[[:space:]]*ERROR' "$OUT" || true)" +if [ -n "$ERRORS" ]; then + echo + echo "FAIL: QML errors detected" + echo "$ERRORS" + exit 1 +fi + +WARNINGS="$(grep -aE '^[[:space:]]*WARN' "$OUT" | sed -E 's/^[[:space:]]*WARN[[:space:]]*//' | sort | uniq -c | sort -rn || true)" +echo +if [ -n "$WARNINGS" ]; then + echo "PASS: no errors (${DURATION}s boot). Non-fatal warnings:" + echo "$WARNINGS" +else + echo "PASS: no errors or warnings (${DURATION}s boot)." +fi +exit 0 From 13f1a3fede99c407105d11b34cfc2b66b29712fd Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 17 Sep 2026 15:22:02 +0200 Subject: [PATCH 51/90] chore: format --- Plugins/ZShell/Components/chatlistview.hpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/ZShell/Components/chatlistview.hpp b/Plugins/ZShell/Components/chatlistview.hpp index 91dc1f2..40c8f3c 100644 --- a/Plugins/ZShell/Components/chatlistview.hpp +++ b/Plugins/ZShell/Components/chatlistview.hpp @@ -224,8 +224,8 @@ class ChatListView : public QQuickItem { [[nodiscard]] std::pair computeVisibleRange() const; [[nodiscard]] QRectF effectiveViewport() const; [[nodiscard]] qreal effectiveEstimatedHeight() const; - [[nodiscard]] static bool isStaleRecord(const ItemRecord& record, - qreal viewWidth); + [[nodiscard]] static bool isStaleRecord( + const ItemRecord& record, qreal viewWidth); [[nodiscard]] static qreal delegateHeight(QQuickItem* item); [[nodiscard]] static qreal delegateVisibleHeight(QQuickItem* item); [[nodiscard]] static bool isDelegateReady(QQuickItem* item); From bdfb9fc2417bc32b996ee402249b3b7e8db20d34 Mon Sep 17 00:00:00 2001 From: inorishio Date: Thu, 17 Sep 2026 22:38:51 +0200 Subject: [PATCH 52/90] fixed: mousewheel swallow --- Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml b/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml index e4cd5f8..4b96374 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml @@ -17,7 +17,7 @@ TextEditBase { Qt.openUrlExternally(link); } - CustomMouseArea { + MouseArea { anchors.fill: parent acceptedButtons: Qt.NoButton cursorShape: root.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor From ca51f660ada501090733701a802b7f2b0021d994 Mon Sep 17 00:00:00 2001 From: zach Date: Sat, 19 Sep 2026 09:19:45 +0200 Subject: [PATCH 53/90] chore: format & fix prettierignore --- .prettierignore | 1 + Plugins/ZShell/Config/llm.hpp | 2 +- scripts/mock-llama-server.py | 1139 +++++++++++++++++---------------- 3 files changed, 580 insertions(+), 562 deletions(-) diff --git a/.prettierignore b/.prettierignore index b1e8d68..3d59707 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ .venv/ scripts/fzf.js scripts/fuzzysort.js +scripts/settings-indexer.js diff --git a/Plugins/ZShell/Config/llm.hpp b/Plugins/ZShell/Config/llm.hpp index b04df7d..bdc5742 100644 --- a/Plugins/ZShell/Config/llm.hpp +++ b/Plugins/ZShell/Config/llm.hpp @@ -33,4 +33,4 @@ class Llm : public ConfigObject { : ConfigObject(parent), m_appearance(new LlmAppearance(this)) {} }; -} +} // namespace ZShell::config diff --git a/scripts/mock-llama-server.py b/scripts/mock-llama-server.py index d632cd5..4644ecb 100644 --- a/scripts/mock-llama-server.py +++ b/scripts/mock-llama-server.py @@ -75,644 +75,661 @@ _port = 8080 # Mutable, shared across handler threads. _state_lock = threading.Lock() _state = { - "loading": False, # 503 "Loading model" on /props, /v1/models, /health - "no_models": False, # /v1/models returns an empty list - "chat_mode": None, # force a chat behaviour, overriding keywords + "loading": False, # 503 "Loading model" on /props, /v1/models, /health + "no_models": False, # /v1/models returns an empty list + "chat_mode": None, # force a chat behaviour, overriding keywords } def _now(): - return int(time.time()) + return int(time.time()) def _chunk(cid, delta, finish_reason=None): - return { - "id": cid, - "object": "chat.completion.chunk", - "created": _now(), - "model": MODEL_ID, - "system_fingerprint": "mock", - "choices": [ - { - "index": 0, - "delta": delta, - "finish_reason": finish_reason, - } - ], - } + return { + "id": cid, + "object": "chat.completion.chunk", + "created": _now(), + "model": MODEL_ID, + "system_fingerprint": "mock", + "choices": [ + { + "index": 0, + "delta": delta, + "finish_reason": finish_reason, + } + ], + } def _usage_chunk(cid, prompt_tokens, completion_tokens): - return { - "id": cid, - "object": "chat.completion.chunk", - "created": _now(), - "model": MODEL_ID, - "choices": [], - "usage": { - "prompt_tokens": prompt_tokens, - "completion_tokens": completion_tokens, - "total_tokens": prompt_tokens + completion_tokens, - }, - } + return { + "id": cid, + "object": "chat.completion.chunk", + "created": _now(), + "model": MODEL_ID, + "choices": [], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": prompt_tokens + completion_tokens, + }, + } def _error(code, message, etype): - return {"error": {"code": code, "message": message, "type": etype}} + return {"error": {"code": code, "message": message, "type": etype}} def _sse(obj): - # Exact llama.cpp format_oai_sse shape: "data: " + compact json + "\n\n". - return ("data: " + json.dumps(obj, separators=(",", ":")) + "\n\n").encode() + # Exact llama.cpp format_oai_sse shape: "data: " + compact json + "\n\n". + return ("data: " + json.dumps(obj, separators=(",", ":")) + "\n\n").encode() def _sse_done(): - return b"data: [DONE]\n\n" + return b"data: [DONE]\n\n" # Chat behaviours. Each returns ("json", status, body_bytes) for a # non-streaming answer, or ("sse", 200, None) and then writes to the # provided stream writer. def _behaviour_for(text): - t = text.lower() - if "instant error" in t: - return "instant_error" - if "context overflow" in t: - return "context_overflow" - if "error during" in t: - return "error_during" - if "no done" in t or "truncate" in t: - return "no_done" - if "200 error" in t: - return "200_error" - # Order matters: "multiple tool calls" contains "tool call". - if "two tool calls" in t or "two tool call" in t: - return "tool_call_duo" - if "some tool calls" in t: - return "tool_call_some" - if "many tool calls" in t: - return "tool_call_many" - if "unknown tool" in t: - return "tool_call_unknown" - if "tool call" in t: - return "tool_call" - return "normal" + t = text.lower() + if "instant error" in t: + return "instant_error" + if "context overflow" in t: + return "context_overflow" + if "error during" in t: + return "error_during" + if "no done" in t or "truncate" in t: + return "no_done" + if "200 error" in t: + return "200_error" + # Order matters: "multiple tool calls" contains "tool call". + if "two tool calls" in t or "two tool call" in t: + return "tool_call_duo" + if "some tool calls" in t: + return "tool_call_some" + if "many tool calls" in t: + return "tool_call_many" + if "unknown tool" in t: + return "tool_call_unknown" + if "tool call" in t: + return "tool_call" + return "normal" -_TOOL_MODES = ("tool_call", "tool_call_duo", "tool_call_some", "tool_call_many", "tool_call_unknown") +_TOOL_MODES = ( + "tool_call", + "tool_call_duo", + "tool_call_some", + "tool_call_many", + "tool_call_unknown", +) def _tool_call_delta(index, call_id=None, name=None, arguments=None): - call = {"index": index} - if call_id is not None: - call["id"] = call_id - if name is not None or arguments is not None: - function = {} - if name is not None: - function["name"] = name - if arguments is not None: - function["arguments"] = arguments - call["function"] = function - return {"tool_calls": [call]} + call = {"index": index} + if call_id is not None: + call["id"] = call_id + if name is not None or arguments is not None: + function = {} + if name is not None: + function["name"] = name + if arguments is not None: + function["arguments"] = arguments + call["function"] = function + return {"tool_calls": [call]} def _stream_tool_calls(w, cid, reasoning, calls): - # One tool round: optional reasoning, then the calls (llama.cpp - # streams the id/name with the first fragment of each call and the - # arguments as successive fragments), then finish_reason - # "tool_calls". The client concatenates the argument fragments and - # shows the permission dialog on stream end. - w(_sse(_chunk(cid, {"role": "assistant"}))) - if reasoning: - for piece in reasoning: - w(_sse(_chunk(cid, {"reasoning_content": piece}))) - time.sleep(0.05) - for i, (call_id, name, arguments) in enumerate(calls): - w(_sse(_chunk(cid, _tool_call_delta(i, call_id=call_id, name=name)))) - time.sleep(0.05) - for fragment in arguments: - w( - _sse( - _chunk(cid, _tool_call_delta(i, arguments=fragment)) - ) - ) - time.sleep(0.05) - w(_sse(_chunk(cid, {}, finish_reason="tool_calls"))) - w(_sse(_usage_chunk(cid, 24, len(reasoning or []) + len(calls) * 2))) - w(_sse_done()) + # One tool round: optional reasoning, then the calls (llama.cpp + # streams the id/name with the first fragment of each call and the + # arguments as successive fragments), then finish_reason + # "tool_calls". The client concatenates the argument fragments and + # shows the permission dialog on stream end. + w(_sse(_chunk(cid, {"role": "assistant"}))) + if reasoning: + for piece in reasoning: + w(_sse(_chunk(cid, {"reasoning_content": piece}))) + time.sleep(0.05) + for i, (call_id, name, arguments) in enumerate(calls): + w(_sse(_chunk(cid, _tool_call_delta(i, call_id=call_id, name=name)))) + time.sleep(0.05) + for fragment in arguments: + w(_sse(_chunk(cid, _tool_call_delta(i, arguments=fragment)))) + time.sleep(0.05) + w(_sse(_chunk(cid, {}, finish_reason="tool_calls"))) + w(_sse(_usage_chunk(cid, 24, len(reasoning or []) + len(calls) * 2))) + w(_sse_done()) def _tool_result_snippets(messages, limit=160): - snippets = [] - for msg in messages or []: - if msg.get("role") != "tool": - continue - content = msg.get("content") - if isinstance(content, list): - content = " ".join( - part.get("text", "") - for part in content - if isinstance(part, dict) and part.get("type") == "text" - ) - flat = " ".join(str(content or "").split()) - snippets.append(flat[:limit] + ("..." if len(flat) > limit else "")) - return snippets + snippets = [] + for msg in messages or []: + if msg.get("role") != "tool": + continue + content = msg.get("content") + if isinstance(content, list): + content = " ".join( + part.get("text", "") + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ) + flat = " ".join(str(content or "").split()) + snippets.append(flat[:limit] + ("..." if len(flat) > limit else "")) + return snippets def _stream_tool_round_done(w, cid, messages): - # Follow-up round: the client sent the tool results back; answer - # with a plain content summary so the turn completes. - w(_sse(_chunk(cid, {"role": "assistant"}))) - parts = ["mock:", "tool", "round", "complete."] - for i, snippet in enumerate(_tool_result_snippets(messages), 1): - parts.append(f"result {i}:") - parts.append(snippet or "(empty)") - for word in parts: - w(_sse(_chunk(cid, {"content": word + " "}))) - time.sleep(0.02) - w(_sse(_chunk(cid, {}, finish_reason="stop"))) - w(_sse(_usage_chunk(cid, 40, len(parts) + 1))) - w(_sse_done()) + # Follow-up round: the client sent the tool results back; answer + # with a plain content summary so the turn completes. + w(_sse(_chunk(cid, {"role": "assistant"}))) + parts = ["mock:", "tool", "round", "complete."] + for i, snippet in enumerate(_tool_result_snippets(messages), 1): + parts.append(f"result {i}:") + parts.append(snippet or "(empty)") + for word in parts: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.02) + w(_sse(_chunk(cid, {}, finish_reason="stop"))) + w(_sse(_usage_chunk(cid, 40, len(parts) + 1))) + w(_sse_done()) def _stream_normal(w, cid, words): - w(_sse(_chunk(cid, {"role": "assistant"}))) - for word in words: - w(_sse(_chunk(cid, {"content": word + " "}))) - time.sleep(0.05) - w(_sse(_chunk(cid, {}, finish_reason="stop"))) - w(_sse(_usage_chunk(cid, 12, len(words) + 1))) - w(_sse_done()) + w(_sse(_chunk(cid, {"role": "assistant"}))) + for word in words: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.05) + w(_sse(_chunk(cid, {}, finish_reason="stop"))) + w(_sse(_usage_chunk(cid, 12, len(words) + 1))) + w(_sse_done()) def _stream_error_during(w, cid, words): - w(_sse(_chunk(cid, {"role": "assistant"}))) - for word in words: - w(_sse(_chunk(cid, {"content": word + " "}))) - time.sleep(0.05) - # Mid-stream failure: error event, then the connection closes without - # [DONE] (llama.cpp terminates the stream on a task error). - w(_sse(_error(500, "Mock: generation failed mid-stream", "server_error"))) + w(_sse(_chunk(cid, {"role": "assistant"}))) + for word in words: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.05) + # Mid-stream failure: error event, then the connection closes without + # [DONE] (llama.cpp terminates the stream on a task error). + w(_sse(_error(500, "Mock: generation failed mid-stream", "server_error"))) def _stream_no_done(w, cid, words): - w(_sse(_chunk(cid, {"role": "assistant"}))) - for word in words: - w(_sse(_chunk(cid, {"content": word + " "}))) - time.sleep(0.05) - # Clean FIN, no [DONE], no error: server died mid-response. + w(_sse(_chunk(cid, {"role": "assistant"}))) + for word in words: + w(_sse(_chunk(cid, {"content": word + " "}))) + time.sleep(0.05) + # Clean FIN, no [DONE], no error: server died mid-response. def _last_user_text(messages): - for msg in reversed(messages or []): - if msg.get("role") == "user": - content = msg.get("content") - if isinstance(content, list): - # multimodal shape: pull out the text parts - parts = [] - for part in content: - if isinstance(part, dict) and part.get("type") == "text": - parts.append(part.get("text", "")) - content = " ".join(parts) - return content or "" - return "" + for msg in reversed(messages or []): + if msg.get("role") == "user": + content = msg.get("content") + if isinstance(content, list): + # multimodal shape: pull out the text parts + parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + parts.append(part.get("text", "")) + content = " ".join(parts) + return content or "" + return "" class Handler(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - server_version = "MockLlama/1.0" + protocol_version = "HTTP/1.1" + server_version = "MockLlama/1.0" - # --- low-level response helpers ----------------------------------- - def _send_json(self, status, obj): - body = json.dumps(obj, separators=(",", ":")).encode() - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(body) + # --- low-level response helpers ----------------------------------- + def _send_json(self, status, obj): + body = json.dumps(obj, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) - def _send_loading_error(self): - # llama.cpp middleware: server not ready -> 503 on every route. - self._send_json( - 503, _error(503, "Loading model", "unavailable_error") - ) + def _send_loading_error(self): + # llama.cpp middleware: server not ready -> 503 on every route. + self._send_json(503, _error(503, "Loading model", "unavailable_error")) - def _not_ready(self): - with _state_lock: - return _state["loading"] + def _not_ready(self): + with _state_lock: + return _state["loading"] - def _stream(self, status, writer): - self.send_response(status) - self.send_header("Content-Type", "text/event-stream") - self.send_header("Cache-Control", "no-cache") - self.send_header("Connection", "close") - self.end_headers() - try: - writer(self.wfile.write) - except (BrokenPipeError, ConnectionResetError): - pass - finally: - try: - self.wfile.flush() - except (BrokenPipeError, ConnectionResetError): - pass + def _stream(self, status, writer): + self.send_response(status) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "close") + self.end_headers() + try: + writer(self.wfile.write) + except (BrokenPipeError, ConnectionResetError): + pass + finally: + try: + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + pass - # --- routing ------------------------------------------------------- - def do_GET(self): - path = self.path.split("?", 1)[0].rstrip("/") or "/" - if path in ("/health", "/v1/health"): - if self._not_ready(): - return self._send_loading_error() - return self._send_json(200, {"status": "ok"}) - if path in ("/props", "/v1/props"): - if self._not_ready(): - return self._send_loading_error() - return self._send_json( - 200, - { - "total_slots": 1, - "model_alias": MODEL_ID, - "model_path": "/mock/model.gguf", - "default_generation_settings": { - "params": {"n_predict": 2048}, - "n_ctx": CTX_SIZE, - }, - "is_sleeping": False, - }, - ) - if path in ("/models", "/v1/models"): - if self._not_ready(): - return self._send_loading_error() - with _state_lock: - no_models = _state["no_models"] - data = [] if no_models else [ - { - "id": MODEL_ID, - "object": "model", - "created": _now(), - "owned_by": "mock", - } - ] - return self._send_json(200, {"object": "list", "data": data}) - if path == "/": - return self._send_json(200, {"status": "ok", "mock": True}) - return self._send_json(404, _error(404, "not found", "not_found_error")) + # --- routing ------------------------------------------------------- + def do_GET(self): + path = self.path.split("?", 1)[0].rstrip("/") or "/" + if path in ("/health", "/v1/health"): + if self._not_ready(): + return self._send_loading_error() + return self._send_json(200, {"status": "ok"}) + if path in ("/props", "/v1/props"): + if self._not_ready(): + return self._send_loading_error() + return self._send_json( + 200, + { + "total_slots": 1, + "model_alias": MODEL_ID, + "model_path": "/mock/model.gguf", + "default_generation_settings": { + "params": {"n_predict": 2048}, + "n_ctx": CTX_SIZE, + }, + "is_sleeping": False, + }, + ) + if path in ("/models", "/v1/models"): + if self._not_ready(): + return self._send_loading_error() + with _state_lock: + no_models = _state["no_models"] + data = ( + [] + if no_models + else [ + { + "id": MODEL_ID, + "object": "model", + "created": _now(), + "owned_by": "mock", + } + ] + ) + return self._send_json(200, {"object": "list", "data": data}) + if path == "/": + return self._send_json(200, {"status": "ok", "mock": True}) + return self._send_json(404, _error(404, "not found", "not_found_error")) - def do_POST(self): - path = self.path.split("?", 1)[0].rstrip("/") or "/" - length = int(self.headers.get("Content-Length") or 0) - raw = self.rfile.read(length) if length else b"" - try: - body = json.loads(raw) if raw else {} - except json.JSONDecodeError: - body = {} + def do_POST(self): + path = self.path.split("?", 1)[0].rstrip("/") or "/" + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + try: + body = json.loads(raw) if raw else {} + except json.JSONDecodeError: + body = {} - if path == "/_mock": - return self._mock_control(body) + if path == "/_mock": + return self._mock_control(body) - if path in ("/chat/completions", "/v1/chat/completions"): - if self._not_ready(): - return self._send_loading_error() - return self._chat(body) + if path in ("/chat/completions", "/v1/chat/completions"): + if self._not_ready(): + return self._send_loading_error() + return self._chat(body) - return self._send_json(404, _error(404, "not found", "not_found_error")) + return self._send_json(404, _error(404, "not found", "not_found_error")) - # --- /_mock control ------------------------------------------------ - def _mock_control(self, body): - with _state_lock: - if body.get("reset"): - _state["loading"] = False - _state["no_models"] = False - _state["chat_mode"] = None - if "loading" in body: - _state["loading"] = bool(body["loading"]) - if "no_models" in body: - _state["no_models"] = bool(body["no_models"]) - if "chat_mode" in body: - _state["chat_mode"] = body["chat_mode"] - snapshot = dict(_state) - self.log_message("control -> %s", json.dumps(snapshot)) - return self._send_json(200, {"ok": True, "state": snapshot}) + # --- /_mock control ------------------------------------------------ + def _mock_control(self, body): + with _state_lock: + if body.get("reset"): + _state["loading"] = False + _state["no_models"] = False + _state["chat_mode"] = None + if "loading" in body: + _state["loading"] = bool(body["loading"]) + if "no_models" in body: + _state["no_models"] = bool(body["no_models"]) + if "chat_mode" in body: + _state["chat_mode"] = body["chat_mode"] + snapshot = dict(_state) + self.log_message("control -> %s", json.dumps(snapshot)) + return self._send_json(200, {"ok": True, "state": snapshot}) - # --- /chat/completions -------------------------------------------- - def _chat(self, body): - text = _last_user_text(body.get("messages")) - with _state_lock: - forced = _state["chat_mode"] - mode = forced or _behaviour_for(text) - stream = bool(body.get("stream", True)) - cid = "chatcmpl-mock" - words = ["mock", "says", "hello", "from", "the", "mock", "server"] - self.log_message("chat mode=%s stream=%s text=%r", mode, stream, text) + # --- /chat/completions -------------------------------------------- + def _chat(self, body): + text = _last_user_text(body.get("messages")) + with _state_lock: + forced = _state["chat_mode"] + mode = forced or _behaviour_for(text) + stream = bool(body.get("stream", True)) + cid = "chatcmpl-mock" + words = ["mock", "says", "hello", "from", "the", "mock", "server"] + self.log_message("chat mode=%s stream=%s text=%r", mode, stream, text) - if mode == "instant_error": - # Error before the first token: plain HTTP error, not a stream. - return self._send_json( - 500, _error(500, "Mock: instant server error", "server_error") - ) + if mode == "instant_error": + # Error before the first token: plain HTTP error, not a stream. + return self._send_json( + 500, _error(500, "Mock: instant server error", "server_error") + ) - if mode == "context_overflow": - return self._send_json( - 400, - _error( - 400, - "request (100000 tokens) exceeds the available context " - "size (2048 tokens), try increasing it", - "exceed_context_size_error", - ), - ) + if mode == "context_overflow": + return self._send_json( + 400, + _error( + 400, + "request (100000 tokens) exceeds the available context " + "size (2048 tokens), try increasing it", + "exceed_context_size_error", + ), + ) - if mode == "200_error": - # 200 with a plain JSON error body (not SSE) -- the case the - # data: filter would otherwise swallow. - return self._send_json( - 200, _error(400, "Mock: 200-with-json-error", "server_error") - ) + if mode == "200_error": + # 200 with a plain JSON error body (not SSE) -- the case the + # data: filter would otherwise swallow. + return self._send_json( + 200, _error(400, "Mock: 200-with-json-error", "server_error") + ) - if mode in _TOOL_MODES: - # Continuation round: the previous round's tool results came - # back through the client (role: "tool" messages). - if any(m.get("role") == "tool" for m in body.get("messages") or []): - return self._stream( - 200, - lambda w: _stream_tool_round_done( - w, cid, body.get("messages") - ), - ) - if mode == "tool_call": - calls = [ - ( - "call_mock_1", - "readfile", - ['{"path": "/etc/', "os-release\"}"], - ) - ] - reasoning = [ - "The user wants a tool call. ", - "I will read /etc/os-release.", - ] - elif mode == "tool_call_duo": - calls = [ - ( - "call_mock_1", - "readfile", - ['{"path": "/etc/', "hostname\"}"], - ), - ( - "call_mock_2", - "webfetch", - [ - '{"url": "http://%s:%d/", ' % (_host, _port), - '"format": "text"}', - ], - ), - ] - reasoning = ["Two independent tool calls, ", - "batched together."] - elif mode == "tool_call_some": - calls = [ - ( - "call_mock_1", - "readfile", - ['{"path": "/etc/', "hostname\"}"], - ), - ( - "call_mock_2", - "webfetch", - [ - '{"url": "http://%s:%d/", ' % (_host, _port), - '"format": "text"}', - ], - ), - ( - "call_mock_3", - "readfile", - ['{"path": "/etc/', "os-release\"}"], - ), - ( - "call_mock_4", - "webfetch", - [ - '{"url": "http://%s:%d/", ' % (_host, _port), - '"format": "text"}', - ], - ), - ( - "call_mock_5", - "readfile", - ['{"path": "/proc/', "uptime\"}"], - ), - ( - "call_mock_6", - "webfetch", - [ - '{"url": "http://%s:%d/", ' % (_host, _port), - '"format": "text"}', - ], - ), - ] - reasoning = ["Two independent tool calls, ", - "batched together."] - elif mode == "tool_call_many": - calls = [ - ( - "call_mock_1", - "readfile", - ['{"path": "/etc/', "hostname\"}"], - ), - ( - "call_mock_2", - "webfetch", - [ - '{"url": "http://%s:%d/", ' % (_host, _port), - '"format": "text"}', - ], - ), - ( - "call_mock_3", - "readfile", - ['{"path": "/etc/', "os-release\"}"], - ), - ( - "call_mock_4", - "webfetch", - [ - '{"url": "http://%s:%d/", ' % (_host, _port), - '"format": "text"}', - ], - ), - ( - "call_mock_5", - "readfile", - ['{"path": "/proc/', "uptime\"}"], - ), - ( - "call_mock_6", - "webfetch", - [ - '{"url": "http://%s:%d/", ' % (_host, _port), - '"format": "text"}', - ], - ), - ( - "call_mock_1", - "readfile", - ['{"path": "/etc/', "hostname\"}"], - ), - ( - "call_mock_2", - "webfetch", - [ - '{"url": "http://%s:%d/", ' % (_host, _port), - '"format": "text"}', - ], - ), - ( - "call_mock_3", - "readfile", - ['{"path": "/etc/', "os-release\"}"], - ), - ( - "call_mock_4", - "webfetch", - [ - '{"url": "http://%s:%d/", ' % (_host, _port), - '"format": "text"}', - ], - ), - ( - "call_mock_5", - "readfile", - ['{"path": "/proc/', "uptime\"}"], - ), - ( - "call_mock_6", - "webfetch", - [ - '{"url": "http://%s:%d/", ' % (_host, _port), - '"format": "text"}', - ], - ), - ( - "call_mock_1", - "readfile", - ['{"path": "/etc/', "hostname\"}"], - ), - ( - "call_mock_2", - "webfetch", - [ - '{"url": "http://%s:%d/", ' % (_host, _port), - '"format": "text"}', - ], - ), - ( - "call_mock_3", - "readfile", - ['{"path": "/etc/', "os-release\"}"], - ), - ( - "call_mock_4", - "webfetch", - [ - '{"url": "http://%s:%d/", ' % (_host, _port), - '"format": "text"}', - ], - ), - ( - "call_mock_5", - "readfile", - ['{"path": "/proc/', "uptime\"}"], - ), - ( - "call_mock_6", - "webfetch", - [ - '{"url": "http://%s:%d/", ' % (_host, _port), - '"format": "text"}', - ], - ), - ] - reasoning = ["Two independent tool calls, ", - "batched together."] - else: # tool_call_unknown - calls = [ - ( - "call_mock_3", - "nonexistent_tool", - ['{"target": "test"}'], - ) - ] - reasoning = ["Calling a tool the client ", "does not have."] - self.log_message( - "tool mode=%s calls=%s", mode, [c[1] for c in calls] - ) - return self._stream( - 200, lambda w: _stream_tool_calls(w, cid, reasoning, calls) - ) + if mode in _TOOL_MODES: + # Continuation round: the previous round's tool results came + # back through the client (role: "tool" messages). + if any(m.get("role") == "tool" for m in body.get("messages") or []): + return self._stream( + 200, + lambda w: _stream_tool_round_done( + w, cid, body.get("messages") + ), + ) + if mode == "tool_call": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', 'os-release"}'], + ) + ] + reasoning = [ + "The user wants a tool call. ", + "I will read /etc/os-release.", + ] + elif mode == "tool_call_duo": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', 'hostname"}'], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ] + reasoning = [ + "Two independent tool calls, ", + "batched together.", + ] + elif mode == "tool_call_some": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', 'hostname"}'], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', 'os-release"}'], + ), + ( + "call_mock_4", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', 'uptime"}'], + ), + ( + "call_mock_6", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ] + reasoning = [ + "Two independent tool calls, ", + "batched together.", + ] + elif mode == "tool_call_many": + calls = [ + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', 'hostname"}'], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', 'os-release"}'], + ), + ( + "call_mock_4", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', 'uptime"}'], + ), + ( + "call_mock_6", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', 'hostname"}'], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', 'os-release"}'], + ), + ( + "call_mock_4", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', 'uptime"}'], + ), + ( + "call_mock_6", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_1", + "readfile", + ['{"path": "/etc/', 'hostname"}'], + ), + ( + "call_mock_2", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_3", + "readfile", + ['{"path": "/etc/', 'os-release"}'], + ), + ( + "call_mock_4", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ( + "call_mock_5", + "readfile", + ['{"path": "/proc/', 'uptime"}'], + ), + ( + "call_mock_6", + "webfetch", + [ + '{"url": "http://%s:%d/", ' % (_host, _port), + '"format": "text"}', + ], + ), + ] + reasoning = [ + "Two independent tool calls, ", + "batched together.", + ] + else: # tool_call_unknown + calls = [ + ( + "call_mock_3", + "nonexistent_tool", + ['{"target": "test"}'], + ) + ] + reasoning = ["Calling a tool the client ", "does not have."] + self.log_message( + "tool mode=%s calls=%s", mode, [c[1] for c in calls] + ) + return self._stream( + 200, lambda w: _stream_tool_calls(w, cid, reasoning, calls) + ) - if not stream: - # Non-streaming success: single chat.completion object. - content = " ".join(words) - return self._send_json( - 200, - { - "id": cid, - "object": "chat.completion", - "created": _now(), - "model": MODEL_ID, - "choices": [ - { - "index": 0, - "message": {"role": "assistant", "content": content}, - "finish_reason": "stop", - } - ], - "usage": { - "prompt_tokens": 12, - "completion_tokens": len(words), - "total_tokens": 12 + len(words), - }, - }, - ) + if not stream: + # Non-streaming success: single chat.completion object. + content = " ".join(words) + return self._send_json( + 200, + { + "id": cid, + "object": "chat.completion", + "created": _now(), + "model": MODEL_ID, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": content, + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 12, + "completion_tokens": len(words), + "total_tokens": 12 + len(words), + }, + }, + ) - if mode == "error_during": - return self._stream(200, lambda w: _stream_error_during(w, cid, words)) - if mode == "no_done": - return self._stream(200, lambda w: _stream_no_done(w, cid, words)) - return self._stream(200, lambda w: _stream_normal(w, cid, words)) + if mode == "error_during": + return self._stream( + 200, lambda w: _stream_error_during(w, cid, words) + ) + if mode == "no_done": + return self._stream(200, lambda w: _stream_no_done(w, cid, words)) + return self._stream(200, lambda w: _stream_normal(w, cid, words)) - def log_message(self, format, *args): - # Compact single-line logging to stdout. - print("%s - %s" % (self.log_date_time_string(), format % args), flush=True) + def log_message(self, format, *args): + # Compact single-line logging to stdout. + print( + "%s - %s" % (self.log_date_time_string(), format % args), flush=True + ) def main(): - ap = argparse.ArgumentParser( - description="Mock llama-server for exercising zshell LLM error handling" - ) - ap.add_argument("--port", type=int, default=8080) - ap.add_argument("--host", default="127.0.0.1") - args = ap.parse_args() + ap = argparse.ArgumentParser( + description="Mock llama-server for exercising zshell LLM error handling" + ) + ap.add_argument("--port", type=int, default=8080) + ap.add_argument("--host", default="127.0.0.1") + args = ap.parse_args() - global _host, _port - _host = args.host - _port = args.port + global _host, _port + _host = args.host + _port = args.port - server = ThreadingHTTPServer((args.host, args.port), Handler) - server.daemon_threads = True - print( - f"mock llama-server on http://{args.host}:{args.port} " - f"(model={MODEL_ID}, ctx={CTX_SIZE})\n" - "type trigger words in the chat UI, or curl /_mock to change state", - flush=True, - ) - try: - server.serve_forever() - except KeyboardInterrupt: - pass + server = ThreadingHTTPServer((args.host, args.port), Handler) + server.daemon_threads = True + print( + f"mock llama-server on http://{args.host}:{args.port} " + f"(model={MODEL_ID}, ctx={CTX_SIZE})\n" + "type trigger words in the chat UI, or curl /_mock to change state", + flush=True, + ) + try: + server.serve_forever() + except KeyboardInterrupt: + pass if __name__ == "__main__": - main() + main() From 83bc153ee38f40219a3bd70c7bb9151ba3e15091 Mon Sep 17 00:00:00 2001 From: zach Date: Sat, 19 Sep 2026 10:13:46 +0200 Subject: [PATCH 54/90] fix: format and apply lint fixes in python script --- scripts/mock-llama-server.py | 138 ++++++----------------------------- 1 file changed, 21 insertions(+), 117 deletions(-) diff --git a/scripts/mock-llama-server.py b/scripts/mock-llama-server.py index 4644ecb..ee7f011 100644 --- a/scripts/mock-llama-server.py +++ b/scripts/mock-llama-server.py @@ -1,64 +1,5 @@ -#!/usr/bin/env python3 -"""Mock llama-server for exercising zshell's LLM error handling. - -Implements the OpenAI-compatible subset of llama-server that the C++ -backend talks to, and lets you trigger specific failure modes from the -QML chat UI by typing a keyword as your message. - -Wire format is taken from ggml-org/llama.cpp (tools/server): - - SSE events are "data: \n\n" (note the space after "data:"). - - A clean stream ends with "data: [DONE]\n\n". - - Errors are {"error": {"code", "message", "type"}}. - - A stream that dies mid-way sends the error event, then closes - WITHOUT [DONE]. - - A request that errors before the first token is a plain HTTP 4xx/5xx - with the error JSON body (not a stream). - - /props is a JSON object; n_ctx lives under - default_generation_settings.n_ctx. - -Run: - python3 scripts/mock-llama-server.py [--port 8080] - -Then point zshell's LLM endpoint at http://localhost:8080. - -Trigger words (type as the chat message, case-insensitive): - normal -> healthy stream, ends with [DONE] (also the default) - instant error -> 500 before any token ("server_error") - context overflow -> 400 "exceeds the available context size (2048 ...)"; - backend retries once at 2048, then fails - error during -> streams a few tokens, then an SSE error event, - closes without [DONE] - no done -> streams a few tokens, closes without [DONE] and - without an error (server killed mid-response) - 200 error -> HTTP 200 with a plain JSON error body (not SSE) - tool call -> streams reasoning, then a tool_calls round calling - readfile on /etc/os-release (finish_reason - "tool_calls"); after you approve (or deny) the - permission dialog, the follow-up round that - carries the tool result gets a normal summary - answer. Exercises the tool permission dialog, - tool execution and the multi-round transcript. - multiple tool -> two tool calls in one round (readfile + webfetch - calls of this mock's own / endpoint); approve both at - once - unknown tool -> a tool call for a tool the client does not have; - approving it yields an "unknown tool" error - result - -Control endpoint (for the non-chat routes / global state), via curl: - curl -XPOST localhost:8080/_mock -d '{"loading": true}' - /props, /v1/models, /health all answer 503 "Loading model" - curl -XPOST localhost:8080/_mock -d '{"no_models": true}' - /v1/models returns an empty list - curl -XPOST localhost:8080/_mock -d '{"chat_mode": "error_during"}' - force a chat behaviour regardless of the message text - (values: normal|instant_error|context_overflow|error_during| - no_done|200_error|tool_call|tool_call_multi| - tool_call_unknown, or null to go back to keywords) - curl -XPOST localhost:8080/_mock -d '{"reset": true}' -""" - import argparse +import contextlib import json import threading import time @@ -67,17 +8,14 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer MODEL_ID = "mock-model" CTX_SIZE = 8192 -# Set in main(); used by the tool-call behaviours so webfetch can point -# at this server itself. _host = "127.0.0.1" _port = 8080 -# Mutable, shared across handler threads. _state_lock = threading.Lock() _state = { - "loading": False, # 503 "Loading model" on /props, /v1/models, /health - "no_models": False, # /v1/models returns an empty list - "chat_mode": None, # force a chat behaviour, overriding keywords + "loading": False, + "no_models": False, + "chat_mode": None, } @@ -122,7 +60,6 @@ def _error(code, message, etype): def _sse(obj): - # Exact llama.cpp format_oai_sse shape: "data: " + compact json + "\n\n". return ("data: " + json.dumps(obj, separators=(",", ":")) + "\n\n").encode() @@ -130,9 +67,6 @@ def _sse_done(): return b"data: [DONE]\n\n" -# Chat behaviours. Each returns ("json", status, body_bytes) for a -# non-streaming answer, or ("sse", 200, None) and then writes to the -# provided stream writer. def _behaviour_for(text): t = text.lower() if "instant error" in t: @@ -145,7 +79,6 @@ def _behaviour_for(text): return "no_done" if "200 error" in t: return "200_error" - # Order matters: "multiple tool calls" contains "tool call". if "two tool calls" in t or "two tool call" in t: return "tool_call_duo" if "some tool calls" in t: @@ -183,11 +116,6 @@ def _tool_call_delta(index, call_id=None, name=None, arguments=None): def _stream_tool_calls(w, cid, reasoning, calls): - # One tool round: optional reasoning, then the calls (llama.cpp - # streams the id/name with the first fragment of each call and the - # arguments as successive fragments), then finish_reason - # "tool_calls". The client concatenates the argument fragments and - # shows the permission dialog on stream end. w(_sse(_chunk(cid, {"role": "assistant"}))) if reasoning: for piece in reasoning: @@ -222,8 +150,6 @@ def _tool_result_snippets(messages, limit=160): def _stream_tool_round_done(w, cid, messages): - # Follow-up round: the client sent the tool results back; answer - # with a plain content summary so the turn completes. w(_sse(_chunk(cid, {"role": "assistant"}))) parts = ["mock:", "tool", "round", "complete."] for i, snippet in enumerate(_tool_result_snippets(messages), 1): @@ -252,8 +178,6 @@ def _stream_error_during(w, cid, words): for word in words: w(_sse(_chunk(cid, {"content": word + " "}))) time.sleep(0.05) - # Mid-stream failure: error event, then the connection closes without - # [DONE] (llama.cpp terminates the stream on a task error). w(_sse(_error(500, "Mock: generation failed mid-stream", "server_error"))) @@ -262,7 +186,6 @@ def _stream_no_done(w, cid, words): for word in words: w(_sse(_chunk(cid, {"content": word + " "}))) time.sleep(0.05) - # Clean FIN, no [DONE], no error: server died mid-response. def _last_user_text(messages): @@ -270,7 +193,6 @@ def _last_user_text(messages): if msg.get("role") == "user": content = msg.get("content") if isinstance(content, list): - # multimodal shape: pull out the text parts parts = [] for part in content: if isinstance(part, dict) and part.get("type") == "text": @@ -284,7 +206,6 @@ class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" server_version = "MockLlama/1.0" - # --- low-level response helpers ----------------------------------- def _send_json(self, status, obj): body = json.dumps(obj, separators=(",", ":")).encode() self.send_response(status) @@ -295,7 +216,6 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(body) def _send_loading_error(self): - # llama.cpp middleware: server not ready -> 503 on every route. self._send_json(503, _error(503, "Loading model", "unavailable_error")) def _not_ready(self): @@ -313,12 +233,9 @@ class Handler(BaseHTTPRequestHandler): except (BrokenPipeError, ConnectionResetError): pass finally: - try: + with contextlib.suppress(BrokenPipeError, ConnectionResetError): self.wfile.flush() - except (BrokenPipeError, ConnectionResetError): - pass - # --- routing ------------------------------------------------------- def do_GET(self): path = self.path.split("?", 1)[0].rstrip("/") or "/" if path in ("/health", "/v1/health"): @@ -382,7 +299,6 @@ class Handler(BaseHTTPRequestHandler): return self._send_json(404, _error(404, "not found", "not_found_error")) - # --- /_mock control ------------------------------------------------ def _mock_control(self, body): with _state_lock: if body.get("reset"): @@ -399,7 +315,6 @@ class Handler(BaseHTTPRequestHandler): self.log_message("control -> %s", json.dumps(snapshot)) return self._send_json(200, {"ok": True, "state": snapshot}) - # --- /chat/completions -------------------------------------------- def _chat(self, body): text = _last_user_text(body.get("messages")) with _state_lock: @@ -411,7 +326,6 @@ class Handler(BaseHTTPRequestHandler): self.log_message("chat mode=%s stream=%s text=%r", mode, stream, text) if mode == "instant_error": - # Error before the first token: plain HTTP error, not a stream. return self._send_json( 500, _error(500, "Mock: instant server error", "server_error") ) @@ -428,15 +342,11 @@ class Handler(BaseHTTPRequestHandler): ) if mode == "200_error": - # 200 with a plain JSON error body (not SSE) -- the case the - # data: filter would otherwise swallow. return self._send_json( 200, _error(400, "Mock: 200-with-json-error", "server_error") ) if mode in _TOOL_MODES: - # Continuation round: the previous round's tool results came - # back through the client (role: "tool" messages). if any(m.get("role") == "tool" for m in body.get("messages") or []): return self._stream( 200, @@ -467,7 +377,7 @@ class Handler(BaseHTTPRequestHandler): "call_mock_2", "webfetch", [ - '{"url": "http://%s:%d/", ' % (_host, _port), + f'{{"url": "http://{_host}:{_port}/", ', '"format": "text"}', ], ), @@ -487,7 +397,7 @@ class Handler(BaseHTTPRequestHandler): "call_mock_2", "webfetch", [ - '{"url": "http://%s:%d/", ' % (_host, _port), + f'{{"url": "http://{_host}:{_port}/", ', '"format": "text"}', ], ), @@ -500,7 +410,7 @@ class Handler(BaseHTTPRequestHandler): "call_mock_4", "webfetch", [ - '{"url": "http://%s:%d/", ' % (_host, _port), + f'{{"url": "http://{_host}:{_port}/", ', '"format": "text"}', ], ), @@ -513,7 +423,7 @@ class Handler(BaseHTTPRequestHandler): "call_mock_6", "webfetch", [ - '{"url": "http://%s:%d/", ' % (_host, _port), + f'{{"url": "http://{_host}:{_port}/", ', '"format": "text"}', ], ), @@ -533,7 +443,7 @@ class Handler(BaseHTTPRequestHandler): "call_mock_2", "webfetch", [ - '{"url": "http://%s:%d/", ' % (_host, _port), + f'{{"url": "http://{_host}:{_port}/", ', '"format": "text"}', ], ), @@ -546,7 +456,7 @@ class Handler(BaseHTTPRequestHandler): "call_mock_4", "webfetch", [ - '{"url": "http://%s:%d/", ' % (_host, _port), + f'{{"url": "http://{_host}:{_port}/", ', '"format": "text"}', ], ), @@ -559,7 +469,7 @@ class Handler(BaseHTTPRequestHandler): "call_mock_6", "webfetch", [ - '{"url": "http://%s:%d/", ' % (_host, _port), + f'{{"url": "http://{_host}:{_port}/", ', '"format": "text"}', ], ), @@ -572,7 +482,7 @@ class Handler(BaseHTTPRequestHandler): "call_mock_2", "webfetch", [ - '{"url": "http://%s:%d/", ' % (_host, _port), + f'{{"url": "http://{_host}:{_port}/", ', '"format": "text"}', ], ), @@ -585,7 +495,7 @@ class Handler(BaseHTTPRequestHandler): "call_mock_4", "webfetch", [ - '{"url": "http://%s:%d/", ' % (_host, _port), + f'{{"url": "http://{_host}:{_port}/", ', '"format": "text"}', ], ), @@ -598,7 +508,7 @@ class Handler(BaseHTTPRequestHandler): "call_mock_6", "webfetch", [ - '{"url": "http://%s:%d/", ' % (_host, _port), + f'{{"url": "http://{_host}:{_port}/", ', '"format": "text"}', ], ), @@ -611,7 +521,7 @@ class Handler(BaseHTTPRequestHandler): "call_mock_2", "webfetch", [ - '{"url": "http://%s:%d/", ' % (_host, _port), + f'{{"url": "http://{_host}:{_port}/", ', '"format": "text"}', ], ), @@ -624,7 +534,7 @@ class Handler(BaseHTTPRequestHandler): "call_mock_4", "webfetch", [ - '{"url": "http://%s:%d/", ' % (_host, _port), + f'{{"url": "http://{_host}:{_port}/", ', '"format": "text"}', ], ), @@ -637,7 +547,7 @@ class Handler(BaseHTTPRequestHandler): "call_mock_6", "webfetch", [ - '{"url": "http://%s:%d/", ' % (_host, _port), + f'{{"url": "http://{_host}:{_port}/", ', '"format": "text"}', ], ), @@ -646,7 +556,7 @@ class Handler(BaseHTTPRequestHandler): "Two independent tool calls, ", "batched together.", ] - else: # tool_call_unknown + else: calls = [ ( "call_mock_3", @@ -663,7 +573,6 @@ class Handler(BaseHTTPRequestHandler): ) if not stream: - # Non-streaming success: single chat.completion object. content = " ".join(words) return self._send_json( 200, @@ -699,10 +608,7 @@ class Handler(BaseHTTPRequestHandler): return self._stream(200, lambda w: _stream_normal(w, cid, words)) def log_message(self, format, *args): - # Compact single-line logging to stdout. - print( - "%s - %s" % (self.log_date_time_string(), format % args), flush=True - ) + print(f"{self.log_date_time_string()} - {format % args}", flush=True) def main(): @@ -725,10 +631,8 @@ def main(): "type trigger words in the chat UI, or curl /_mock to change state", flush=True, ) - try: + with contextlib.suppress(KeyboardInterrupt): server.serve_forever() - except KeyboardInterrupt: - pass if __name__ == "__main__": From 4f797a9741c4bc775a96126bfe192bf845092323 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 15:11:51 +0200 Subject: [PATCH 55/90] separate dashboard and settings changes to new branch --- .gitignore | 1 + Components/GeminiGlow.qml | 42 +++++++++ .../Sidebar/Chat/ChatContent.qml | 12 +-- .../Notifications/Sidebar/Chat/ChatInput.qml | 53 +++++++++++ assets/shaders/geminiglow.frag | 82 ++++++++++++++++++ assets/shaders/geminiglow.frag.qsb | Bin 0 -> 3721 bytes 6 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 Components/GeminiGlow.qml create mode 100644 assets/shaders/geminiglow.frag create mode 100644 assets/shaders/geminiglow.frag.qsb diff --git a/.gitignore b/.gitignore index 8b588fe..c6ff3ec 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ network-dev/ **/zshell.dist/ .opencode/ run-agent.sh +AGENTS.md diff --git a/Components/GeminiGlow.qml b/Components/GeminiGlow.qml new file mode 100644 index 0000000..443ef07 --- /dev/null +++ b/Components/GeminiGlow.qml @@ -0,0 +1,42 @@ +import QtQuick +import Quickshell + +ShaderEffect { + id: root + + property Item target: parent + + property real glowSize: 40 + property real innerGlow: 0 + property real lineWidth: 2 + property real intensity: 1.0 + property real speed: 1.0 + property int period: 6000 + property bool running: true + + property color color1: "#4285F4" + property color color2: "#9B72CB" + property color color3: "#D96570" + property color color4: "#F2A54A" + + readonly property size itemSize: Qt.size(width, height) + readonly property size rectSize: target ? Qt.size(target.width, target.height) : Qt.size(0, 0) + readonly property real radius: (target as Rectangle).radius ?? 0 + property real phase: 0 + + anchors.fill: target + anchors.margins: -glowSize + z: -1 + + enabled: false + + fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/geminiglow.frag.qsb`) + + FrameAnimation { + running: root.running && root.visible + onTriggered: { + const dt = frameTime * 1000; + root.phase = (root.phase + dt * root.speed / root.period) % 1.0; + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 88a4c2d..e4cbaf2 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -170,7 +170,7 @@ Item { Layout.fillWidth: ChatState.isWindow ? false : true Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter elide: Text.ElideRight - font.pointSize: Tokens.font.size.larger + font: Tokens.font.title.medium text: qsTr(root.chatData.title) } } @@ -346,7 +346,7 @@ Item { id: scrollToBottom anchors.centerIn: parent - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.large icon: "arrow_downward" isRound: true padding: Tokens.padding.extraSmall @@ -455,7 +455,7 @@ Item { MaterialIcon { anchors.centerIn: parent - font.pointSize: Tokens.font.size.extraLarge + font.pointSize: Tokens.font.size.large text: thumb.fileIcon(thumb.modelData?.mimeType ?? "") visible: !thumb.modelData?.isImage ?? false } @@ -537,13 +537,15 @@ Item { anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right + anchors.margins: Tokens.padding.medium bg.border.color: Colors.palette.m3outlineVariant bg.color: Colors.tPalette.m3surfaceContainerLowest - font.pointSize: Tokens.font.size.normal + fastGlow: Chat.busy + font: Tokens.font.body.small implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) focus: true placeholderText: qsTr("Send a message") - sendIcon.font.pointSize: Tokens.font.size.large + sendIcon.font: Tokens.font.icon.medium sendIcon.icon: "arrow_upward" sendIcon.padding: Tokens.padding.extraSmall diff --git a/Modules/Notifications/Sidebar/Chat/ChatInput.qml b/Modules/Notifications/Sidebar/Chat/ChatInput.qml index 058ac31..34d2275 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatInput.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatInput.qml @@ -9,6 +9,8 @@ TextAreaBase { readonly property alias bg: bg readonly property alias sendIcon: sendIcon + property bool fastGlow: false + signal sendPressed bottomPadding: Tokens.padding.large @@ -17,6 +19,16 @@ TextAreaBase { topPadding: Tokens.padding.large wrapMode: TextAreaBase.Wrap + onFastGlowChanged: { + if (fastGlow) { + resetAnim.stop(); + boostAnim.restart(); + } else { + boostAnim.stop(); + resetAnim.restart(); + } + } + background: CustomRect { id: bg @@ -24,6 +36,47 @@ TextAreaBase { color: Colors.tPalette.m3surfaceContainer radius: Tokens.rounding.extraLarge + GeminiGlow { + id: glow + glowSize: 32 + + opacity: root.activeFocus ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + + SequentialAnimation { + id: boostAnim + + NumberAnimation { + target: glow + property: "speed" + to: 4.0 + duration: 250 + easing.type: Easing.OutCubic + } + NumberAnimation { + target: glow + property: "speed" + to: 2.25 + duration: 1500 + easing.type: Easing.InOutQuad + } + } + + // Ease back down to normal once fastGlow is false + NumberAnimation { + id: resetAnim + target: glow + property: "speed" + to: 1.0 + duration: 800 + easing.type: Easing.InOutQuad + } + } + StateLayer { id: stateLayer diff --git a/assets/shaders/geminiglow.frag b/assets/shaders/geminiglow.frag new file mode 100644 index 0000000..ea19795 --- /dev/null +++ b/assets/shaders/geminiglow.frag @@ -0,0 +1,82 @@ +#version 440 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + vec2 itemSize; + vec2 rectSize; + float radius; + float glowSize; + float innerGlow; + float lineWidth; + float phase; + float intensity; + vec4 color1; + vec4 color2; + vec4 color3; + vec4 color4; +}; + +const float TAU = 6.28318530718; + +float sdRoundBox(vec2 p, vec2 halfSize, float r) +{ + vec2 q = abs(p) - (halfSize - r); + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; +} + +vec3 palette(float t) +{ + t = fract(t) * 4.0; + float f = smoothstep(0.0, 1.0, fract(t)); + if (t < 1.0) return mix(color1.rgb, color2.rgb, f); + if (t < 2.0) return mix(color2.rgb, color3.rgb, f); + if (t < 3.0) return mix(color3.rgb, color4.rgb, f); + return mix(color4.rgb, color1.rgb, f); +} + +float hash(vec2 p) +{ + return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); +} + +void main() +{ + vec2 p = (qt_TexCoord0 - 0.5) * itemSize; + + float r = clamp(radius, 0.0, min(rectSize.x, rectSize.y) * 0.5); + float d = sdRoundBox(p, rectSize * 0.5, r); + + float ang = atan(p.y, p.x); + float a2 = phase * TAU; + float u = ang / TAU + 0.5 + phase; + u += 0.07 * sin(ang * 2.0 + a2) + 0.04 * sin(ang * 3.0 - a2 * 2.0); + vec3 col = palette(u); + + float g = 0.0; + if (d > 0.0) { + float t = clamp(d / max(glowSize, 0.001), 0.0, 1.0); + g = pow(1.0 - t, 3.0); + } else if (innerGlow > 0.0) { + g = exp(d / innerGlow * 3.0); + } + + float pulse = 0.9 + 0.1 * sin(a2 * 2.0 + ang); + g *= pulse; + + float line = 0.0; + if (lineWidth > 0.0) + line = 1.0 - smoothstep(0.0, lineWidth, abs(d)); + + float a = clamp(g * intensity + line * 0.9, 0.0, 1.0); + + col = mix(col, vec3(1.0), line * 0.25); + + a += (hash(gl_FragCoord.xy) - 0.5) / 255.0; + a = clamp(a, 0.0, 1.0); + + fragColor = vec4(col * a, a) * qt_Opacity; +} diff --git a/assets/shaders/geminiglow.frag.qsb b/assets/shaders/geminiglow.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..233240e6fd7552d50ef537e097b4a5c3c2609215 GIT binary patch literal 3721 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 literal 0 HcmV?d00001 From e86d1e8220fc58cb38d0475132b36a8f44564969 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 15:18:16 +0200 Subject: [PATCH 56/90] remove unrelated files --- Components/GeminiGlow.qml | 42 ------------- assets/shaders/geminiglow.frag | 93 ----------------------------- assets/shaders/geminiglow.frag.qsb | Bin 3721 -> 0 bytes 3 files changed, 135 deletions(-) delete mode 100755 Components/GeminiGlow.qml delete mode 100755 assets/shaders/geminiglow.frag delete mode 100644 assets/shaders/geminiglow.frag.qsb diff --git a/Components/GeminiGlow.qml b/Components/GeminiGlow.qml deleted file mode 100755 index 443ef07..0000000 --- a/Components/GeminiGlow.qml +++ /dev/null @@ -1,42 +0,0 @@ -import QtQuick -import Quickshell - -ShaderEffect { - id: root - - property Item target: parent - - property real glowSize: 40 - property real innerGlow: 0 - property real lineWidth: 2 - property real intensity: 1.0 - property real speed: 1.0 - property int period: 6000 - property bool running: true - - property color color1: "#4285F4" - property color color2: "#9B72CB" - property color color3: "#D96570" - property color color4: "#F2A54A" - - readonly property size itemSize: Qt.size(width, height) - readonly property size rectSize: target ? Qt.size(target.width, target.height) : Qt.size(0, 0) - readonly property real radius: (target as Rectangle).radius ?? 0 - property real phase: 0 - - anchors.fill: target - anchors.margins: -glowSize - z: -1 - - enabled: false - - fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/geminiglow.frag.qsb`) - - FrameAnimation { - running: root.running && root.visible - onTriggered: { - const dt = frameTime * 1000; - root.phase = (root.phase + dt * root.speed / root.period) % 1.0; - } - } -} diff --git a/assets/shaders/geminiglow.frag b/assets/shaders/geminiglow.frag deleted file mode 100755 index ad9de36..0000000 --- a/assets/shaders/geminiglow.frag +++ /dev/null @@ -1,93 +0,0 @@ -#version 440 - -layout(location = 0) in vec2 qt_TexCoord0; -layout(location = 0) out vec4 fragColor; - -// NOTE: std140 layout. Order/types must match the ShaderEffect properties by name. -layout(std140, binding = 0) uniform buf { - mat4 qt_Matrix; - float qt_Opacity; - vec2 itemSize; // full ShaderEffect size in px (rect + glow padding) - vec2 rectSize; // size of the target rectangle in px - float radius; // corner radius of the target rectangle in px - float glowSize; // how far the glow extends outside the rectangle (px) - float innerGlow; // how far the glow bleeds inside (px), 0 = none - float lineWidth; // width of the bright edge line (px), 0 = none - float phase; // 0..1, loops seamlessly - float intensity; // overall brightness - vec4 color1; - vec4 color2; - vec4 color3; - vec4 color4; -}; - -const float TAU = 6.28318530718; - -// Signed distance to a rounded box centred at the origin. <0 inside, >0 outside. -float sdRoundBox(vec2 p, vec2 halfSize, float r) -{ - vec2 q = abs(p) - (halfSize - r); - return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; -} - -// Cyclic 4-colour gradient, t is wrapped to [0,1) -vec3 palette(float t) -{ - t = fract(t) * 4.0; - float f = smoothstep(0.0, 1.0, fract(t)); - if (t < 1.0) return mix(color1.rgb, color2.rgb, f); - if (t < 2.0) return mix(color2.rgb, color3.rgb, f); - if (t < 3.0) return mix(color3.rgb, color4.rgb, f); - return mix(color4.rgb, color1.rgb, f); -} - -// Cheap hash for dithering (avoids banding in soft gradients) -float hash(vec2 p) -{ - return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); -} - -void main() -{ - // Pixel position relative to the centre of the item - vec2 p = (qt_TexCoord0 - 0.5) * itemSize; - - float r = clamp(radius, 0.0, min(rectSize.x, rectSize.y) * 0.5); - float d = sdRoundBox(p, rectSize * 0.5, r); - - // ---- colour: rotates around the rectangle, slightly wobbling ---- - float ang = atan(p.y, p.x); - float a2 = phase * TAU; - float u = ang / TAU + 0.5 + phase; - u += 0.07 * sin(ang * 2.0 + a2) + 0.04 * sin(ang * 3.0 - a2 * 2.0); - vec3 col = palette(u); - - // ---- intensity falloff ---- - float g = 0.0; - if (d > 0.0) { - float t = clamp(d / max(glowSize, 0.001), 0.0, 1.0); - g = pow(1.0 - t, 3.0); - } else if (innerGlow > 0.0) { - g = exp(d / innerGlow * 3.0); - } - - // Slow "breathing" so the glow feels alive - float pulse = 0.9 + 0.1 * sin(a2 * 2.0 + ang); - g *= pulse; - - // Crisp bright line along the edge - float line = 0.0; - if (lineWidth > 0.0) - line = 1.0 - smoothstep(0.0, lineWidth, abs(d)); - - float a = clamp(g * intensity + line * 0.9, 0.0, 1.0); - - // Brighten the colour a bit where the line is - col = mix(col, vec3(1.0), line * 0.25); - - a += (hash(gl_FragCoord.xy) - 0.5) / 255.0; - a = clamp(a, 0.0, 1.0); - - // Premultiplied alpha output - fragColor = vec4(col * a, a) * qt_Opacity; -} diff --git a/assets/shaders/geminiglow.frag.qsb b/assets/shaders/geminiglow.frag.qsb deleted file mode 100644 index 233240e6fd7552d50ef537e097b4a5c3c2609215..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3721 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 From 3c359ce25f5596402e3470b38af8aafb85235d7c Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 18:38:07 +0200 Subject: [PATCH 57/90] fix: re-add lost changes --- CMakeLists.txt | 40 +++---- Plugins/CMakeLists.txt | 23 ++++ Plugins/ZShell/Blobs/CMakeLists.txt | 26 ++--- Plugins/ZShell/Config/CMakeLists.txt | 10 +- Plugins/ZShell/Config/rootnodes.hpp | 2 - Plugins/ZShell/Models/CMakeLists.txt | 12 +- Plugins/ZShell/zutils.cpp | 157 ++++++++++++++++++++++++++- Plugins/ZShell/zutils.hpp | 21 +++- Plugins/cmake/pch.cmake | 14 +++ Plugins/cmake/sensorslib.cmake | 10 ++ Plugins/include/util/metaenum.hpp | 31 ++++++ 11 files changed, 292 insertions(+), 54 deletions(-) create mode 100644 Plugins/cmake/pch.cmake create mode 100644 Plugins/cmake/sensorslib.cmake create mode 100644 Plugins/include/util/metaenum.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 95c3714..fc9f50a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,22 +26,36 @@ 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}) message(STATUS "ZShell version: ${VERSION}") +include(GNUInstallDirs) + set(CMAKE_CXX_STANDARD 20) 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") -set(INSTALL_QSCONFDIR "etc/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir") -set(INSTALL_GREETERCONFDIR "etc/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir") +set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/ZShell" CACHE STRING "Library install dir") +set(INSTALL_QMLDIR "${CMAKE_INSTALL_LIBDIR}/qt6/qml" CACHE STRING "QML install dir") +set(INSTALL_QSCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir") +set(INSTALL_GREETERCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir") set(CMAKE_INSTALL_MESSAGE NEVER) @@ -54,21 +68,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/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 4a2ff2f..5c302ae 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -1 +1,24 @@ +find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick QuickPrivate 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/Blobs/CMakeLists.txt b/Plugins/ZShell/Blobs/CMakeLists.txt index 05ec0d9..a51b194 100644 --- a/Plugins/ZShell/Blobs/CMakeLists.txt +++ b/Plugins/ZShell/Blobs/CMakeLists.txt @@ -1,20 +1,20 @@ qml_module(ZShell-blobs URI ZShell.Blobs - SOURCES - blobgroup.cpp - blobshape.cpp - blobrect.cpp - blobinvertedrect.cpp - blobmaterial.cpp - LIBRARIES - Qt::Quick + SOURCES + blobgroup.cpp + blobshape.cpp + blobrect.cpp + blobinvertedrect.cpp + blobmaterial.cpp + LIBRARIES + Qt::Quick ) qt_add_shaders(ZShell-blobs "blob_shaders" - BATCHABLE OPTIMIZED NOHLSL NOMSL + BATCHABLE OPTIMIZED NOHLSL NOMSL GLSL "300es,330" - PREFIX "/" - FILES - shaders/blob.frag - shaders/blob.vert + PREFIX "/" + FILES + shaders/blob.frag + shaders/blob.vert ) diff --git a/Plugins/ZShell/Config/CMakeLists.txt b/Plugins/ZShell/Config/CMakeLists.txt index 350ab9a..d17da2a 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -1,13 +1,9 @@ qml_module(ZShell-config URI ZShell.Config INCLUDE_PREFIX config - DEPENDENCIES - ZShell.Settings SOURCES - confignode.hpp confignode.cpp - configobject.hpp configobject.cpp - configlist.hpp configlist.cpp - config.hpp config.cpp + rootnodes.hpp rootnodes.cpp + common.hpp common.cpp migration.hpp migration.cpp anim.hpp anim.cpp fontbuilder.hpp fontbuilder.cpp @@ -31,6 +27,8 @@ qml_module(ZShell-config services.hpp sidebar.hpp utilities.hpp + DEPENDENCIES + ZShell.Settings ) target_link_libraries(ZShell-config PUBLIC diff --git a/Plugins/ZShell/Config/rootnodes.hpp b/Plugins/ZShell/Config/rootnodes.hpp index 308e90a..bbbe3c5 100644 --- a/Plugins/ZShell/Config/rootnodes.hpp +++ b/Plugins/ZShell/Config/rootnodes.hpp @@ -16,7 +16,6 @@ #include "dock.hpp" #include "general.hpp" #include "launcher.hpp" -#include "llm.hpp" #include "lock.hpp" #include "notifs.hpp" #include "osd.hpp" @@ -43,7 +42,6 @@ class ConfigRoot : public settings::RootNode { CONFIG_SUBOBJECT(Dock, dock) CONFIG_SUBOBJECT(General, general) CONFIG_SUBOBJECT(Launcher, launcher) - CONFIG_SUBOBJECT(Llm, llm) CONFIG_SUBOBJECT(Lock, lock) CONFIG_SUBOBJECT(Notifs, notifs) CONFIG_SUBOBJECT(Osd, osd) diff --git a/Plugins/ZShell/Models/CMakeLists.txt b/Plugins/ZShell/Models/CMakeLists.txt index 785db17..9bac1a8 100644 --- a/Plugins/ZShell/Models/CMakeLists.txt +++ b/Plugins/ZShell/Models/CMakeLists.txt @@ -1,8 +1,8 @@ qml_module(ZShell-models - URI ZShell.Models - SOURCES - filesystemmodel.hpp filesystemmodel.cpp - LIBRARIES - Qt::Gui - Qt::Concurrent + URI ZShell.Models + SOURCES + filesystemmodel.hpp filesystemmodel.cpp + LIBRARIES + Qt::Gui + Qt::Concurrent ) diff --git a/Plugins/ZShell/zutils.cpp b/Plugins/ZShell/zutils.cpp index 4aef1fe..8a81a39 100644 --- a/Plugins/ZShell/zutils.cpp +++ b/Plugins/ZShell/zutils.cpp @@ -5,13 +5,17 @@ #include #include #include +#include #include #include #include #include +#include #include #include +#include "util/metaenum.hpp" + Q_LOGGING_CATEGORY(lcZUtils, "ZShell.cutils", QtInfoMsg) namespace ZShell { @@ -171,13 +175,112 @@ 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); +} + +namespace { + +template +QQuickItem* findChildDfs(QQuickItem* root, Predicate&& match) { + const auto children = root->childItems(); + for (QQuickItem* const child : children) { + if (match(child)) { + return child; + } + if (QQuickItem* const found = findChildDfs(child, match)) { + return found; + } + } + return nullptr; +} + +template +void findChildrenDfs( + QQuickItem* root, Predicate&& match, QList& out) { + const auto children = root->childItems(); + for (QQuickItem* const child : children) { + if (match(child)) { + out.append(child); + } + findChildrenDfs(child, match, out); + } +} + +} // namespace + +QQuickItem* ZUtils::findChild(QQuickItem* root, const QString& name) { + if (!root) { + return nullptr; + } + + return findChildDfs(root, [&name](const QQuickItem* item) { + return item->objectName() == name; + }); +} + +QList ZUtils::findChildren(QQuickItem* root, const QString& name) { + QList children; + if (root) { + findChildrenDfs( + root, + [&name](const QQuickItem* item) { + return item->objectName() == name; + }, + children); + } + return children; +} + +QList ZUtils::findChildrenMatching( + QQuickItem* root, const QString& pattern) { + QList children; + if (root) { + const QRegularExpression re(pattern); + findChildrenDfs( + root, + [&re](const QQuickItem* item) { + return re.match(item->objectName()).hasMatch(); + }, + children); + } + return children; } #ifndef ZSHELL_VERSION @@ -192,4 +295,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/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 From 8dd65a90a8bc8c9fef2fcba77a1d43fe185041fe Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 18:39:24 +0200 Subject: [PATCH 58/90] fix: re-add lost changes missed files --- Plugins/ZShell/CMakeLists.txt | 56 +----------------------- Plugins/ZShell/Components/CMakeLists.txt | 5 +-- 2 files changed, 3 insertions(+), 58 deletions(-) diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index 9822b65..f0e1a36 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -1,58 +1,5 @@ -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 - INCLUDE_PREFIX core SOURCES writefile.hpp writefile.cpp appdb.hpp appdb.cpp @@ -61,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 @@ -70,6 +15,7 @@ qml_module(ZShell Qt::Sql Qt::DBus PkgConfig::Qalculate + zshell-util ) target_compile_definitions(ZShell PRIVATE ZSHELL_VERSION="${VERSION}") diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index abc5960..c4f2154 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -1,11 +1,10 @@ qml_module(ZShell-components URI ZShell.Components - SOURCES - lazylistview.hpp lazylistview.cpp + SOURCES + lazylistview.hpp lazylistview.cpp wavyline.hpp wavyline.cpp buttonrow.hpp buttonrow.cpp carouselview.hpp carouselview.cpp - wheelinverter.hpp sparklinecontent.hpp sparklinecontent.cpp sparklineitem.hpp sparklineitem.cpp sparklinesceneitem.hpp sparklinesceneitem.cpp From c1f26e7dcb72c1a1f88907394d04e714ef4ff2bd Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 18:40:46 +0200 Subject: [PATCH 59/90] fix: add wheelinverter to cmake --- Plugins/ZShell/Components/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index a7d57b4..889edcc 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -10,6 +10,7 @@ qml_module(ZShell-components sparklinecontent.hpp sparklinecontent.cpp sparklineitem.hpp sparklineitem.cpp sparklinesceneitem.hpp sparklinesceneitem.cpp + wheelinverter.hpp LIBRARIES Qt::Quick Qt::QuickPrivate From e8bf8b97273072764462e25c5f582542a71eef8c Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 19:30:06 +0200 Subject: [PATCH 60/90] fix: restore lost changes --- Components/CircularIndicator.qml | 2 +- Components/ScreenState.qml | 2 +- Components/TextAreaBase.qml | 2 +- Components/TextEditBase.qml | 12 +- .../Sidebar/Chat/ChatContent.qml | 2 +- .../Sidebar/Chat/ChatDelegate.qml | 16 +- .../Notifications/Sidebar/Chat/ChatList.qml | 6 +- .../Notifications/Sidebar/Chat/ChatState.qml | 4 +- .../Sidebar/Chat/Content/Actions.qml | 8 +- .../Sidebar/Chat/Content/BubbleEdit.qml | 2 +- .../Sidebar/Chat/Content/ChatHost.qml | 216 +++++++++--------- .../Sidebar/Chat/Content/CodeBlockView.qml | 26 +-- .../Sidebar/Chat/Content/ContentBubble.qml | 2 +- .../Sidebar/Chat/Content/ErrorBubble.qml | 4 +- .../Sidebar/Chat/Content/MarkdownBlocks.qml | 9 +- .../Sidebar/Chat/Content/MathBlockView.qml | 7 +- .../Sidebar/Chat/Content/MessageDelegate.qml | 2 +- .../Sidebar/Chat/Content/ProcessBlock.qml | 10 +- Modules/Notifications/Sidebar/Chat/Detach.qml | 2 +- .../Sidebar/Chat/SidebarView.qml | 4 +- Modules/Notifications/Sidebar/Content.qml | 10 +- Modules/Notifications/Sidebar/Tabs.qml | 6 +- .../Settings/Common/DialogSelectButton.qml | 1 - .../Pages/Panels/Bar/BarStatusIcons.qml | 1 - Plugins/ZShell/CMakeLists.txt | 1 + Plugins/ZShell/Config/llm.hpp | 35 ++- Plugins/ZShell/Config/rootnodes.hpp | 2 + Plugins/ZShell/Llm/CMakeLists.txt | 59 +---- Plugins/ZShell/Llm/chat.cpp | 16 +- 29 files changed, 201 insertions(+), 268 deletions(-) diff --git a/Components/CircularIndicator.qml b/Components/CircularIndicator.qml index 1c9a97d..cf28beb 100644 --- a/Components/CircularIndicator.qml +++ b/Components/CircularIndicator.qml @@ -20,7 +20,7 @@ BusyIndicator { property int animState property color bgColor: Colors.palette.m3secondaryContainer property color fgColor: Colors.palette.m3primary - property real implicitSize: Tokens.font.size.normal * 3 + property real implicitSize: Tokens.font.body.medium.pointSize * 3 property real internalStrokeWidth: strokeWidth readonly property alias progress: manager.progress property real strokeWidth: Tokens.padding.extraSmall * 0.8 diff --git a/Components/ScreenState.qml b/Components/ScreenState.qml index f1b303f..38ec504 100644 --- a/Components/ScreenState.qml +++ b/Components/ScreenState.qml @@ -23,7 +23,7 @@ PersistentProperties { property date dashboardDate: new Date() // Sidebar state - property int sidebarTab + property int sidebarTab: 0 property list expandedNotifs: [] // Utilities state diff --git a/Components/TextAreaBase.qml b/Components/TextAreaBase.qml index a96b9c0..5408a2d 100644 --- a/Components/TextAreaBase.qml +++ b/Components/TextAreaBase.qml @@ -8,7 +8,7 @@ TextArea { color: Colors.palette.m3onSurface cursorVisible: !readOnly - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small implicitHeight: contentHeight + topPadding + bottomPadding implicitWidth: contentWidth + leftPadding + rightPadding placeholderTextColor: Colors.palette.m3onSurfaceVariant // No anim cause placeholder is custom diff --git a/Components/TextEditBase.qml b/Components/TextEditBase.qml index 8eb49a3..e0931f3 100644 --- a/Components/TextEditBase.qml +++ b/Components/TextEditBase.qml @@ -11,22 +11,18 @@ TextEdit { color: Colors.palette.m3onSurface cursorVisible: !readOnly - font.pointSize: Tokens.font.size.small - font.family: Config.appearance.font.family.sans + font: Tokens.font.body.small renderType: TextField.NativeRendering selectedTextColor: color selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4) verticalAlignment: TextInput.AlignVCenter Behavior on color { - CAnim { - } - } - cursorDelegate: Item { + CAnim {} } + cursorDelegate: Item {} Behavior on selectionColor { - CAnim { - } + CAnim {} } CustomRect { diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index e4cbaf2..66cbf63 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -455,7 +455,7 @@ Item { MaterialIcon { anchors.centerIn: parent - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large text: thumb.fileIcon(thumb.modelData?.mimeType ?? "") visible: !thumb.modelData?.isImage ?? false } diff --git a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml index 5a2613f..b7a6721 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml @@ -67,7 +67,7 @@ CustomClippingRect { anchors.leftMargin: Tokens.padding.large anchors.top: parent.top anchors.topMargin: Tokens.padding.large - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge text: root.modelData.icon || "check" color: Colors.palette.m3onSurface } @@ -134,7 +134,7 @@ CustomClippingRect { anchors.leftMargin: readOnly ? 0 : Tokens.padding.large anchors.top: parent.top anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2 - font.pointSize: Tokens.font.size.normal + font: Tokens.font.body.medium text: root.modelData.title || qsTr("New chat") readOnly: true color: Colors.palette.m3onSurface @@ -192,7 +192,7 @@ CustomClippingRect { anchors.topMargin: Tokens.spacing.extraSmall anchors.left: parent.left anchors.right: parent.right - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small color: Colors.palette.m3onSurfaceVariant text: qsTr("Created on: %1").arg(root.modelData.createdAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP")) opacity: root.expanded ? 1 : 0 @@ -208,7 +208,7 @@ CustomClippingRect { anchors.top: created.bottom anchors.topMargin: Tokens.spacing.small anchors.left: parent.left - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small color: Colors.palette.m3onSurfaceVariant text: qsTr("Updated on: ") opacity: root.expanded ? 1 : 0 @@ -225,7 +225,7 @@ CustomClippingRect { anchors.top: title.bottom anchors.topMargin: Tokens.spacing.extraSmall color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP") states: State { @@ -256,7 +256,7 @@ CustomClippingRect { y: infoContainer.implicitHeight + topMargin elide: Text.ElideRight color: Colors.palette.m3onSurfaceVariant - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small maximumLineCount: 1 opacity: !root.expanded && !root.slim ? 1 : 0 text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet") @@ -323,7 +323,7 @@ CustomClippingRect { fillWidth: true shapeMorph: true isRound: true - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3) inactiveOnColor: Colors.palette.m3onSurfaceVariant @@ -336,7 +336,7 @@ CustomClippingRect { isToggle: true fillWidth: true shapeMorph: true - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium isRound: true inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3) inactiveOnColor: Colors.palette.m3onSurfaceVariant diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml index d9a17fe..3b65771 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatList.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -156,7 +156,7 @@ Item { anchors.verticalCenter: parent.verticalCenter text: modelsContainer.expanded ? "unfold_less" : "unfold_more" animate: true - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large visible: modelsList.count > 0 } @@ -229,7 +229,7 @@ Item { Layout.alignment: Qt.AlignRight visible: model.selected color: Colors.palette.m3onTertiaryContainer - font.pointSize: Tokens.font.size.normal + fontStyle: Tokens.font.icon.normal opacity: model.selected ? 1 : 0 text: "check" @@ -405,7 +405,7 @@ Item { MaterialIcon { text: fabMenuItem.modelData.icon color: Colors.palette.m3onPrimaryContainer - font.pointSize: Tokens.font.size.large + fontStyle: Tokens.font.icon.large fill: 1 } diff --git a/Modules/Notifications/Sidebar/Chat/ChatState.qml b/Modules/Notifications/Sidebar/Chat/ChatState.qml index df83c8e..0240a94 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatState.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatState.qml @@ -3,7 +3,7 @@ pragma Singleton import QtQuick import Quickshell import ZShell.Llm -import qs.Helpers +import qs.Services Singleton { id: root @@ -20,7 +20,7 @@ Singleton { property bool narrowSidebarExpanded: false property bool windowIsWide: false property ShellScreen screen - property bool sidebarVisible: Visibilities.getForActive().sidebar + property bool sidebarVisible: ShellState.forActive().sidebar onSidebarVisibleChanged: if (!sidebarVisible) fabExpanded = false diff --git a/Modules/Notifications/Sidebar/Chat/Content/Actions.qml b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml index 1d4efdf..aa018d6 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/Actions.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml @@ -29,6 +29,7 @@ Item { enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== 0 icon: "chevron_left" type: IconButton.Tonal + isRound: true onClicked: { if (!root.edit.readOnly) @@ -40,8 +41,7 @@ Item { IconButton { enabled: false icon: `${root.message.activeGenerationIndex + 1}` - label.font.family: Config.appearance.font.family.sans - label.font.pointSize: Tokens.font.size.small + label.font: Tokens.font.body.small type: IconButton.Text } @@ -49,6 +49,7 @@ Item { enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== root.message.generationCount - 1 icon: "chevron_right" type: IconButton.Tonal + isRound: true onClicked: { if (!root.edit.readOnly) @@ -76,6 +77,7 @@ Item { inactiveOnColor: Colors.palette.m3onSecondary scale: root.edit.readOnly ? 1 : 0 visible: !root.isUser + isRound: true Behavior on scale { Anim {} @@ -91,6 +93,7 @@ Item { inactiveColor: Colors.palette.m3secondary inactiveOnColor: Colors.palette.m3onSecondary scale: root.edit.readOnly ? 1 : 0 + isRound: true Behavior on scale { Anim {} @@ -106,6 +109,7 @@ Item { inactiveColor: Colors.palette.m3tertiary inactiveOnColor: Colors.palette.m3onTertiary visible: root.isUser + isRound: true onClicked: { root.edit.readOnly = !root.edit.readOnly; diff --git a/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml b/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml index 4b96374..a3666f3 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/BubbleEdit.qml @@ -10,7 +10,7 @@ TextEditBase { color: Colors.palette.m3onSurface readOnly: true textFormat: Text.MarkdownText - font.pointSize: Tokens.font.size.smaller + font: Tokens.font.body.small wrapMode: Text.WrapAtWordBoundaryOrAnywhere onLinkActivated: link => { diff --git a/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml b/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml index 9869855..d708ce5 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml @@ -6,138 +6,138 @@ import qs.Services import qs.Modules.Notifications.Sidebar.Chat Item { - id: root + id: root - property int animOff - property Item currentItem - property bool noAnim: false - property bool loading: false - property int lastIdx: -1 + property int animOff + property Item currentItem + property bool noAnim: false + property bool loading: false + property int lastIdx: -1 - readonly property Component conversationComp: ChatContent {} + readonly property Component conversationComp: ChatContent {} - signal requestClose + signal requestClose - function loadConversation(chat): void { - if (currentItem) { - currentItem.destroy(); - currentItem = null; - } + function loadConversation(chat): void { + if (currentItem) { + currentItem.destroy(); + currentItem = null; + } - if (!chat) - return; + if (!chat) + return; - root.loading = true; + root.loading = true; - const incubator = root.conversationComp.incubateObject(container, { - chatData: chat - }); + const incubator = root.conversationComp.incubateObject(container, { + chatData: chat + }); - const attach = () => { - incubator.object.anchors.fill = container; - incubator.object.requestClose.connect(root.requestClose); - currentItem = incubator.object; - root.loading = false; - if (!noAnim) - enterAnim.start(); - }; + const attach = () => { + incubator.object.anchors.fill = container; + incubator.object.requestClose.connect(root.requestClose); + currentItem = incubator.object; + root.loading = false; + if (!noAnim) + enterAnim.start(); + }; - if (incubator.status === Component.Ready) - attach(); - else - incubator.onStatusChanged = status => { - if (status === Component.Ready) - attach(); - }; - } + if (incubator.status === Component.Ready) + attach(); + else + incubator.onStatusChanged = status => { + if (status === Component.Ready) + attach(); + }; + } - Item { - id: container + Item { + id: container - anchors.fill: parent - layer.enabled: opacity < 1 - objectName: "ConversationContainer" + anchors.fill: parent + layer.enabled: opacity < 1 + objectName: "ConversationContainer" - Component.onCompleted: { - if (ChatState.chatSession) - root.loadConversation(ChatState.chatSession); - } - } + Component.onCompleted: { + if (ChatState.chatSession) + root.loadConversation(ChatState.chatSession); + } + } - LoadingIndicator { - anchors.centerIn: parent - implicitSize: Tokens.font.size.extraLarge * 4 - opacity: root.loading ? 1 : 0 - visible: opacity > 0 + LoadingIndicator { + anchors.centerIn: parent + implicitSize: Tokens.font.icon.extraLarge.pointSize * 4 + opacity: root.loading ? 1 : 0 + visible: opacity > 0 - Behavior on opacity { - Anim {} - } - } + Behavior on opacity { + Anim {} + } + } - Connections { - function onChatSessionChanged(): void { - exitAnim.complete(); - enterAnim.complete(); + Connections { + function onChatSessionChanged(): void { + exitAnim.complete(); + enterAnim.complete(); - if (root.noAnim) { - root.loadConversation(ChatState.chatSession); - return; - } + if (root.noAnim) { + root.loadConversation(ChatState.chatSession); + return; + } - root.animOff = Tokens.padding.small * (ChatState.currentIdx > root.lastIdx ? 1 : -1); - root.lastIdx = ChatState.currentIdx; - exitAnim.start(); - } + root.animOff = Tokens.padding.small * (ChatState.currentIdx > root.lastIdx ? 1 : -1); + root.lastIdx = ChatState.currentIdx; + exitAnim.start(); + } - target: ChatState - } + target: ChatState + } - SequentialAnimation { - id: exitAnim + SequentialAnimation { + id: exitAnim - Anim { - property: "opacity" - target: container - to: 0 - type: Anim.DefaultEffects - } + Anim { + property: "opacity" + target: container + to: 0 + type: Anim.DefaultEffects + } - ScriptAction { - script: root.loadConversation(ChatState.chatSession) - } - } + ScriptAction { + script: root.loadConversation(ChatState.chatSession) + } + } - SequentialAnimation { - id: enterAnim + SequentialAnimation { + id: enterAnim - PropertyAction { - property: "topMargin" - target: container.anchors - value: root.animOff - } + PropertyAction { + property: "topMargin" + target: container.anchors + value: root.animOff + } - PropertyAction { - property: "bottomMargin" - target: container.anchors - value: -root.animOff - } + PropertyAction { + property: "bottomMargin" + target: container.anchors + value: -root.animOff + } - ParallelAnimation { - Anim { - from: 0 - property: "opacity" - target: container - to: 1 - type: Anim.SlowEffects - } + ParallelAnimation { + Anim { + from: 0 + property: "opacity" + target: container + to: 1 + type: Anim.SlowEffects + } - Anim { - properties: "topMargin,bottomMargin" - target: container.anchors - to: 0 - type: Anim.SlowEffects - } - } - } + Anim { + properties: "topMargin,bottomMargin" + target: container.anchors + to: 0 + type: Anim.SlowEffects + } + } + } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml index d199797..993c5e8 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/CodeBlockView.qml @@ -32,8 +32,7 @@ CustomRect { function onHighlightSpans(token, spans) { if (token !== root.highlightToken) return; - if (root.code !== root.requestedCode || - root.language !== root.requestedLanguage) + if (root.code !== root.requestedCode || root.language !== root.requestedLanguage) return; html = spans.length ? root.highlightedHtml(root.code, spans) : ""; colored = root.visible && html.length > 0; @@ -84,15 +83,13 @@ CustomRect { // whitespace kept, mid-line runs collapsed, tabs as single spaces) so the // plain and colored layouts are identical. function plainForDisplay(code): string { - return code.split("\n") - .map(line => { - const i = line.search(/\S/); - if (i < 0) - return ""; - const lead = line.slice(0, i).replace(/\t/g, " "); - return lead + line.slice(i).replace(/\s+/g, " ").replace(/\s+$/, ""); - }) - .join("\n"); + return code.split("\n").map(line => { + const i = line.search(/\S/); + if (i < 0) + return ""; + const lead = line.slice(0, i).replace(/\t/g, " "); + return lead + line.slice(i).replace(/\s+/g, " ").replace(/\s+$/, ""); + }).join("\n"); } function highlightedHtml(code, spans): string { @@ -136,7 +133,7 @@ CustomRect { refreshTimer.restart(); } onVisibleChanged: if (root.visible) - refreshTimer.restart(); + refreshTimer.restart() Timer { id: refreshTimer @@ -223,7 +220,7 @@ CustomRect { Layout.alignment: Qt.AlignVCenter color: root.codeHeaderColor - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small text: CodeIcons.name(root.language) visible: root.language.length > 0 } @@ -289,8 +286,7 @@ CustomRect { color: CodeColors.active.normal layer.enabled: true clip: false - font.family: Config.appearance.font.family.mono - font.pointSize: Tokens.font.size.small + font: Tokens.font.mono.small textFormat: root.colored ? Text.RichText : Text.PlainText text: root.colored ? root.html : root.plainForDisplay(root.code) readOnly: true diff --git a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml index 86581c4..1c4669c 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -85,7 +85,7 @@ Item { animateCursor: false color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary - font.pointSize: Tokens.font.size.smaller + font: Tokens.font.body.small readOnly: true selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4) text: root.isUser ? root.segment.text : "" diff --git a/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml index 503df55..f3cfca7 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml @@ -93,7 +93,7 @@ Item { Layout.fillWidth: true - font.pointSize: Tokens.font.size.large + font: Tokens.font.headline.small elide: CustomText.ElideRight color: Colors.palette.m3onErrorContainer @@ -120,7 +120,7 @@ Item { isRound: true icon: "close" type: IconButton.Text - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium onClicked: exitAnim.start() } diff --git a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml index 32377ad..877f7e7 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml @@ -62,13 +62,12 @@ Column { text: modelData.text textFormat: Text.MarkdownText readOnly: true - font.bold: true - font.pointSize: { + font: { if (modelData.level <= 1) - return Tokens.font.size.large; + return Tokens.font.title.builders.large.weight(Font.Bold).build(); if (modelData.level === 2) - return Tokens.font.size.normal; - return Tokens.font.size.smaller; + return Tokens.font.title.builders.medium.weight(Font.Bold).build(); + return Tokens.font.title.builders.small.weight(Font.Bold).build(); } wrapMode: Text.WrapAtWordBoundaryOrAnywhere } diff --git a/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml index b07d49f..8680312 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MathBlockView.qml @@ -9,7 +9,7 @@ Item { required property string latex property color mathColor: Colors.palette.m3onSurface - property real fontSize: Tokens.font.size.large + property font font: Tokens.font.headline.medium implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2 @@ -18,7 +18,7 @@ Item { latex: root.latex color: root.mathColor - fontPointSize: root.fontSize + fontPointSize: root.font.pointSize devicePixelRatio: Screen.devicePixelRatio } @@ -39,8 +39,7 @@ Item { anchors.centerIn: parent color: root.mathColor - font.family: Config.appearance.font.family.mono - font.pointSize: Tokens.font.size.small + font: Tokens.font.mono.medium text: root.latex wrapMode: Text.WrapAtWordBoundaryOrAnywhere visible: !math.ok diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index af362f7..6d37c87 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -231,7 +231,7 @@ MouseArea { MaterialIcon { anchors.centerIn: parent - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge text: thumb.fileIcon(thumb.modelData?.mimeType ?? "") visible: !thumb.modelData?.isImage ?? false } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml index 70bff51..4bcfdc7 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -70,7 +70,7 @@ Item { IconButton { id: expandBtn - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "keyboard_arrow_down" inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline anchors.centerIn: parent @@ -91,7 +91,7 @@ Item { id: collapsedText color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small + font: Tokens.font.label.small Layout.fillWidth: true Layout.leftMargin: Tokens.spacing.medium text: { @@ -139,7 +139,7 @@ Item { CustomText { id: requestHeader text: qsTr("Tool call request") - font.pointSize: Tokens.font.size.large + font: Tokens.font.headline.medium color: Colors.palette.m3onSurface } @@ -420,7 +420,7 @@ Item { IconButton { id: toolExpandBtn - font.pointSize: Tokens.font.size.large + font: Tokens.font.icon.medium icon: "keyboard_arrow_down" inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline rotation: toolCall.expanded ? 180 : 0 @@ -609,7 +609,7 @@ Item { anchors.topMargin: Tokens.spacing.small anchors.right: parent.right color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small + font: Tokens.font.body.small wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("%1 %2").arg(content.prettyTool(segment.modelData)).arg(content.toolTarget(segment.modelData)) } diff --git a/Modules/Notifications/Sidebar/Chat/Detach.qml b/Modules/Notifications/Sidebar/Chat/Detach.qml index c0ab82d..128202d 100644 --- a/Modules/Notifications/Sidebar/Chat/Detach.qml +++ b/Modules/Notifications/Sidebar/Chat/Detach.qml @@ -11,7 +11,7 @@ Singleton { function create(parent: Item, props: var): void { chatComp.createObject(parent ?? dummy, props); - Visibilities.getForActive().sidebar = false; + ShellState.forActive().sidebar = false; ChatState.isWindow = true; } diff --git a/Modules/Notifications/Sidebar/Chat/SidebarView.qml b/Modules/Notifications/Sidebar/Chat/SidebarView.qml index 499b669..afc93d9 100644 --- a/Modules/Notifications/Sidebar/Chat/SidebarView.qml +++ b/Modules/Notifications/Sidebar/Chat/SidebarView.qml @@ -16,7 +16,7 @@ Item { property int breakpoint: Math.round(Config.sidebar.sizes.width * 2) + Tokens.spacing.medium * 2 + Tokens.padding.medium * 3 property color blobColor: Colors.tPalette.m3surfaceContainerLow - readonly property int iconSize: Math.round(Tokens.font.size.extraLarge * (96 / 72)) + Tokens.padding.large * 2 + readonly property int iconSize: Math.round(Tokens.font.icon.extraLarge.pointSize * (96 / 72)) + Tokens.padding.large * 2 property alias conversationModel: sidebar.model property bool chatOpen: ChatState.chatSession !== null @@ -174,7 +174,7 @@ Item { MaterialIcon { Layout.alignment: Qt.AlignCenter text: "chat_bubble_off" - font.pointSize: Tokens.font.size.extraLarge + fontStyle: Tokens.font.icon.extraLarge color: Colors.tPalette.m3onSurfaceVariant } diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index e72853c..1a6cea8 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -15,7 +15,7 @@ Item { Connections { function onIsWindowChanged(): void { if (ChatState.isWindow) - root.screenState.dashboardTab = 0; + root.screenState.sidebarTab = 0; } target: ChatState @@ -46,9 +46,9 @@ Item { anchors.bottom: parent.bottom anchors.top: parent.top implicitWidth: parent.width - opacity: root.screenState.dashboardTab === 0 ? 1 : 0 + opacity: root.screenState.sidebarTab === 0 ? 1 : 0 visible: opacity > 0 - x: root.screenState.dashboardTab === 0 ? 0 : -root.width + x: root.screenState.sidebarTab === 0 ? 0 : -root.width Behavior on opacity { Anim {} @@ -80,9 +80,9 @@ Item { sourceComponent: Item { id: chatPage - opacity: root.screenState.dashboardTab === 1 ? 1 : 0 + opacity: root.screenState.sidebarTab === 1 ? 1 : 0 visible: opacity > 0 - x: root.screenState.dashboardTab === 0 ? root.width : 0 + x: root.screenState.sidebarTab === 0 ? root.width : 0 z: 1 Behavior on opacity { diff --git a/Modules/Notifications/Sidebar/Tabs.qml b/Modules/Notifications/Sidebar/Tabs.qml index d650550..3dbd448 100644 --- a/Modules/Notifications/Sidebar/Tabs.qml +++ b/Modules/Notifications/Sidebar/Tabs.qml @@ -24,7 +24,7 @@ Item { anchors.right: parent.right anchors.top: parent.top background: null - currentIndex: root.screenState.dashboardTab + currentIndex: root.screenState.sidebarTab implicitHeight: contentHeight contentItem: RowLayout { @@ -108,7 +108,7 @@ Item { color: tab.current ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface radius: Tokens.rounding.small - onClicked: root.screenState.dashboardTab = tab.TabBar.index + onClicked: root.screenState.sidebarTab = tab.TabBar.index } MaterialIcon { @@ -118,7 +118,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant fill: tab.current ? 1 : 0 - font.pointSize: 18 + fontStyle: Tokens.font.icon.large text: tab.iconName Behavior on fill { diff --git a/Modules/Settings/Common/DialogSelectButton.qml b/Modules/Settings/Common/DialogSelectButton.qml index ad133f4..c9bf846 100644 --- a/Modules/Settings/Common/DialogSelectButton.qml +++ b/Modules/Settings/Common/DialogSelectButton.qml @@ -12,7 +12,6 @@ DialogRowButton { property var initialSelect property var selectedItem - property var initialSelect function keyFor(item: var): string { return item.id; diff --git a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml index 73f2eaa..7b0aea9 100644 --- a/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml +++ b/Modules/Settings/Pages/Panels/Bar/BarStatusIcons.qml @@ -57,7 +57,6 @@ PageBase { icon: "add" last: true label: qsTr("Add entry") - last: true model: { const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id)); return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({ diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index 8cecf26..7d1bbaf 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -1,5 +1,6 @@ qml_module(ZShell URI ZShell + INCLUDE_PREFIX core SOURCES writefile.hpp writefile.cpp appdb.hpp appdb.cpp diff --git a/Plugins/ZShell/Config/llm.hpp b/Plugins/ZShell/Config/llm.hpp index bdc5742..aabd24e 100644 --- a/Plugins/ZShell/Config/llm.hpp +++ b/Plugins/ZShell/Config/llm.hpp @@ -1,36 +1,29 @@ #pragma once -#include "configobject.hpp" + #include #include #include +#include "common.hpp" + namespace ZShell::config { -class LlmAppearance : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class LlmAppearance : public settings::ObjectNode { + CONFIG_NODE(LlmAppearance, settings::ObjectNode) - CFG_PROPERTY(QString, scheme, QStringLiteral("tokyoNight")) - - public: - explicit LlmAppearance(QObject* parent = nullptr) : ConfigObject(parent) {} + CONFIG_PROPERTY(QString, scheme, QStringLiteral("tokyoNight")) }; -class Llm : public ConfigObject { - Q_OBJECT - QML_ANONYMOUS +class Llm : public settings::ObjectNode { + CONFIG_NODE(Llm, settings::ObjectNode) - CFG_PROPERTY(QString, endpoint, "http://localhost:8080") - CFG_PROPERTY(QString, model, "") - CFG_PROPERTY(double, temperature, 0.7) - CFG_PROPERTY(bool, tools, true) - CFG_PROPERTY(bool, sandbox, true) - CFG_PROPERTY(bool, enabled, true) + CONFIG_PROPERTY(QString, endpoint, "http://localhost:8080") + CONFIG_PROPERTY(QString, model, "") + CONFIG_PROPERTY(double, temperature, 0.7) + CONFIG_PROPERTY(bool, tools, true) + CONFIG_PROPERTY(bool, sandbox, true) + CONFIG_PROPERTY(bool, enabled, true) CONFIG_SUBOBJECT(LlmAppearance, appearance) - - public: - explicit Llm(QObject* parent = nullptr) - : ConfigObject(parent), m_appearance(new LlmAppearance(this)) {} }; } // namespace ZShell::config diff --git a/Plugins/ZShell/Config/rootnodes.hpp b/Plugins/ZShell/Config/rootnodes.hpp index bbbe3c5..308e90a 100644 --- a/Plugins/ZShell/Config/rootnodes.hpp +++ b/Plugins/ZShell/Config/rootnodes.hpp @@ -16,6 +16,7 @@ #include "dock.hpp" #include "general.hpp" #include "launcher.hpp" +#include "llm.hpp" #include "lock.hpp" #include "notifs.hpp" #include "osd.hpp" @@ -42,6 +43,7 @@ class ConfigRoot : public settings::RootNode { CONFIG_SUBOBJECT(Dock, dock) CONFIG_SUBOBJECT(General, general) CONFIG_SUBOBJECT(Launcher, launcher) + CONFIG_SUBOBJECT(Llm, llm) CONFIG_SUBOBJECT(Lock, lock) CONFIG_SUBOBJECT(Notifs, notifs) CONFIG_SUBOBJECT(Osd, osd) diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt index 6433ee0..5501b34 100644 --- a/Plugins/ZShell/Llm/CMakeLists.txt +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -1,38 +1,14 @@ -# cmark-gfm: markdown -> block structure (pkg-config; no CMake config -# package is installed). pkg_check_modules(CMARK_GFM REQUIRED IMPORTED_TARGET libcmark-gfm) -# tree-sitter runtime for code block highlighting (grammars are -# dlopen()'d at runtime and optional). pkg_check_modules(TREE_SITTER REQUIRED IMPORTED_TARGET tree-sitter) -# JKQTMathText (JKQtPlotter) for LaTeX rendering. The config files live -# in a shared JKQTPlotter6 directory, not one named after the package. find_path(JKQTPlotter6_CMAKE_DIR NAMES JKQTMathText6Config.cmake HINTS /usr/lib/cmake/JKQTPlotter6 /usr/local/lib/cmake/JKQTPlotter6 DOC "Directory containing the JKQtPlotter cmake package files") find_package(JKQTMathText6 REQUIRED PATHS "${JKQTPlotter6_CMAKE_DIR}") -# --- tree-sitter grammar discovery (configure time) --- -# -# Discover installed tree-sitter grammars — system packages -# (libtree-sitter-.so) and the parsers Neovim's nvim-treesitter -# installs (~/.local/share/nvim/site/parser/*.so) — and pair each with -# highlight queries. Query sources, in priority order: -# 1. vendored highlight-queries/*.scm (version-pinned; see the -# per-file source headers, MIT), -# 2. Neovim's own queries (version-matched to its parsers), -# 3. tree-sitter/highlighting from GitHub (cached in the build dir). -# The result is embedded as highlight-queries.hpp. Re-run cmake to pick -# up grammars installed later. -# -# Candidate entry points are read from the .so with `nm` rather than -# assumed to be tree_sitter_; a missing entry point just makes that -# candidate fail at runtime. function(_ts_entry_point out file) - # OUTPUT_VARIABLE + OUTPUT_QUIET loses the output on CMake 4, so - # capture through a temp file. get_filename_component(_ts_nm_base "${file}" NAME) set(_ts_nm_file "${CMAKE_CURRENT_BINARY_DIR}/ts-entry-${_ts_nm_base}") execute_process( @@ -44,8 +20,6 @@ function(_ts_entry_point out file) if(_ts_nm_rc EQUAL 0 AND EXISTS "${_ts_nm_file}") file(READ "${_ts_nm_file}" _ts_nm_out) file(REMOVE "${_ts_nm_file}" "${_ts_nm_file}.err") - # The library also exports the external scanner functions; the - # entry point is the one without the _external suffix. string(REGEX MATCHALL " T tree_sitter_[A-Za-z0-9_]+" _ts_syms "${_ts_nm_out}") foreach(_ts_s IN LISTS _ts_syms) string(REPLACE " T " "" _ts_s "${_ts_s}") @@ -58,22 +32,11 @@ function(_ts_entry_point out file) set(${out} "${_ts_sym}" PARENT_SCOPE) endfunction() -# Append a query text to (PARENT_SCOPE) unless it duplicates a -# hash in . The text is written to the build tree right away: -# the s-expression `;` comments would split CMake list items and the -# query `(` `)` break bracket arguments, so query text only ever lives -# in variables and files, never in lists. -# Expand Neovim's "; inherits:" directives by appending the inherited -# query set's highlights file (recursively; a visited set prevents -# cycles). Several languages are stubs that inherit the real query -# (html inherits html_tags, qmljs inherits ecma, ...). function(_ts_expand_inherits query_dir text outVar) set(result "${text}") set(_visited "") set(_depth 0) while(_depth LESS 8) - # Do not match the leading `;`: a MATCHALL result that itself - # contains a semicolon is re-split into list items. string(REGEX MATCHALL "inherits:[ \t]*[A-Za-z0-9_]+" _inh "${result}") if(NOT _inh) break() @@ -99,7 +62,6 @@ function(_ts_expand_inherits query_dir text outVar) endfunction() function(_ts_add_query sid text filesVar hashesVar) - # filesVar/hashesVar hold the caller's variable names. set(files "${${filesVar}}") set(hashes "${${hashesVar}}") string(SHA256 _ts_qh "${text}") @@ -115,7 +77,7 @@ function(_ts_add_query sid text filesVar hashesVar) set(${hashesVar} "${hashes}" PARENT_SCOPE) endfunction() -set(_ts_candidates "") # entries: ||| +set(_ts_candidates "") file(GLOB _ts_sys_files "/usr/lib/libtree-sitter-*.so" "/usr/local/lib/libtree-sitter-*.so") foreach(_ts_file IN LISTS _ts_sys_files) @@ -164,20 +126,13 @@ set(_ts_vendored foreach(_ts_id IN LISTS _ts_ids) string(REPLACE "-" "_" _ts_sid "${_ts_id}") - # Query candidates in priority order (deduped; identical texts are - # skipped, the nvim site and plugin copies are usually the same - # file). set(_ts_qfiles "") set(_ts_qhashes "") if(_ts_id STREQUAL "cpp") - # The C++ grammar is a superset of C and its query only covers - # the C++ delta; base C coverage comes from the C query. file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/c.scm" _ts_qa) file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/cpp.scm" _ts_qb) _ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes) elseif(_ts_id STREQUAL "typescript" OR _ts_id STREQUAL "tsx") - # The TS grammars reuse the JS node names; the JS query usually - # compiles against them and gives full coverage. file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/javascript.scm" _ts_qa) file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/typescript.scm" _ts_qb) _ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes) @@ -195,10 +150,6 @@ foreach(_ts_id IN LISTS _ts_ids) endif() endforeach() if(NOT _ts_qfiles) - # Last resort: fetch from the tree-sitter/highlighting repo. - # Version-skewed against locally installed grammars, so only - # used when nothing local exists. Cached; offline builds simply - # drop the language. set(_ts_dl "${CMAKE_CURRENT_BINARY_DIR}/ts-query-downloads/${_ts_sid}.scm") if(NOT EXISTS "${_ts_dl}") file(DOWNLOAD @@ -219,7 +170,6 @@ foreach(_ts_id IN LISTS _ts_ids) continue() endif() - # Emit query sources. set(_ts_qn 0) set(_ts_q_ptrs "") foreach(_ts_qfile IN LISTS _ts_qfiles) @@ -232,7 +182,6 @@ foreach(_ts_id IN LISTS _ts_ids) string(APPEND _hl_header "inline constexpr const char* const q_${_ts_sid}[] = { ${_ts_q_ptrs} };\n") - # Emit library candidates (system package first, then Neovim). string(APPEND _hl_header "inline constexpr Candidate cand_${_ts_sid}[] = {\n") set(_ts_nc 0) foreach(_ts_c IN LISTS _ts_candidates) @@ -248,8 +197,7 @@ foreach(_ts_id IN LISTS _ts_ids) endforeach() string(APPEND _hl_header "};\n") if(_ts_nc EQUAL 0) - # Queries but no library: pointless, drop the language. - string(APPEND _hl_header "") # (arrays stay; grammar row is skipped) + string(APPEND _hl_header "") continue() endif() @@ -263,8 +211,6 @@ endforeach() string(APPEND _hl_header "};\n}\n") file(WRITE "${HIGHLIGHT_QUERIES_HPP}" "${_hl_header}") -# Embed the vendored Latin Modern fonts (GUST Font License; provenance -# in fonts/latinmodern/GUST-FONT-LICENSE.txt) as byte arrays. set(LM_FONTS lmroman10-regular lmroman10-italic @@ -314,4 +260,3 @@ qml_module(ZShell-llm ) target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_BINARY_DIR}) -target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../Config) diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp index 9e28bed..4440386 100644 --- a/Plugins/ZShell/Llm/chat.cpp +++ b/Plugins/ZShell/Llm/chat.cpp @@ -1,12 +1,13 @@ #include "chat.hpp" -#include "config.hpp" #include "filetool.hpp" -#include "llm.hpp" #include "llmclient.hpp" #include "shelltool.hpp" #include "webfetchtool.hpp" +#include "config/rootnodes.hpp" +#include "config/llm.hpp" + #include namespace ZShell::llm { @@ -15,14 +16,12 @@ Chat::Chat(QObject* parent) : QObject(parent) , m_store(new ChatStore(this)) , m_client(new LlmClient(this)) { - if (!config::Config::instance()) new config::Config(); - m_store->setLlmClient(m_client); m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); m_client->tools()->registerTool(new FileReadTool(m_client->tools())); m_client->tools()->registerTool(new ShellTool(m_client, m_client->tools())); - const auto* llm = config::Config::instance()->llm(); + const auto* llm = config::ConfigSingleton::instance()->llm(); m_client->setEndpoint(llm->endpoint()); m_client->setModel(llm->model()); m_client->setTemperature(llm->temperature()); @@ -144,7 +143,7 @@ bool Chat::toolsEnabled() const { void Chat::setToolsEnabled(bool value) { m_client->setToolsEnabled(value); - if (auto* config = config::Config::instance()) + if (auto* config = config::ConfigSingleton::instance()) config->llm()->set_tools(value); } @@ -154,7 +153,7 @@ bool Chat::sandboxEnabled() const { void Chat::setSandboxEnabled(bool value) { m_client->setSandboxEnabled(value); - if (auto* config = config::Config::instance()) + if (auto* config = config::ConfigSingleton::instance()) config->llm()->set_sandbox(value); } @@ -195,7 +194,8 @@ void Chat::refreshFromServer() { void Chat::selectModel(const QString& id) { if (id.isEmpty()) return; m_client->setModel(id); - if (auto* config = config::Config::instance()) config->llm()->set_model(id); + if (auto* config = config::ConfigSingleton::instance()) + config->llm()->set_model(id); } } // namespace ZShell::llm From ad36b4ed8f9ff2ddbffc33b17b0ee7464ffffeb3 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 23:33:22 +0200 Subject: [PATCH 61/90] fix: sizing and positioning of portrait aspect ratios for wallpaper cropper --- Modules/Settings/Common/WallpaperCropper.qml | 152 +++++++++---------- 1 file changed, 72 insertions(+), 80 deletions(-) diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index 0b5b0d9..70418ee 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -13,9 +13,7 @@ import qs.Services Item { id: wrapper - readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null - readonly property real maxZoom: 5.0 - + property int buttonRowHeight: Tokens.padding.large * 3 readonly property bool changesMade: { cropRevision; @@ -27,21 +25,13 @@ Item { return Math.abs(r.x - t.x) > 0.5 || Math.abs(r.y - t.y) > 0.5 || Math.abs(r.zoom - t.zoom) > 0.001; } - property int cropRevision: 0 - property int buttonRowHeight: Tokens.padding.large * 3 + readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null + readonly property real maxZoom: 5.0 property var screens: [] property int selectedScreenIndex: 0 property bool shouldBeActive: true - function zoomFromSlider(t: real): real { - return 1 / (1 - t * (1 - 1 / maxZoom)); - } - - function sliderFromZoom(zoom: real): real { - return (1 - 1 / zoom) / (1 - 1 / maxZoom); - } - function applyCrop(): void { if (!cropRectLoader.item || !currentScreen) return; @@ -72,6 +62,10 @@ Item { Qt.callLater(syncCropToScreen); } + function sliderFromZoom(zoom: real): real { + return (1 - 1 / zoom) / (1 - 1 / maxZoom); + } + function syncCropToScreen(): void { if (cropRectLoader.item) cropRectLoader.item.restoreFromData(); @@ -94,6 +88,10 @@ Item { cropRect.clampToBounds(); } + function zoomFromSlider(t: real): real { + return 1 / (1 - t * (1 - 1 / maxZoom)); + } + anchors.left: parent.left anchors.right: parent.right implicitHeight: shouldBeActive ? 430 : 0 @@ -102,13 +100,16 @@ Item { visible: opacity > 0 Behavior on opacity { - Anim {} + Anim { + } } Behavior on scale { - Anim {} + Anim { + } } Behavior on y { - Anim {} + Anim { + } } Component.onCompleted: { @@ -140,12 +141,12 @@ Item { required property var modelData fillWidth: true + font: Tokens.font.body.builders.large.weight(Font.DemiBold).build() inactiveColor: isCurrent ? Colors.palette.m3primary : Qt.alpha(Colors.palette.m3surfaceContainer, 0.7) inactiveOnColor: isCurrent ? Colors.palette.m3onPrimary : Qt.alpha(Colors.palette.m3onSurface, 0.7) isRound: true shapeMorph: true text: modelData.name - font: Tokens.font.body.builders.large.weight(Font.DemiBold).build() onClicked: wrapper.selectScreen(index) } @@ -180,15 +181,15 @@ Item { spacing: Tokens.spacing.medium IconTextButton { - isRound: true - icon: "close" - text: qsTr("Cancel") + enabled: wrapper.changesMade font: Tokens.font.body.large - shapeMorph: true horizontalPadding: Tokens.padding.large + icon: "close" inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) inactiveOnColor: Colors.palette.m3onSurfaceVariant - enabled: wrapper.changesMade + isRound: true + shapeMorph: true + text: qsTr("Cancel") onClicked: { wrapper.syncCropToScreen(); @@ -196,15 +197,15 @@ Item { } IconTextButton { - isRound: true - icon: "check" - text: qsTr("Apply") + enabled: wrapper.changesMade font: Tokens.font.body.large - shapeMorph: true horizontalPadding: Tokens.padding.large + icon: "check" inactiveColor: Colors.palette.m3secondary inactiveOnColor: Colors.palette.m3onSecondary - enabled: wrapper.changesMade + isRound: true + shapeMorph: true + text: qsTr("Apply") onClicked: { wrapper.applyCrop(); @@ -227,42 +228,39 @@ Item { id: fitBox readonly property real aspect: scaledImg.implicitHeight > 0 ? scaledImg.implicitWidth / scaledImg.implicitHeight : 1 - readonly property real fitW: Math.min(imageSlot.width, imageSlot.height * aspect) + readonly property real fitW: imageSlot.height * aspect + anchors.centerIn: parent implicitHeight: Math.round(fitW / aspect) implicitWidth: Math.round(fitW) - x: Math.round((imageSlot.width - width) / 2) - y: Math.round((imageSlot.height - height) / 2) radius: Tokens.rounding.largeIncreased + onAspectChanged: console.log(aspect) + Loader { - anchors.centerIn: parent - - opacity: scaledImg.status === Image.Ready ? 0 : 1 active: opacity > 0 + anchors.centerIn: parent + opacity: scaledImg.status === Image.Ready ? 0 : 1 + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } sourceComponent: CustomRect { - implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2 - implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2 - color: Colors.palette.m3primaryContainer + implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2 + implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2 radius: Tokens.rounding.full LoadingIndicator { id: loadingIndicator anchors.centerIn: parent - containsIcon: true implicitSize: Math.min(fitBox.width, fitBox.height) * 0.3 } } - - Behavior on opacity { - Anim { - type: Anim.DefaultEffects - } - } } Image { @@ -270,14 +268,12 @@ Item { anchors.fill: parent asynchronous: true - fillMode: Image.Stretch - source: Wallpapers.current - sourceSize: { - const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; - return Qt.size(width * dpr, height * dpr); - } - retainWhileLoading: true + fillMode: Image.PreserveAspectFit + mipmap: true opacity: status === Image.Ready ? 1 : 0 + retainWhileLoading: true + smooth: true + source: Wallpapers.current Behavior on opacity { Anim { @@ -297,7 +293,6 @@ Item { property real aspectRatio: wrapper.currentScreen ? wrapper.currentScreen.width / wrapper.currentScreen.height : 1 readonly property real baseHeight: baseWidth / aspectRatio - readonly property real baseWidth: { let fittedHeight = imageH; let fittedWidth = fittedHeight * aspectRatio; @@ -309,7 +304,6 @@ Item { return fittedWidth; } - readonly property real imageH: Math.round(fitBox.height) readonly property real imageW: Math.round(fitBox.width) readonly property real imageX: 0 @@ -327,6 +321,13 @@ Item { y = Math.max(imageY, Math.min(y, imageY + imageH - height)); } + function restoreFromData() { + const t = savedTarget(); + zoom = t.zoom; + x = t.x; + y = t.y; + } + // Where the rect should be according to saved data (same rules as a restore). function savedTarget() { const data = Wallpapers.getCrop(wrapper.currentScreen.name); @@ -349,19 +350,13 @@ Item { }; } - function restoreFromData() { - const t = savedTarget(); - zoom = t.zoom; - x = t.x; - y = t.y; - } - + color: zoomSlider.dragging || mouse.pressed ? Qt.alpha(Colors.palette.m3tertiary, 0.3) : "transparent" height: Math.round(baseHeight / zoom) width: Math.round(baseWidth / zoom) - color: zoomSlider.dragging || mouse.pressed ? Qt.alpha(Colors.palette.m3tertiary, 0.3) : "transparent" Behavior on color { - CAnim {} + CAnim { + } } Component.onCompleted: { @@ -374,21 +369,19 @@ Item { } ShaderEffect { - anchors.fill: parent - + property color borderColor: Colors.palette.m3tertiary + property real borderWidth: 2 + property real cornerRadius: fitBox.radius + property vector4d cropBox: cropRectLoader.item ? Qt.vector4d(snap(cropRectLoader.item.x), snap(cropRectLoader.item.y), snap(cropRectLoader.item.width), snap(cropRectLoader.item.height)) : Qt.vector4d(0, 0, 0, 0) readonly property real dpr: (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1 + property vector2d itemSize: Qt.vector2d(width, height) + property real pixelScale: dpr function snap(v: real): real { return Math.round(v * dpr) / dpr; } - property color borderColor: Colors.palette.m3tertiary - property vector4d cropBox: cropRectLoader.item ? Qt.vector4d(snap(cropRectLoader.item.x), snap(cropRectLoader.item.y), snap(cropRectLoader.item.width), snap(cropRectLoader.item.height)) : Qt.vector4d(0, 0, 0, 0) - property real borderWidth: 2 - property real cornerRadius: fitBox.radius - property vector2d itemSize: Qt.vector2d(width, height) - property real pixelScale: dpr - + anchors.fill: parent fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/crop_sdf.frag.qsb`) } @@ -398,14 +391,6 @@ Item { property real grabX: 0 property real grabY: 0 - function insideCropRect(mx, my): bool { - const r = cropRectLoader.item; - if (!r) - return false; - - return mx >= r.x && mx <= r.x + r.width && my >= r.y && my <= r.y + r.height; - } - function beginGrab(mx, my): void { const r = cropRectLoader.item; if (!r) @@ -422,6 +407,14 @@ Item { } } + function insideCropRect(mx, my): bool { + const r = cropRectLoader.item; + if (!r) + return false; + + return mx >= r.x && mx <= r.x + r.width && my >= r.y && my <= r.y + r.height; + } + function updateCrop(mx, my): void { if (!cropRectLoader.item) return; @@ -439,11 +432,10 @@ Item { } anchors.fill: parent + cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor hoverEnabled: true preventStealing: true - cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor - onPositionChanged: mouse => { if (pressed) { updateCrop(mouse.x, mouse.y); From 765b84a19ce376fcd7edb7184041212bd11599b6 Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 1 Oct 2026 17:11:20 +0200 Subject: [PATCH 62/90] fix: settings drag gesture and dashboard popout regions --- Drawers/Interactions2.qml | 75 +++++++++++++-------------- Modules/Bar/Popouts/UpdatesPopout.qml | 6 ++- 2 files changed, 40 insertions(+), 41 deletions(-) diff --git a/Drawers/Interactions2.qml b/Drawers/Interactions2.qml index 001db80..e11bf02 100644 --- a/Drawers/Interactions2.qml +++ b/Drawers/Interactions2.qml @@ -11,19 +11,18 @@ CustomMouseArea { required property Item bar required property real borderThickness - required property ScreenState screenState + property bool dashboardShortcutActive + property point dragStart required property Drawing drawing required property EdgeGeometry geometry + property bool osdShortcutActive required property Panels panels required property Wrapper popouts required property ShellScreen screen - - property bool dashboardShortcutActive - property bool osdShortcutActive + required property ScreenState screenState + property bool setInitialPoint: false property bool singleGestureTriggered: false property bool utilitiesShortcutActive - property bool setInitialPoint: false - property point dragStart function inBottomPanel(panel: Item, x: real, y: real): bool { const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); @@ -60,39 +59,10 @@ CustomMouseArea { return x >= panelX && x <= panelX + panel.width; } - anchors.fill: parent acceptedButtons: bar.fullscreen ? Qt.NoButton : Qt.AllButtons + anchors.fill: parent hoverEnabled: true - onPressed: event => { - dragStart = Qt.point(event.x, event.y); - - if (screenState.isDrawing) { - if (event.button & Qt.RightButton) { - drawing.content.clear(); - return; - } - - if (!inLeftPanel(panels.drawing, mouseX, mouseY)) - drawing.content.beginStroke(event.x, event.y); - } - } - - onPressedChanged: { - if (!pressed) { - if (screenState.isDrawing) { - setInitialPoint = false; - drawing.content.endStroke(); - if (!inLeftPanel(panels.drawing, mouseX, mouseY)) - drawing.content.showHover(mouseX, mouseY); - } - } else { - if (screenState.isDrawing) { - panels.drawing.collapse(); - } - } - } - onContainsMouseChanged: { if (!containsMouse) { if (!osdShortcutActive) { @@ -111,7 +81,6 @@ CustomMouseArea { screenState.dashboard = false; } } - onPositionChanged: event => { const x = event.x; const y = event.y; @@ -144,7 +113,8 @@ CustomMouseArea { if (pressed && dragStart.y >= screen.height - Config.bar.border && dragStart.x > screen.width / 5 && dragY < -Config.launcher.dragThreshold) screenState.launcher = true; - const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboardWrapper, x, y); + const entryAt = bar.entryAt(geometry.axisPos(x, y)); + const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboardWrapper, x, y) && (!entryAt || entryAt === "activeWindow"); if (!dashboardShortcutActive) { screenState.dashboard = showDashboard; @@ -159,7 +129,7 @@ CustomMouseArea { screenState.dashboard = false; } - if (pressed && geometry.insetTop(borderThickness) > dragStart.y && (Config.dashboard.showOnHover || !inTopPanel(panels.dashboardWrapper, dragStart.x, dragStart.y))) { + if (pressed && geometry.insetTop(borderThickness) > dragStart.y && !inTopPanel(panels.dashboardWrapper, dragStart.x, dragStart.y)) { if (dragY > 20) { screenState.settings = true; } else if (dragY < -20) { @@ -212,6 +182,33 @@ CustomMouseArea { if (geometry.barContains(x, y)) bar.checkPopout(geometry.axisPos(x, y)); } + onPressed: event => { + dragStart = Qt.point(event.x, event.y); + + if (screenState.isDrawing) { + if (event.button & Qt.RightButton) { + drawing.content.clear(); + return; + } + + if (!inLeftPanel(panels.drawing, mouseX, mouseY)) + drawing.content.beginStroke(event.x, event.y); + } + } + onPressedChanged: { + if (!pressed) { + if (screenState.isDrawing) { + setInitialPoint = false; + drawing.content.endStroke(); + if (!inLeftPanel(panels.drawing, mouseX, mouseY)) + drawing.content.showHover(mouseX, mouseY); + } + } else { + if (screenState.isDrawing) { + panels.drawing.collapse(); + } + } + } Timer { id: unhideTimer diff --git a/Modules/Bar/Popouts/UpdatesPopout.qml b/Modules/Bar/Popouts/UpdatesPopout.qml index 6de1f39..a5b380d 100644 --- a/Modules/Bar/Popouts/UpdatesPopout.qml +++ b/Modules/Bar/Popouts/UpdatesPopout.qml @@ -36,8 +36,8 @@ CustomClippingRect { MaterialIcon { id: noUpdatesIcon - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top + anchors.centerIn: parent + anchors.verticalCenterOffset: -noUpdatesText.implicitHeight color: Colors.tPalette.m3onSurfaceVariant fontStyle: Tokens.font.icon.builders.large.size(48).weight(Font.DemiBold).build() horizontalAlignment: Text.AlignHCenter @@ -45,6 +45,8 @@ CustomClippingRect { } CustomText { + id: noUpdatesText + anchors.horizontalCenter: parent.horizontalCenter anchors.top: noUpdatesIcon.bottom color: Colors.tPalette.m3onSurfaceVariant From fc672924d147f57f2e87c6b1ab6ba29aaa3b0b61 Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 2 Oct 2026 19:54:33 +0200 Subject: [PATCH 63/90] fix: settings gpu enum --- Modules/Settings/Pages/ServicesPage.qml | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/Modules/Settings/Pages/ServicesPage.qml b/Modules/Settings/Pages/ServicesPage.qml index fe827bf..e636fb5 100644 --- a/Modules/Settings/Pages/ServicesPage.qml +++ b/Modules/Settings/Pages/ServicesPage.qml @@ -25,18 +25,6 @@ PageBase { text: qsTr("None") } ] - readonly property list gpuValues: ["", "NVIDIA", "GENERIC", "None"] - - function gpuKeyToIndex(key: string): int { - const u = (key ?? "").trim().toUpperCase(); - if (u === "") - return 0; // Auto - if (u === "NVIDIA") - return 1; - if (u === "GENERIC") - return 2; - return 3; // None - } title: qsTr("Services") @@ -211,7 +199,7 @@ PageBase { } SelectRow { - active: root.gpuItems[root.gpuKeyToIndex(Config.services.gpuType)] + active: root.gpuItems[Config.services.gpuType] last: true menuItems: root.gpuItems menuOnTop: true @@ -219,7 +207,7 @@ PageBase { subtext: Gpu.name ? qsTr("Monitoring: %1").arg(Gpu.name) : qsTr("Override for GPU type") text: qsTr("GPU") - onSelected: item => Config.services.gpuType = root.gpuValues[root.gpuItems.indexOf(item)] + onSelected: item => Config.services.gpuType = root.gpuItems.indexOf(item) } } } From dc99d6677ceac1bd9977c9a9c814aeb945a89db5 Mon Sep 17 00:00:00 2001 From: zach Date: Sat, 3 Oct 2026 20:26:00 +0200 Subject: [PATCH 64/90] remove layer usage to reduce vram consumption --- Drawers/Windows.qml | 63 +-- Modules/Settings/Common/WallpaperCropper.qml | 2 - Modules/Wallpaper/Wallpaper.qml | 1 + Plugins/ZShell/Blobs/CMakeLists.txt | 4 + Plugins/ZShell/Blobs/blobgroup2.cpp | 382 +++++++++++++++++++ Plugins/ZShell/Blobs/blobgroup2.hpp | 128 +++++++ Plugins/ZShell/Blobs/blobinvertedrect2.cpp | 44 +++ Plugins/ZShell/Blobs/blobinvertedrect2.hpp | 58 +++ Plugins/ZShell/Blobs/blobmaterial.cpp | 9 +- Plugins/ZShell/Blobs/blobmaterial.hpp | 5 + Plugins/ZShell/Blobs/blobrect.cpp | 119 +++--- Plugins/ZShell/Blobs/blobrect.hpp | 7 + Plugins/ZShell/Blobs/blobrect2.cpp | 353 +++++++++++++++++ Plugins/ZShell/Blobs/blobrect2.hpp | 167 ++++++++ Plugins/ZShell/Blobs/blobshape.cpp | 6 +- Plugins/ZShell/Blobs/blobshape2.cpp | 67 ++++ Plugins/ZShell/Blobs/blobshape2.hpp | 76 ++++ Plugins/ZShell/Blobs/shaders/blob.frag | 56 ++- Plugins/ZShell/Internal/wallpaperimage.cpp | 4 +- shell.qml | 18 +- 20 files changed, 1455 insertions(+), 114 deletions(-) create mode 100644 Plugins/ZShell/Blobs/blobgroup2.cpp create mode 100644 Plugins/ZShell/Blobs/blobgroup2.hpp create mode 100644 Plugins/ZShell/Blobs/blobinvertedrect2.cpp create mode 100644 Plugins/ZShell/Blobs/blobinvertedrect2.hpp create mode 100644 Plugins/ZShell/Blobs/blobrect2.cpp create mode 100644 Plugins/ZShell/Blobs/blobrect2.hpp create mode 100644 Plugins/ZShell/Blobs/blobshape2.cpp create mode 100644 Plugins/ZShell/Blobs/blobshape2.hpp diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index 2121c27..84b53e5 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -52,7 +52,7 @@ CustomWindow { property var root: Quickshell.shellDir readonly property ScreenState screenState: ShellState.forScreen(screen) readonly property real sdfBorderOffset: 2 * fsTransitionProg - readonly property real shadowOpacity: 0.7 * (1 - (bar.isHovered ? 0 : fsTransitionProg)) + readonly property real shadowOpacity: 0.5 * (1 - (bar.isHovered ? 0 : fsTransitionProg)) property color surfaceColor: Colors.tPalette.m3surface WlrLayershell.exclusionMode: ExclusionMode.Ignore @@ -145,7 +145,6 @@ CustomWindow { HyprlandFocusGrab { id: focusGrab - windows: [root] active: { const s = root.screenState; const c = Config; @@ -162,6 +161,7 @@ CustomWindow { return true; return false; } + windows: [root] onCleared: { root.screenState.launcher = false; @@ -187,23 +187,19 @@ CustomWindow { id: surface anchors.fill: parent - layer.enabled: true opacity: root.surfaceColor.a - layer.effect: MultiEffect { - blurMax: 32 - shadowColor: Qt.alpha(Colors.palette.m3shadow, Math.max(0, root.shadowOpacity)) - shadowEnabled: true - } - - BlobGroup { + BlobGroup2 { id: blobGroup + anchors.fill: parent color: root.surfaceColor + shadowColor: Qt.alpha(Colors.palette.m3shadow, Math.max(0, root.shadowOpacity)) + shadowRadius: 18 smoothing: Config.bar.smoothing } - BlobInvertedRect { + BlobInvertedRect2 { anchors.fill: parent anchors.margins: -50 borderBottom: geometry.insetBottom(root.borderThickness) - anchors.margins - root.sdfBorderOffset @@ -299,13 +295,15 @@ CustomWindow { PanelBg { id: resourcesBg + property real extraExtent: 0.2 + deformAmount: 0.05 - implicitHeight: panels.resources.height - implicitWidth: panels.resources.width + implicitHeight: panels.resources.height * (1 + extraExtent) + implicitWidth: panels.resources.width * (1 + extraExtent) panel: panels.resourcesWrapper radius: Tokens.rounding.largeIncreased + Tokens.padding.small - x: panels.resourcesWrapper.x + panels.resources.x + geometry.insetLeft(root.borderThickness) - y: panels.resourcesWrapper.y + panels.resources.y + geometry.insetTop(root.borderThickness) + x: panels.resourcesWrapper.x + panels.resources.x + geometry.insetLeft(root.borderThickness) - panels.resources.width * extraExtent + y: panels.resourcesWrapper.y + panels.resources.y + geometry.insetTop(root.borderThickness) - panels.resources.height * extraExtent } PanelBg { @@ -335,9 +333,15 @@ CustomWindow { PanelBg { id: clipboardBg + property real extraExtent: 0.2 + deformAmount: 0.03 + implicitHeight: panels.clipboard.height * (1 + extraExtent) + implicitWidth: panels.clipboard.width * (1 + extraExtent) panel: panels.clipboard radius: 29 + x: panels.clipboard.x + geometry.insetLeft(root.borderThickness) - panels.clipboard.width * extraExtent + y: panels.clipboard.y + geometry.insetTop(root.borderThickness) } } @@ -345,34 +349,8 @@ CustomWindow { id: drawing anchors.fill: parent - layer.enabled: true screenState: root.screenState z: 2 - - layer.effect: MultiEffect { - maskEnabled: true - maskInverted: true - maskSource: maskSource - } - } - - Item { - id: maskSource - - anchors.fill: parent - layer.enabled: true - visible: false - - CustomRect { - readonly property int extraWidth: radius - - color: "white" - implicitHeight: panels.drawing.height - implicitWidth: panels.drawing.width + extraWidth - radius: drawingBg.radius - x: -extraWidth + root.borderThickness + panels.drawing.x - y: panels.drawing.y + bar.implicitHeight - } } Interactions2 { @@ -452,10 +430,11 @@ CustomWindow { } } - component PanelBg: BlobRect { + component PanelBg: BlobRect2 { property real deformAmount: 0.15 required property Item panel + damping: 28.3 deformScale: (deformAmount * Config.appearance.deform.scale) / 10000 group: blobGroup implicitHeight: panel.height diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index 70418ee..393b3d9 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -235,8 +235,6 @@ Item { implicitWidth: Math.round(fitW) radius: Tokens.rounding.largeIncreased - onAspectChanged: console.log(aspect) - Loader { active: opacity > 0 anchors.centerIn: parent diff --git a/Modules/Wallpaper/Wallpaper.qml b/Modules/Wallpaper/Wallpaper.qml index 9de4551..02fe54b 100644 --- a/Modules/Wallpaper/Wallpaper.qml +++ b/Modules/Wallpaper/Wallpaper.qml @@ -21,6 +21,7 @@ Loader { WlrLayershell.namespace: "ZShell-Wallpaper" color: "transparent" screen: modelData + surfaceFormat.opaque: true anchors { bottom: true diff --git a/Plugins/ZShell/Blobs/CMakeLists.txt b/Plugins/ZShell/Blobs/CMakeLists.txt index a51b194..32798f5 100644 --- a/Plugins/ZShell/Blobs/CMakeLists.txt +++ b/Plugins/ZShell/Blobs/CMakeLists.txt @@ -5,6 +5,10 @@ qml_module(ZShell-blobs blobshape.cpp blobrect.cpp blobinvertedrect.cpp + blobgroup2.cpp + blobshape2.cpp + blobrect2.cpp + blobinvertedrect2.cpp blobmaterial.cpp LIBRARIES Qt::Quick diff --git a/Plugins/ZShell/Blobs/blobgroup2.cpp b/Plugins/ZShell/Blobs/blobgroup2.cpp new file mode 100644 index 0000000..219d48c --- /dev/null +++ b/Plugins/ZShell/Blobs/blobgroup2.cpp @@ -0,0 +1,382 @@ +#include "blobgroup2.hpp" +#include "blobinvertedrect2.hpp" +#include "blobshape2.hpp" + +#include +#include + +#include +#include +#include + +static float cpuSdBox( + float px, float py, float cx, float cy, float hw, float hh) { + const float dx = std::abs(px - cx) - hw; + const float dy = std::abs(py - cy) - hh; + const float mdx = std::max(dx, 0.0f); + const float mdy = std::max(dy, 0.0f); + return std::sqrt(mdx * mdx + mdy * mdy) + std::min(std::max(dx, dy), 0.0f); +} + +static float cpuSmoothstep(float edge0, float edge1, float x) { + const float t = std::clamp((x - edge0) / (edge1 - edge0), 0.0f, 1.0f); + return t * t * (3.0f - 2.0f * t); +} + +static float cornerFillFactor(float sd, float smoothFactor) { + const float outside = cpuSmoothstep(0.0f, smoothFactor, sd); + const float inside = cpuSmoothstep(0.0f, -smoothFactor, sd); + return std::max(outside, inside); +} + +BlobGroup2::BlobGroup2(QQuickItem* parent) : QQuickItem(parent) { + setFlag(ItemHasContents); +} + +BlobGroup2::~BlobGroup2() { + for (auto* shape : std::as_const(m_shapes)) + shape->m_group = nullptr; + if (m_invertedRect) + static_cast(m_invertedRect)->m_group = nullptr; +} + +void BlobGroup2::setSmoothing(qreal s) { + if (qFuzzyCompare(m_smoothing, s)) return; + m_smoothing = s; + emit smoothingChanged(); + markDirty(); +} + +void BlobGroup2::setColor(const QColor& c) { + if (m_color == c) return; + m_color = c; + emit colorChanged(); + markDirty(); +} + +void BlobGroup2::setCornerFill(bool e) { + if (m_cornerFill == e) return; + m_cornerFill = e; + emit cornerFillChanged(); + markDirty(); +} + +void BlobGroup2::setShadowColor(const QColor& c) { + if (m_shadowColor == c) return; + m_shadowColor = c; + emit shadowColorChanged(); + markDirty(); +} + +void BlobGroup2::setShadowRadius(qreal r) { + if (qFuzzyCompare(m_shadowRadius, r)) return; + m_shadowRadius = r; + emit shadowRadiusChanged(); + markDirty(); +} + +void BlobGroup2::addShape(BlobShape2* shape) { + if (!shape || m_shapes.contains(shape)) return; + m_shapes.append(shape); + markDirty(); +} + +void BlobGroup2::removeShape(BlobShape2* shape) { + m_shapes.removeOne(shape); + markDirty(); +} + +void BlobGroup2::setInvertedRect(BlobInvertedRect2* rect) { + if (m_invertedRect == rect) return; + m_invertedRect = rect; + markDirty(); +} + +void BlobGroup2::clearInvertedRect(BlobInvertedRect2* rect) { + if (m_invertedRect != rect) return; + m_invertedRect = nullptr; + markDirty(); +} + +void BlobGroup2::markDirty() { + m_physicsUpdated = false; + m_dataBuilt = false; + // Physics runs during polish, before the render pass. The panel content is + // transformed by deformMatrix from QML, so the matrix has to be final for this + // frame; building the data in updatePaintNode would let the transform lag one + // frame behind the material. + polish(); + update(); +} + +void BlobGroup2::updatePolish() { + // The group is the only thing that draws, so it also drives the shapes' + // physics. + ensureDataBuilt(); +} + +void BlobGroup2::ensurePhysicsUpdated() { + if (m_physicsUpdated) return; + m_physicsUpdated = true; + for (auto* shape : std::as_const(m_shapes)) + shape->updatePhysics(); +} + +void BlobGroup2::ensureDataBuilt() { + if (m_dataBuilt) return; + ensurePhysicsUpdated(); + + // Physics can request the next frame through markDirty(); the data is + // refreshed here anyway, so the flag is set after it runs. + m_dataBuilt = true; + m_data.rects.clear(); + m_bounds = QRectF(); + + const float smoothFactor = static_cast(m_smoothing); + // The same reach the shader uses to decide whether a rect can contribute, plus + // the shadow, so every pixel the shader can give a value to is inside the node. + const double extent = static_cast(smoothFactor) * 1.5 + + static_cast(m_shadowRadius); + + // Group order, no pruning: the shader sees this list for every pixel. + // entryShapes maps a shared entry back to the shape it came from. + QVector entryShapes; + + for (auto* shape : std::as_const(m_shapes)) { + if (shape->width() <= 0 || shape->height() <= 0) continue; + if (static_cast(m_data.rects.size()) >= BlobGroupData::kMaxRects) + continue; + + const QPointF scene = shape->mapToScene(QPointF(0, 0)); + const QMatrix4x4& dm = shape->m_deformMatrix; + + BlobRectData r; + r.cx = static_cast(scene.x() + shape->width() / 2.0); + r.cy = static_cast(scene.y() + shape->height() / 2.0); + r.hw = static_cast(shape->width()) * 0.5f; + r.hh = static_cast(shape->height()) * 0.5f; + shape->cornerRadii(r.radius); + r.offsetX = dm(0, 3); + r.offsetY = dm(1, 3); + + const float a = dm(0, 0), b = dm(1, 0); + const float c = dm(0, 1), e = dm(1, 1); + const float det = a * e - c * b; + const float invDet = std::abs(det) > 1e-6f ? 1.0f / det : 1.0f; + r.invDeform[0] = e * invDet; + r.invDeform[1] = -b * invDet; + r.invDeform[2] = -c * invDet; + r.invDeform[3] = a * invDet; + + const float halfTr = 0.5f * (a + e); + const float halfDiff = 0.5f * (a - e); + r.minEig = halfTr - std::sqrt(halfDiff * halfDiff + c * c); + + r.screenHalfX = std::abs(a) * r.hw + std::abs(c) * r.hh; + r.screenHalfY = std::abs(b) * r.hw + std::abs(e) * r.hh; + + m_data.rects.append(r); + m_bounds = m_bounds.united(QRectF( + static_cast(r.cx) + static_cast(r.offsetX) - + static_cast(r.screenHalfX) - extent, + static_cast(r.cy) + static_cast(r.offsetY) - + static_cast(r.screenHalfY) - extent, + 2.0 * (static_cast(r.screenHalfX) + extent), + 2.0 * (static_cast(r.screenHalfY) + extent))); + + entryShapes.append(shape); + } + + const int count = static_cast(m_data.rects.size()); + + // Exclusion gates only the smooth blend. + for (int i = 0; i < count; ++i) { + int mask = 0; + BlobShape2* const si = entryShapes[i]; + for (int j = 0; j < count; ++j) { + if (j == i) continue; + BlobShape2* const sj = entryShapes[j]; + if (si->isExcluded(sj) || sj->isExcluded(si)) mask |= (1 << j); + } + m_data.rects[i].excludeMask = mask; + } + + constexpr float minR = 2.0f; + const bool cornerFill = m_cornerFill; + + for (int i = 0; i < count; ++i) { + auto& ri = m_data.rects[i]; + BlobShape2* const si = entryShapes[i]; + float fTr = 1.0f, fBr = 1.0f, fBl = 1.0f, fTl = 1.0f; + + const float cTrX = ri.cx + ri.hw, cTrY = ri.cy - ri.hh; + const float cBrX = ri.cx + ri.hw, cBrY = ri.cy + ri.hh; + const float cBlX = ri.cx - ri.hw, cBlY = ri.cy + ri.hh; + const float cTlX = ri.cx - ri.hw, cTlY = ri.cy - ri.hh; + + if (cornerFill) { + for (int j = 0; j < count; ++j) { + if (j == i) continue; + if (ri.excludeMask & (1 << j)) continue; + BlobShape2* const sj = entryShapes[j]; + if (si->isCornerExcluded(sj) || sj->isCornerExcluded(si)) + continue; + const auto& rj = m_data.rects[j]; + fTr = std::min( + fTr, + cornerFillFactor( + cpuSdBox(cTrX, cTrY, rj.cx, rj.cy, rj.hw, rj.hh), + smoothFactor)); + fBr = std::min( + fBr, + cornerFillFactor( + cpuSdBox(cBrX, cBrY, rj.cx, rj.cy, rj.hw, rj.hh), + smoothFactor)); + fBl = std::min( + fBl, + cornerFillFactor( + cpuSdBox(cBlX, cBlY, rj.cx, rj.cy, rj.hw, rj.hh), + smoothFactor)); + fTl = std::min( + fTl, + cornerFillFactor( + cpuSdBox(cTlX, cTlY, rj.cx, rj.cy, rj.hw, rj.hh), + smoothFactor)); + } + } + + ri.radius[0] = std::max(ri.radius[0] * fTr, minR); + ri.radius[1] = std::max(ri.radius[1] * fBr, minR); + ri.radius[2] = std::max(ri.radius[2] * fBl, minR); + ri.radius[3] = std::max(ri.radius[3] * fTl, minR); + } + + // Inverted rect. The frame term is evaluated for every pixel in the bounds, + // so the group flag cannot be gated per shape. + if (!m_invertedRect) return; + + auto* inv = static_cast(m_invertedRect); + if (inv->width() <= 0 || inv->height() <= 0) return; + + const QPointF invScene = inv->mapToScene(QPointF(0, 0)); + const float outerCX = static_cast(invScene.x() + inv->width() / 2.0); + const float outerCY = + static_cast(invScene.y() + inv->height() / 2.0); + const float outerHW = static_cast(inv->width()) * 0.5f; + const float outerHH = static_cast(inv->height()) * 0.5f; + + const auto* invRect = m_invertedRect; + const float innerCX = + outerCX + static_cast( + (invRect->borderLeft() - invRect->borderRight()) / 2.0); + const float innerCY = + outerCY + static_cast( + (invRect->borderTop() - invRect->borderBottom()) / 2.0); + const float innerHW = + outerHW - static_cast( + (invRect->borderLeft() + invRect->borderRight()) / 2.0); + const float innerHH = + outerHH - static_cast( + (invRect->borderTop() + invRect->borderBottom()) / 2.0); + + m_data.hasInverted = true; + m_data.invertedRadius = static_cast(invRect->radius()); + + m_data.invertedOuter[0] = outerCX; + m_data.invertedOuter[1] = outerCY; + m_data.invertedOuter[2] = outerHW; + m_data.invertedOuter[3] = outerHH; + + m_data.invertedInner[0] = innerCX; + m_data.invertedInner[1] = innerCY; + m_data.invertedInner[2] = innerHW; + m_data.invertedInner[3] = innerHH; + + m_bounds = m_bounds.united(QRectF( + static_cast(outerCX) - static_cast(outerHW) - extent, + static_cast(outerCY) - static_cast(outerHH) - extent, + 2.0 * (static_cast(outerHW) + extent), + 2.0 * (static_cast(outerHH) + extent))); +} + +void BlobGroup2::fillMaterial(BlobMaterial* material) { + material->m_paddedX = static_cast(m_bounds.x()); + material->m_paddedY = static_cast(m_bounds.y()); + material->m_paddedW = static_cast(m_bounds.width()); + material->m_paddedH = static_cast(m_bounds.height()); + + material->m_smoothFactor = static_cast(m_smoothing); + // The single group draw has no owner, so the shader skips the ownership test. + material->m_myIndex = -2; + material->m_color = m_color; + material->m_hasInverted = m_data.hasInverted ? 1 : 0; + material->m_invertedRadius = data().invertedRadius; + memcpy( + material->m_invertedOuter, + data().invertedOuter, + sizeof(data().invertedOuter)); + memcpy( + material->m_invertedInner, + data().invertedInner, + sizeof(data().invertedInner)); + + material->m_shadowRadius = static_cast(m_shadowRadius); + material->m_shadowColor[0] = static_cast(m_shadowColor.redF()); + material->m_shadowColor[1] = static_cast(m_shadowColor.greenF()); + material->m_shadowColor[2] = static_cast(m_shadowColor.blueF()); + material->m_shadowColor[3] = static_cast(m_shadowColor.alphaF()); + + const int count = + static_cast(qMin(data().rects.size(), qsizetype(16))); + material->m_rectCount = count; + for (int i = 0; i < count; ++i) + material->m_rects[i] = data().rects[i]; +} + +QSGNode* BlobGroup2::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) { + // Data is built during polish. This only covers a render pass that happens + // without one. + ensureDataBuilt(); + + if (m_data.rects.isEmpty() && !m_data.hasInverted) { + delete oldNode; + return nullptr; + } + + auto* node = static_cast(oldNode); + if (!node) { + node = new QSGGeometryNode; + + auto* geometry = + new QSGGeometry(QSGGeometry::defaultAttributes_TexturedPoint2D(), 4); + geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); + node->setGeometry(geometry); + node->setFlag(QSGNode::OwnsGeometry); + + auto* material = new BlobMaterial; + material->setFlag(QSGMaterial::Blending); + node->setMaterial(material); + node->setFlag(QSGNode::OwnsMaterial); + } + + // The shader evaluates the field at the scene position a vertex maps to, so + // the group item itself must not be transformed. The vertex positions are the + // bounds in this item's frame; the material carries the same rect in scene + // coordinates. + auto* v = node->geometry()->vertexDataAsTexturedPoint2D(); + const QPointF p0 = mapFromScene(m_bounds.topLeft()); + const QPointF p1 = mapFromScene(m_bounds.bottomRight()); + + v[0].set(static_cast(p0.x()), static_cast(p0.y()), 0.0f, 0.0f); + v[1].set(static_cast(p1.x()), static_cast(p0.y()), 1.0f, 0.0f); + v[2].set(static_cast(p0.x()), static_cast(p1.y()), 0.0f, 1.0f); + v[3].set(static_cast(p1.x()), static_cast(p1.y()), 1.0f, 1.0f); + + node->markDirty(QSGNode::DirtyGeometry); + + fillMaterial(static_cast(node->material())); + + node->markDirty(QSGNode::DirtyMaterial); + + return node; +} diff --git a/Plugins/ZShell/Blobs/blobgroup2.hpp b/Plugins/ZShell/Blobs/blobgroup2.hpp new file mode 100644 index 0000000..9f922e6 --- /dev/null +++ b/Plugins/ZShell/Blobs/blobgroup2.hpp @@ -0,0 +1,128 @@ +#pragma once + +#include "blobmaterial.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +class BlobShape2; +class BlobInvertedRect2; + +// One shared valuation for a whole group, drawn by one node. +// +// The shapes here are data only: they say where the material should be. The group +// draws a single quad over everything the shader can evaluate, so a pixel cannot +// be evaluated by one shape and missed by another, and no second draw can +// disagree with the first. That is what makes overlapping shapes read as one +// surface - one value, one writer, one alpha per pixel. +struct BlobGroupData { + // The shader reads rectData[80] = 16 entries, and excludeMask is a 32-bit + // mask. The shared list is capped at 16, in group order, so the cap is the + // same list for every pixel. + static constexpr int kMaxRects = 16; + + QVector rects; + bool hasInverted = false; + float invertedRadius = 0; + float invertedOuter[4] = {}; + float invertedInner[4] = {}; +}; + +class BlobGroup2 : public QQuickItem { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY( + qreal smoothing READ smoothing WRITE setSmoothing NOTIFY + smoothingChanged) + Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged) + Q_PROPERTY( + bool cornerFill READ cornerFill WRITE setCornerFill NOTIFY + cornerFillChanged) + Q_PROPERTY( + QColor shadowColor READ shadowColor WRITE setShadowColor NOTIFY + shadowColorChanged) + Q_PROPERTY( + qreal shadowRadius READ shadowRadius WRITE setShadowRadius NOTIFY + shadowRadiusChanged) + + friend class BlobShape2; + + public: + explicit BlobGroup2(QQuickItem* parent = nullptr); + ~BlobGroup2() override; + + [[nodiscard]] qreal smoothing() const { return m_smoothing; } + + void setSmoothing(qreal s); + + [[nodiscard]] QColor color() const { return m_color; } + + void setColor(const QColor& c); + + [[nodiscard]] bool cornerFill() const { return m_cornerFill; } + + void setCornerFill(bool e); + + [[nodiscard]] QColor shadowColor() const { return m_shadowColor; } + + void setShadowColor(const QColor& c); + + [[nodiscard]] qreal shadowRadius() const { return m_shadowRadius; } + + void setShadowRadius(qreal r); + + void addShape(BlobShape2* shape); + void removeShape(BlobShape2* shape); + + void setInvertedRect(BlobInvertedRect2* rect); + void clearInvertedRect(BlobInvertedRect2* rect); + + [[nodiscard]] const QList& shapes() const { return m_shapes; } + + [[nodiscard]] BlobInvertedRect2* invertedRect() const { + return m_invertedRect; + } + + [[nodiscard]] const BlobGroupData& data() const { return m_data; } + + // One draw per group, so a change to any shape changes the value for every + // pixel. Refresh the whole group. + void markDirty(); + + signals: + void smoothingChanged(); + void colorChanged(); + void cornerFillChanged(); + void shadowColorChanged(); + void shadowRadiusChanged(); + + protected: + void updatePolish() override; + QSGNode* updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) override; + + private: + void ensurePhysicsUpdated(); + void ensureDataBuilt(); + void fillMaterial(BlobMaterial* material); + + qreal m_smoothing = 32.0; + QColor m_color{0x44, 0x88, 0xff}; + bool m_cornerFill = true; + QColor m_shadowColor{0, 0, 0, 128}; + qreal m_shadowRadius = 0.0; + QList m_shapes; + BlobInvertedRect2* m_invertedRect = nullptr; + bool m_physicsUpdated = false; + bool m_dataBuilt = false; + BlobGroupData m_data; + + // Scene-space union of what the shader can evaluate for any shape. The node + // covers exactly this, so gaps are not possible. + QRectF m_bounds; +}; diff --git a/Plugins/ZShell/Blobs/blobinvertedrect2.cpp b/Plugins/ZShell/Blobs/blobinvertedrect2.cpp new file mode 100644 index 0000000..2972cf2 --- /dev/null +++ b/Plugins/ZShell/Blobs/blobinvertedrect2.cpp @@ -0,0 +1,44 @@ +#include "blobinvertedrect2.hpp" +#include "blobgroup2.hpp" + +BlobInvertedRect2::BlobInvertedRect2(QQuickItem* parent) : BlobShape2(parent) {} + +BlobInvertedRect2::~BlobInvertedRect2() { + if (m_group) m_group->clearInvertedRect(this); +} + +void BlobInvertedRect2::setBorderLeft(qreal v) { + if (qFuzzyCompare(m_borderLeft, v)) return; + m_borderLeft = v; + emit borderLeftChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobInvertedRect2::setBorderRight(qreal v) { + if (qFuzzyCompare(m_borderRight, v)) return; + m_borderRight = v; + emit borderRightChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobInvertedRect2::setBorderTop(qreal v) { + if (qFuzzyCompare(m_borderTop, v)) return; + m_borderTop = v; + emit borderTopChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobInvertedRect2::setBorderBottom(qreal v) { + if (qFuzzyCompare(m_borderBottom, v)) return; + m_borderBottom = v; + emit borderBottomChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobInvertedRect2::registerWithGroup() { + if (m_group) m_group->setInvertedRect(this); +} + +void BlobInvertedRect2::unregisterFromGroup() { + if (m_group) m_group->clearInvertedRect(this); +} diff --git a/Plugins/ZShell/Blobs/blobinvertedrect2.hpp b/Plugins/ZShell/Blobs/blobinvertedrect2.hpp new file mode 100644 index 0000000..4b7fb85 --- /dev/null +++ b/Plugins/ZShell/Blobs/blobinvertedrect2.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include "blobshape2.hpp" + +#include + +class BlobInvertedRect2 : public BlobShape2 { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY( + qreal borderLeft READ borderLeft WRITE setBorderLeft NOTIFY + borderLeftChanged) + Q_PROPERTY( + qreal borderRight READ borderRight WRITE setBorderRight NOTIFY + borderRightChanged) + Q_PROPERTY( + qreal borderTop READ borderTop WRITE setBorderTop NOTIFY + borderTopChanged) + Q_PROPERTY( + qreal borderBottom READ borderBottom WRITE setBorderBottom NOTIFY + borderBottomChanged) + + public: + explicit BlobInvertedRect2(QQuickItem* parent = nullptr); + ~BlobInvertedRect2() override; + + [[nodiscard]] qreal borderLeft() const { return m_borderLeft; } + + void setBorderLeft(qreal v); + + [[nodiscard]] qreal borderRight() const { return m_borderRight; } + + void setBorderRight(qreal v); + + [[nodiscard]] qreal borderTop() const { return m_borderTop; } + + void setBorderTop(qreal v); + + [[nodiscard]] qreal borderBottom() const { return m_borderBottom; } + + void setBorderBottom(qreal v); + + signals: + void borderLeftChanged(); + void borderRightChanged(); + void borderTopChanged(); + void borderBottomChanged(); + + protected: + void registerWithGroup() override; + void unregisterFromGroup() override; + + private: + qreal m_borderLeft = 0; + qreal m_borderRight = 0; + qreal m_borderTop = 0; + qreal m_borderBottom = 0; +}; diff --git a/Plugins/ZShell/Blobs/blobmaterial.cpp b/Plugins/ZShell/Blobs/blobmaterial.cpp index cdd5cd7..528d617 100644 --- a/Plugins/ZShell/Blobs/blobmaterial.cpp +++ b/Plugins/ZShell/Blobs/blobmaterial.cpp @@ -33,7 +33,7 @@ bool BlobMaterialShader::updateUniformData( Q_UNUSED(oldMaterial); auto* mat = static_cast(newMaterial); QByteArray* buf = state.uniformData(); - Q_ASSERT(buf->size() >= 1440); + Q_ASSERT(buf->size() >= 1472); if (state.isMatrixDirty()) { const QMatrix4x4 m = state.combinedMatrix(); @@ -56,7 +56,7 @@ bool BlobMaterialShader::updateUniformData( // Rect count (offset 88) memcpy(buf->data() + 88, &mat->m_rectCount, 4); - // My index (offset 92) + // My index (offset 92). -2 skips the ownership test in the shader. memcpy(buf->data() + 92, &mat->m_myIndex, 4); // Color as vec4 (offset 96, 16 bytes) @@ -100,5 +100,10 @@ bool BlobMaterialShader::updateUniformData( memcpy(buf->data() + base + 64, r.radius, 16); } + // Shadow: after rectData, which ends at 1440. shadowColor is 16-byte aligned, + // so it lands at 1456. + memcpy(buf->data() + 1440, &mat->m_shadowRadius, 4); + memcpy(buf->data() + 1456, mat->m_shadowColor, 16); + return true; } diff --git a/Plugins/ZShell/Blobs/blobmaterial.hpp b/Plugins/ZShell/Blobs/blobmaterial.hpp index 4a30448..19536a5 100644 --- a/Plugins/ZShell/Blobs/blobmaterial.hpp +++ b/Plugins/ZShell/Blobs/blobmaterial.hpp @@ -32,12 +32,17 @@ class BlobMaterial : public QSGMaterial { float m_paddedH = 0; float m_smoothFactor = 32.0f; int m_rectCount = 0; + // -2 means no owner test: the single group draw writes every pixel it covers. int m_myIndex = -2; QColor m_color{0x44, 0x88, 0xff}; int m_hasInverted = 0; float m_invertedRadius = 0; float m_invertedOuter[4] = {}; float m_invertedInner[4] = {}; + // Phase 2: shadow around the material edges. Matches the uniform block, which + // puts these after rectData. + float m_shadowRadius = 0.0f; + float m_shadowColor[4] = {0.0f, 0.0f, 0.0f, 0.5f}; BlobRectData m_rects[16] = {}; }; diff --git a/Plugins/ZShell/Blobs/blobrect.cpp b/Plugins/ZShell/Blobs/blobrect.cpp index f7dc72c..3100604 100644 --- a/Plugins/ZShell/Blobs/blobrect.cpp +++ b/Plugins/ZShell/Blobs/blobrect.cpp @@ -5,6 +5,10 @@ #include #include +namespace { +constexpr float k_retireMs = 120.0f; +} + BlobRect::BlobRect(QQuickItem* parent) : BlobShape(parent) {} BlobRect::~BlobRect() { @@ -12,41 +16,75 @@ BlobRect::~BlobRect() { } void BlobRect::updatePolish() { - if (m_physicsActive) { - float totalDelta = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + - std::abs(m_dm11 - 1.0f); - float totalVel = - std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11); + BlobShape::updatePolish(); - if (totalDelta < 0.004f && totalVel < 0.05f) { + if (!m_physicsActive) return; + + if (m_retiring) { + const float t = std::clamp( + static_cast(m_retireElapsed.elapsed()) / k_retireMs, + 0.0f, + 1.0f); + const float s = t * t * (3.0f - 2.0f * t); + + if (t >= 1.0f) { m_dm00 = 1.0f; m_dm01 = 0.0f; m_dm11 = 1.0f; - m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; m_deformMatrix = QMatrix4x4(); - emit rawDeformMatrixChanged(); - updateCenteredDeformMatrix(); + m_retiring = false; m_physicsActive = false; - - if (m_group) { - QMetaObject::invokeMethod( - this, - [this]() { - if (m_group) m_group->markDirty(); - }, - Qt::QueuedConnection); - } } else { - QMetaObject::invokeMethod( - this, - [this]() { - if (m_physicsActive && m_group) m_group->markDirty(); - }, - Qt::QueuedConnection); + m_dm00 = m_retire00 + (1.0f - m_retire00) * s; + m_dm01 = m_retire01 - m_retire01 * s; + m_dm11 = m_retire11 + (1.0f - m_retire11) * s; + m_deformMatrix = QMatrix4x4( + m_dm00, + m_dm01, + 0, + 0, + m_dm01, + m_dm11, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1); } + + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + + if (!m_physicsActive) return; + } else { + const float totalDelta = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + + std::abs(m_dm11 - 1.0f); + const float totalVel = + std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11); + + if (totalDelta < 0.004f && totalVel < 0.05f) beginRetire(); } - BlobShape::updatePolish(); + QMetaObject::invokeMethod( + this, + [this]() { + if (m_physicsActive && m_group) m_group->markDirty(); + }, + Qt::QueuedConnection); +} + +void BlobRect::beginRetire() { + m_retire00 = m_dm00; + m_retire01 = m_dm01; + m_retire11 = m_dm11; + m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; + m_retireElapsed.start(); + m_retiring = true; } void BlobRect::updatePhysics() { @@ -74,6 +112,13 @@ void BlobRect::updatePhysics() { const float speed = std::sqrt(velX * velX + velY * velY); + if (m_retiring) { + if (speed > 5.0f) + m_retiring = false; + else + return; + } + if (!m_physicsActive) { if (speed < 5.0f) return; m_physicsActive = true; @@ -297,6 +342,8 @@ void BlobRect::excludeCornersRemoveLast(QQmlListProperty* prop) { } void BlobRect::checkAtRest(float speed) { + if (m_retiring) return; + constexpr float kEpsilon = 0.002f; const bool atRest = std::abs(m_dm00 - 1.0f) < kEpsilon && std::abs(m_dm01) < kEpsilon && @@ -304,25 +351,5 @@ void BlobRect::checkAtRest(float speed) { std::abs(m_dmVel01) < kEpsilon && std::abs(m_dmVel11) < kEpsilon && speed < 5.0f; - if (atRest) { - m_dm00 = 1.0f; - m_dm01 = 0.0f; - m_dm11 = 1.0f; - m_dmVel00 = 0.0f; - m_dmVel01 = 0.0f; - m_dmVel11 = 0.0f; - m_deformMatrix = QMatrix4x4(); - emit rawDeformMatrixChanged(); - updateCenteredDeformMatrix(); - m_physicsActive = false; - - if (m_group) { - QMetaObject::invokeMethod( - this, - [this]() { - if (m_group) m_group->markDirty(); - }, - Qt::QueuedConnection); - } - } + if (atRest) beginRetire(); } diff --git a/Plugins/ZShell/Blobs/blobrect.hpp b/Plugins/ZShell/Blobs/blobrect.hpp index 51a6157..68df1c3 100644 --- a/Plugins/ZShell/Blobs/blobrect.hpp +++ b/Plugins/ZShell/Blobs/blobrect.hpp @@ -108,6 +108,7 @@ class BlobRect : public BlobShape { private: void checkAtRest(float speed); + void beginRetire(); // Physics state QPointF m_prevScenePos; @@ -115,6 +116,12 @@ class BlobRect : public BlobShape { bool m_physicsActive = false; bool m_hasPrevPos = false; + bool m_retiring = false; + QElapsedTimer m_retireElapsed; + float m_retire00 = 1.0f; + float m_retire01 = 0.0f; + float m_retire11 = 1.0f; + // Symmetric 2x2 deformation matrix components (3 independent: m00, m01, // m11) Rest state is identity: m00=1, m01=0, m11=1 float m_dm00 = 1.0f; diff --git a/Plugins/ZShell/Blobs/blobrect2.cpp b/Plugins/ZShell/Blobs/blobrect2.cpp new file mode 100644 index 0000000..3fdfa28 --- /dev/null +++ b/Plugins/ZShell/Blobs/blobrect2.cpp @@ -0,0 +1,353 @@ +#include "blobrect2.hpp" +#include "blobgroup2.hpp" + +#include +#include + +namespace { +constexpr float k_retireMs = 120.0f; +} // namespace + +BlobRect2::BlobRect2(QQuickItem* parent) : BlobShape2(parent) {} + +BlobRect2::~BlobRect2() { + if (m_group) m_group->removeShape(this); +} + +void BlobRect2::finishAtIdentity() { + m_dm00 = 1.0f; + m_dm01 = 0.0f; + m_dm11 = 1.0f; + m_deformMatrix = QMatrix4x4(); + m_retiring = false; + m_physicsActive = false; + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); +} + +void BlobRect2::beginRetire() { + // The settle check would otherwise re-enter this every frame and restart the + // curve timer, so the curve never reaches t = 1. + if (m_retiring) return; + + m_retire00 = m_dm00; + m_retire01 = m_dm01; + m_retire11 = m_dm11; + m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f; + m_retireElapsed.start(); + m_retiring = true; +} + +void BlobRect2::updatePhysics() { + // Shapes are data only, so the group drives physics here. + if (m_retiring) { + const float t = std::clamp( + static_cast(m_retireElapsed.elapsed()) / k_retireMs, + 0.0f, + 1.0f); + const float s = t * t * (3.0f - 2.0f * t); + + if (t >= 1.0f) { + finishAtIdentity(); + return; + } + + m_dm00 = m_retire00 + (1.0f - m_retire00) * s; + m_dm01 = m_retire01 - m_retire01 * s; + m_dm11 = m_retire11 + (1.0f - m_retire11) * s; + m_deformMatrix = QMatrix4x4( + m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + + if (!m_physicsActive) return; + } + + // No "not active" guard here: an inactive shape still has to be evaluated so it + // can see the panel start moving and become active. The frame loop is only kept + // alive while active, and it is requested before anything can return early — + // requesting it at the end lets a shape stop being evaluated with the matrix + // still off identity, which is what left the panel contents blurry. + if (m_physicsActive && m_group) m_group->markDirty(); + + const QPointF scenePos = mapToScene(QPointF(width() / 2.0, height() / 2.0)); + + if (!m_hasPrevPos) { + m_prevScenePos = scenePos; + m_elapsed.start(); + m_hasPrevPos = true; + return; + } + + const float dt = static_cast(m_elapsed.restart()) / 1000.0f; + if (dt > 0.1f || dt < 0.001f) { + m_prevScenePos = scenePos; + if (m_physicsActive) checkAtRest(0.0f); + return; + } + + const float velX = + static_cast(scenePos.x() - m_prevScenePos.x()) / dt; + const float velY = + static_cast(scenePos.y() - m_prevScenePos.y()) / dt; + m_prevScenePos = scenePos; + + const float speed = std::sqrt(velX * velX + velY * velY); + + if (m_retiring) { + if (speed > 5.0f) + m_retiring = false; + else + return; + } + + if (!m_physicsActive) { + if (speed < 5.0f) return; + m_physicsActive = true; + } + + const float kStretchFactor = static_cast(m_deformScale); + constexpr float kMaxStretch = 0.35f; + + float target00 = 1.0f; + float target01 = 0.0f; + float target11 = 1.0f; + + if (speed > 5.0f) { + const float targetStretch = + 1.0f + std::min(speed * kStretchFactor, kMaxStretch); + const float targetCompress = 1.0f / targetStretch; + + const float cosA = velX / speed; + const float sinA = velY / speed; + const float cos2 = cosA * cosA; + const float sin2 = sinA * sinA; + const float cs = cosA * sinA; + + target00 = targetStretch * cos2 + targetCompress * sin2; + target01 = (targetStretch - targetCompress) * cs; + target11 = targetStretch * sin2 + targetCompress * cos2; + } + + const float kStiffness = static_cast(m_stiffness); + const float kDamping = static_cast(m_damping); + const float invDamp = 1.0f / (1.0f + kDamping * dt); + + m_dmVel00 = (m_dmVel00 - kStiffness * (m_dm00 - target00) * dt) * invDamp; + m_dm00 += m_dmVel00 * dt; + + m_dmVel01 = (m_dmVel01 - kStiffness * (m_dm01 - target01) * dt) * invDamp; + m_dm01 += m_dmVel01 * dt; + + m_dmVel11 = (m_dmVel11 - kStiffness * (m_dm11 - target11) * dt) * invDamp; + m_dm11 += m_dmVel11 * dt; + + m_deformMatrix = QMatrix4x4( + m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); + emit rawDeformMatrixChanged(); + updateCenteredDeformMatrix(); + + // Start the retire as soon as the matrix is close to identity, as in v1. Waiting + // for every term to fall below kEpsilon keeps the matrix just off identity for + // longer, and the panel content is transformed by it, which reads as blur on + // text. + const float totalDelta = + std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + std::abs(m_dm11 - 1.0f); + const float totalVel = + std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11); + // The residual is already inside the rest epsilon, so the eased curve would only + // be aborted or restarted every frame by the panel creeping. Snap instead. + if (totalDelta < 0.004f && totalVel < 0.05f) { + finishAtIdentity(); + return; + } + + checkAtRest(speed); +} + +void BlobRect2::setTopLeftRadius(qreal r) { + if (!qFuzzyCompare(m_topLeftRadius, r)) { + m_topLeftRadius = r; + emit topLeftRadiusChanged(); + if (m_group) m_group->markDirty(); + } +} + +void BlobRect2::setTopRightRadius(qreal r) { + if (!qFuzzyCompare(m_topRightRadius, r)) { + m_topRightRadius = r; + emit topRightRadiusChanged(); + if (m_group) m_group->markDirty(); + } +} + +void BlobRect2::setBottomLeftRadius(qreal r) { + if (!qFuzzyCompare(m_bottomLeftRadius, r)) { + m_bottomLeftRadius = r; + emit bottomLeftRadiusChanged(); + if (m_group) m_group->markDirty(); + } +} + +void BlobRect2::setBottomRightRadius(qreal r) { + if (!qFuzzyCompare(m_bottomRightRadius, r)) { + m_bottomRightRadius = r; + emit bottomRightRadiusChanged(); + if (m_group) m_group->markDirty(); + } +} + +void BlobRect2::cornerRadii(float out[4]) const { + const auto maxR = static_cast(std::min(width(), height())) * 0.5f; + const auto base = std::min(static_cast(m_radius), maxR); + out[0] = std::min( + m_topRightRadius >= 0 ? static_cast(m_topRightRadius) : base, + maxR); + out[1] = std::min( + m_bottomRightRadius >= 0 ? static_cast(m_bottomRightRadius) + : base, + maxR); + out[2] = std::min( + m_bottomLeftRadius >= 0 ? static_cast(m_bottomLeftRadius) : base, + maxR); + out[3] = std::min( + m_topLeftRadius >= 0 ? static_cast(m_topLeftRadius) : base, + maxR); +} + +bool BlobRect2::isExcluded(const BlobShape2* other) const { + for (const auto& ptr : m_exclude) { + if (ptr == other) return true; + } + return false; +} + +bool BlobRect2::isCornerExcluded(const BlobShape2* other) const { + for (const auto& ptr : m_excludeCorners) { + if (ptr == other) return true; + } + return false; +} + +QQmlListProperty BlobRect2::exclude() { + return QQmlListProperty( + this, + nullptr, + &excludeAppend, + &excludeCount, + &excludeAt, + &excludeClear, + &excludeReplace, + &excludeRemoveLast); +} + +QQmlListProperty BlobRect2::excludeCorners() { + return QQmlListProperty( + this, + nullptr, + &excludeCornersAppend, + &excludeCornersCount, + &excludeCornersAt, + &excludeCornersClear, + &excludeCornersReplace, + &excludeCornersRemoveLast); +} + +void BlobRect2::excludeAppend( + QQmlListProperty* prop, BlobRect2* rect) { + auto* self = static_cast(prop->object); + self->m_exclude.append(rect); + if (self->m_group) self->m_group->markDirty(); + emit self->excludeChanged(); +} + +qsizetype BlobRect2::excludeCount(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + return self->m_exclude.size(); +} + +BlobRect2* BlobRect2::excludeAt( + QQmlListProperty* prop, qsizetype index) { + auto* self = static_cast(prop->object); + return self->m_exclude.at(index); +} + +void BlobRect2::excludeClear(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + if (self->m_exclude.isEmpty()) return; + self->m_exclude.clear(); + if (self->m_group) self->m_group->markDirty(); + emit self->excludeChanged(); +} + +void BlobRect2::excludeReplace( + QQmlListProperty* prop, qsizetype index, BlobRect2* rect) { + auto* self = static_cast(prop->object); + self->m_exclude[index] = rect; + if (self->m_group) self->m_group->markDirty(); + emit self->excludeChanged(); +} + +void BlobRect2::excludeRemoveLast(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + if (self->m_exclude.isEmpty()) return; + self->m_exclude.removeLast(); + if (self->m_group) self->m_group->markDirty(); + emit self->excludeChanged(); +} + +void BlobRect2::excludeCornersAppend( + QQmlListProperty* prop, BlobRect2* rect) { + auto* self = static_cast(prop->object); + self->m_excludeCorners.append(rect); + if (self->m_group) self->m_group->markDirty(); + emit self->excludeCornersChanged(); +} + +qsizetype BlobRect2::excludeCornersCount(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + return self->m_excludeCorners.size(); +} + +BlobRect2* BlobRect2::excludeCornersAt( + QQmlListProperty* prop, qsizetype index) { + auto* self = static_cast(prop->object); + return self->m_excludeCorners.at(index); +} + +void BlobRect2::excludeCornersClear(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + if (self->m_excludeCorners.isEmpty()) return; + self->m_excludeCorners.clear(); + if (self->m_group) self->m_group->markDirty(); + emit self->excludeCornersChanged(); +} + +void BlobRect2::excludeCornersReplace( + QQmlListProperty* prop, qsizetype index, BlobRect2* rect) { + auto* self = static_cast(prop->object); + self->m_excludeCorners[index] = rect; + if (self->m_group) self->m_group->markDirty(); + emit self->excludeCornersChanged(); +} + +void BlobRect2::excludeCornersRemoveLast(QQmlListProperty* prop) { + auto* self = static_cast(prop->object); + if (self->m_excludeCorners.isEmpty()) return; + self->m_excludeCorners.removeLast(); + if (self->m_group) self->m_group->markDirty(); + emit self->excludeCornersChanged(); +} + +void BlobRect2::checkAtRest(float speed) { + if (m_retiring) return; + + constexpr float kEpsilon = 0.002f; + const bool atRest = + std::abs(m_dm00 - 1.0f) < kEpsilon && std::abs(m_dm01) < kEpsilon && + std::abs(m_dm11 - 1.0f) < kEpsilon && std::abs(m_dmVel00) < kEpsilon && + std::abs(m_dmVel01) < kEpsilon && std::abs(m_dmVel11) < kEpsilon && + speed < 5.0f; + + if (atRest) beginRetire(); +} diff --git a/Plugins/ZShell/Blobs/blobrect2.hpp b/Plugins/ZShell/Blobs/blobrect2.hpp new file mode 100644 index 0000000..800b37b --- /dev/null +++ b/Plugins/ZShell/Blobs/blobrect2.hpp @@ -0,0 +1,167 @@ +#pragma once + +#include "blobshape2.hpp" + +#include +#include +#include +#include + +class BlobRect2 : public BlobShape2 { + Q_OBJECT + QML_ELEMENT + Q_PROPERTY( + qreal stiffness READ stiffness WRITE setStiffness NOTIFY + stiffnessChanged) + Q_PROPERTY(qreal damping READ damping WRITE setDamping NOTIFY dampingChanged) + Q_PROPERTY( + qreal deformScale READ deformScale WRITE setDeformScale NOTIFY + deformScaleChanged) + Q_PROPERTY( + QQmlListProperty exclude READ exclude NOTIFY excludeChanged) + Q_PROPERTY( + QQmlListProperty excludeCorners READ excludeCorners NOTIFY + excludeCornersChanged) + Q_PROPERTY( + qreal topLeftRadius READ topLeftRadius WRITE setTopLeftRadius NOTIFY + topLeftRadiusChanged) + Q_PROPERTY( + qreal topRightRadius READ topRightRadius WRITE setTopRightRadius NOTIFY + topRightRadiusChanged) + Q_PROPERTY( + qreal bottomLeftRadius READ bottomLeftRadius WRITE setBottomLeftRadius + NOTIFY bottomLeftRadiusChanged) + Q_PROPERTY( + qreal bottomRightRadius READ bottomRightRadius WRITE + setBottomRightRadius NOTIFY bottomRightRadiusChanged) + + public: + explicit BlobRect2(QQuickItem* parent = nullptr); + ~BlobRect2() override; + + [[nodiscard]] qreal stiffness() const { return m_stiffness; } + + void setStiffness(qreal s) { + if (!qFuzzyCompare(m_stiffness, s)) { + m_stiffness = s; + emit stiffnessChanged(); + } + } + + [[nodiscard]] qreal damping() const { return m_damping; } + + void setDamping(qreal value) { + if (!qFuzzyCompare(m_damping, value)) { + m_damping = value; + emit dampingChanged(); + } + } + + [[nodiscard]] qreal deformScale() const { return m_deformScale; } + + void setDeformScale(qreal s) { + if (!qFuzzyCompare(m_deformScale, s)) { + m_deformScale = s; + emit deformScaleChanged(); + } + } + + QQmlListProperty exclude(); + QQmlListProperty excludeCorners(); + + bool isExcluded(const BlobShape2* other) const override; + bool isCornerExcluded(const BlobShape2* other) const override; + void cornerRadii(float out[4]) const override; + + [[nodiscard]] qreal topLeftRadius() const { return m_topLeftRadius; } + + void setTopLeftRadius(qreal r); + + [[nodiscard]] qreal topRightRadius() const { return m_topRightRadius; } + + void setTopRightRadius(qreal r); + + [[nodiscard]] qreal bottomLeftRadius() const { return m_bottomLeftRadius; } + + void setBottomLeftRadius(qreal r); + + [[nodiscard]] qreal bottomRightRadius() const { + return m_bottomRightRadius; + } + + void setBottomRightRadius(qreal r); + + signals: + void stiffnessChanged(); + void dampingChanged(); + void deformScaleChanged(); + void excludeChanged(); + void excludeCornersChanged(); + void topLeftRadiusChanged(); + void topRightRadiusChanged(); + void bottomLeftRadiusChanged(); + void bottomRightRadiusChanged(); + + protected: + void updatePhysics() override; + + private: + void checkAtRest(float speed); + void beginRetire(); + void finishAtIdentity(); + + // Physics state + QPointF m_prevScenePos; + QElapsedTimer m_elapsed; + bool m_physicsActive = false; + bool m_hasPrevPos = false; + + bool m_retiring = false; + QElapsedTimer m_retireElapsed; + float m_retire00 = 1.0f; + float m_retire01 = 0.0f; + float m_retire11 = 1.0f; + + // Symmetric 2x2 deformation matrix components (3 independent: m00, m01, + // m11) Rest state is identity: m00=1, m01=0, m11=1 + float m_dm00 = 1.0f; + float m_dm01 = 0.0f; + float m_dm11 = 1.0f; + + // Spring velocities for each component + float m_dmVel00 = 0.0f; + float m_dmVel01 = 0.0f; + float m_dmVel11 = 0.0f; + + qreal m_stiffness = 200.0; + qreal m_damping = 16.0; + qreal m_deformScale = 0.0005; + + qreal m_topLeftRadius = -1; + qreal m_topRightRadius = -1; + qreal m_bottomLeftRadius = -1; + qreal m_bottomRightRadius = -1; + + QList> m_exclude; + QList> m_excludeCorners; + + static void excludeAppend( + QQmlListProperty* prop, BlobRect2* rect); + static qsizetype excludeCount(QQmlListProperty* prop); + static BlobRect2* excludeAt( + QQmlListProperty* prop, qsizetype index); + static void excludeClear(QQmlListProperty* prop); + static void excludeReplace( + QQmlListProperty* prop, qsizetype index, BlobRect2* rect); + static void excludeRemoveLast(QQmlListProperty* prop); + + static void excludeCornersAppend( + QQmlListProperty* prop, BlobRect2* rect); + static qsizetype excludeCornersCount(QQmlListProperty* prop); + static BlobRect2* excludeCornersAt( + QQmlListProperty* prop, qsizetype index); + static void excludeCornersClear(QQmlListProperty* prop); + static void excludeCornersReplace( + QQmlListProperty* prop, qsizetype index, BlobRect2* rect); + static void excludeCornersRemoveLast(QQmlListProperty* prop); +}; diff --git a/Plugins/ZShell/Blobs/blobshape.cpp b/Plugins/ZShell/Blobs/blobshape.cpp index 99d4eec..e33b2f3 100644 --- a/Plugins/ZShell/Blobs/blobshape.cpp +++ b/Plugins/ZShell/Blobs/blobshape.cpp @@ -168,8 +168,10 @@ void BlobShape::updatePolish() { if (other->width() <= 0 || other->height() <= 0) continue; - if (isExcluded(other)) continue; - + // Excluded shapes stay in the rect data. Exclusion only gates the smooth + // blend (via excludeMask below); ownership must still see every shape, + // otherwise two nodes can both believe they own a pixel and the pixel is + // blended twice. const QPointF otherScene = other->mapToScene(QPointF(0, 0)); bool include = false; diff --git a/Plugins/ZShell/Blobs/blobshape2.cpp b/Plugins/ZShell/Blobs/blobshape2.cpp new file mode 100644 index 0000000..8ce1232 --- /dev/null +++ b/Plugins/ZShell/Blobs/blobshape2.cpp @@ -0,0 +1,67 @@ +#include "blobshape2.hpp" +#include "blobgroup2.hpp" + +#include +#include + +BlobShape2::BlobShape2(QQuickItem* parent) : QQuickItem(parent) {} + +void BlobShape2::setGroup(BlobGroup2* g) { + if (m_group == g) return; + if (m_group && isComponentComplete()) unregisterFromGroup(); + m_group = g; + if (m_group && isComponentComplete()) registerWithGroup(); + emit groupChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobShape2::setRadius(qreal r) { + if (qFuzzyCompare(m_radius, r)) return; + m_radius = r; + emit radiusChanged(); + if (m_group) m_group->markDirty(); +} + +void BlobShape2::componentComplete() { + QQuickItem::componentComplete(); + if (m_group) registerWithGroup(); +} + +void BlobShape2::geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) { + QQuickItem::geometryChange(newGeometry, oldGeometry); + updateCenteredDeformMatrix(); + + // One draw covers the whole group, so any shape change is a group change. + if (m_group) m_group->markDirty(); +} + +void BlobShape2::updateCenteredDeformMatrix() { + const auto cx = static_cast(width()) * 0.5f; + const auto cy = static_cast(height()) * 0.5f; + QMatrix4x4 result; + result.translate(cx, cy); + result *= m_deformMatrix; + result.translate(-cx, -cy); + if (m_centeredDeformMatrix != result) { + m_centeredDeformMatrix = result; + emit deformMatrixChanged(); + } +} + +void BlobShape2::cornerRadii(float out[4]) const { + const auto maxR = static_cast(std::min(width(), height())) * 0.5f; + const auto r = std::min(static_cast(m_radius), maxR); + out[0] = r; + out[1] = r; + out[2] = r; + out[3] = r; +} + +void BlobShape2::registerWithGroup() { + if (m_group) m_group->addShape(this); +} + +void BlobShape2::unregisterFromGroup() { + if (m_group) m_group->removeShape(this); +} diff --git a/Plugins/ZShell/Blobs/blobshape2.hpp b/Plugins/ZShell/Blobs/blobshape2.hpp new file mode 100644 index 0000000..3035082 --- /dev/null +++ b/Plugins/ZShell/Blobs/blobshape2.hpp @@ -0,0 +1,76 @@ +#pragma once + +#include "blobgroup2.hpp" + +#include +#include + +class BlobGroup2; + +// Data-only shape. +// +// The group draws a single node for the whole group, so a shape here contributes +// only what the shader needs: position, radii, exclusion, physics. A shape does +// not create a render node, so no pixel can be evaluated by one shape and missed +// by another. +class BlobShape2 : public QQuickItem { + Q_OBJECT + Q_PROPERTY(BlobGroup2* group READ group WRITE setGroup NOTIFY groupChanged) + Q_PROPERTY(qreal radius READ radius WRITE setRadius NOTIFY radiusChanged) + Q_PROPERTY( + QMatrix4x4 deformMatrix READ deformMatrix NOTIFY deformMatrixChanged) + Q_PROPERTY( + QMatrix4x4 rawDeformMatrix READ rawDeformMatrix NOTIFY + rawDeformMatrixChanged) + + friend class BlobGroup2; + + public: + explicit BlobShape2(QQuickItem* parent = nullptr); + ~BlobShape2() override = default; + + [[nodiscard]] BlobGroup2* group() const { return m_group; } + + void setGroup(BlobGroup2* g); + + [[nodiscard]] qreal radius() const { return m_radius; } + + void setRadius(qreal r); + + [[nodiscard]] QMatrix4x4 deformMatrix() const { + return m_centeredDeformMatrix; + } + + [[nodiscard]] QMatrix4x4 rawDeformMatrix() const { return m_deformMatrix; } + + signals: + void groupChanged(); + void radiusChanged(); + void deformMatrixChanged(); + void rawDeformMatrixChanged(); + + protected: + void componentComplete() override; + void geometryChange( + const QRectF& newGeometry, const QRectF& oldGeometry) override; + + virtual bool isExcluded(const BlobShape2*) const { return false; } + + virtual bool isCornerExcluded(const BlobShape2* /*other*/) const { + return false; + } + + virtual void cornerRadii(float out[4]) const; + + // Called by the group when it rebuilds the shared data, once per frame. + virtual void updatePhysics() {} + + virtual void registerWithGroup(); + virtual void unregisterFromGroup(); + void updateCenteredDeformMatrix(); + + BlobGroup2* m_group = nullptr; + qreal m_radius = 0; + QMatrix4x4 m_deformMatrix; // identity by default + QMatrix4x4 m_centeredDeformMatrix; +}; diff --git a/Plugins/ZShell/Blobs/shaders/blob.frag b/Plugins/ZShell/Blobs/shaders/blob.frag index ddee3f7..1231bc6 100644 --- a/Plugins/ZShell/Blobs/shaders/blob.frag +++ b/Plugins/ZShell/Blobs/shaders/blob.frag @@ -19,6 +19,8 @@ layout(std140, binding = 0) uniform buf { vec4 invertedOuter; vec4 invertedInner; vec4 rectData[80]; + float shadowRadius; + vec4 shadowColor; }; float sdRoundedBox(vec2 p, vec2 center, vec2 halfSize, float radius) { @@ -63,9 +65,10 @@ float smaxSharpA(float a, float b, float k) { void main() { vec2 pixel = vec2(paddedX, paddedY) + qt_TexCoord0 * vec2(paddedW, paddedH); - // Phase 1: compute per-rect SDF, track owner. We can't smin yet because - // excluded pairs need to skip the smooth blend, which requires pairwise pass - // below. + // Phase 1: compute per-rect SDF, and track which rect owns the pixel for the + // per-shape path (BlobGroup). The single group draw (BlobGroup2) has no owner: + // myIndex stays -2, so the ownership test is skipped and this pass writes every + // pixel the quad covers. Excluded pairs still need the pairwise pass below. float dArr[16]; int owner = -2; float minDist = 1e10; @@ -82,8 +85,10 @@ void main() { // Offset center for asymmetric deformation vec2 center = rect.xy + props.yz; - // AABB early-out: skip rects far from this pixel - vec2 extent = sh.xy + vec2(smoothFactor * 1.5); + // AABB early-out: skip rects far from this pixel. The reach has to include the + // shadow, otherwise a rect that is only inside the shadow range would be + // skipped and its distance could not be used. + vec2 extent = sh.xy + vec2(smoothFactor * 1.5 + shadowRadius); if (abs(pixel.x - center.x) > extent.x || abs(pixel.y - center.y) > extent.y) { dArr[i] = 1e10; @@ -251,14 +256,39 @@ void main() { } } - // Each renderer only outputs pixels it owns, but allow rendering - // blend zones to prevent gaps (mergedSdf < smoothFactor means in blend) - // myIndex == -1: inverted rect renders border-owned pixels - // myIndex >= 0: individual rect renders its owned pixels - if (owner != myIndex && mergedSdf > smoothFactor) + // A pixel this far from every rect cannot have a value, so it is not written. + // The one-unit margin keeps the outermost pixels of the quad out of the AA: their + // derivatives are undefined (the quad neighbour is outside the primitive), which + // is what drew a line along the coverage edge. + float reach = max(smoothFactor, shadowRadius); + if (mergedSdf > reach - 1.0) discard; - float fw = fwidth(mergedSdf); - float alpha = 1.0 - smoothstep(-fw, fw, mergedSdf); - fragColor = vec4(color.rgb * alpha, alpha) * qt_Opacity; + // Only the owner writes a pixel, which is what makes the per-shape path look + // like one surface: qt_Opacity is applied once per pixel instead of once per + // overlapping draw. The single group draw has no owner (myIndex == -2), so the + // test is skipped and the value below is the only value a pixel can have. + if (myIndex >= -1 && owner != myIndex) + discard; + + // Fixed-width AA: fwidth is capped at one unit, so a bad derivative near the quad + // boundary cannot make a wide edge. + float fw = min(fwidth(mergedSdf), 1.0); + float materialAlpha = 1.0 - smoothstep(-fw, fw, mergedSdf); + + // Phase 2: the shadow, as a distance falloff from the merged surface. The + // quadratic reaches zero with zero slope at its outer edge, so that edge needs no + // AA of its own. Only the single group draw writes a shadow: in the per-shape + // path overlapping draws would darken the same area twice. + float shadowAlpha = 0.0; + if (shadowRadius > 0.0 && myIndex == -2) { + float t = clamp(mergedSdf / shadowRadius, 0.0, 1.0); + shadowAlpha = (1.0 - t) * (1.0 - t) * shadowColor.a; + } + + // Material over shadow, premultiplied. + float totalAlpha = materialAlpha + shadowAlpha * (1.0 - materialAlpha); + vec3 rgb = color.rgb * materialAlpha + + shadowColor.rgb * shadowAlpha * (1.0 - materialAlpha); + fragColor = vec4(rgb, totalAlpha) * qt_Opacity; } diff --git a/Plugins/ZShell/Internal/wallpaperimage.cpp b/Plugins/ZShell/Internal/wallpaperimage.cpp index b6ad891..f6de9de 100644 --- a/Plugins/ZShell/Internal/wallpaperimage.cpp +++ b/Plugins/ZShell/Internal/wallpaperimage.cpp @@ -285,13 +285,15 @@ QSGNode* WallpaperImage::updatePaintNode( Q_UNUSED(oldNode); if (m_hasPending) { + qDebug() << "pending format" << m_pendingImage.format() + << double(m_pendingImage.sizeInBytes()) / 1048576.0 << "MB"; if (m_previousTexture) { delete m_previousTexture; m_previousTexture = nullptr; } m_previousTexture = m_texture; m_texture = window()->createTextureFromImage( - m_pendingImage, QQuickWindow::TextureHasAlphaChannel); + m_pendingImage, QQuickWindow::TextureIsOpaque); m_pendingImage = QImage(); m_hasPending = false; diff --git a/shell.qml b/shell.qml index 6df3f3a..f602e71 100644 --- a/shell.qml +++ b/shell.qml @@ -23,27 +23,33 @@ ShellRoot { settings.watchFiles: true - Drawers {} + Drawers { + } - Wallpaper {} + Wallpaper { + } - AreaPicker {} + AreaPicker { + } Lock { id: lock } - Shortcuts {} + Shortcuts { + } IdleMonitors { lock: lock } - Polkit {} + Polkit { + } LazyLoader { activeAsync: root.laptop - component: BatteryService {} + component: BatteryService { + } } } From 5507b99fdcc2b6d18550d428efe441e52416fea0 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 4 Oct 2026 13:48:50 +0200 Subject: [PATCH 65/90] fix: merge two strokecanvas into one + better cursorShape and pointer handling --- Helpers/Picker.qml | 26 ++--- Helpers/Picker/AnnotationCanvas.qml | 103 ++++++++---------- Helpers/Picker/SelectionHandles.qml | 40 +++++-- .../ZShell/Internal/strokecanvasrenderer.cpp | 28 +++-- 4 files changed, 110 insertions(+), 87 deletions(-) diff --git a/Helpers/Picker.qml b/Helpers/Picker.qml index 710dd99..a7683c7 100755 --- a/Helpers/Picker.qml +++ b/Helpers/Picker.qml @@ -134,25 +134,25 @@ Item { Behavior on rsx { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { + Anim { } } Behavior on rsy { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { + Anim { } } Behavior on sh { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { + Anim { } } Behavior on sw { enabled: !selectArea.pressed && root.mode === "select" - ExAnim { + Anim { } } @@ -240,19 +240,19 @@ Item { to: 0 } - ExAnim { + Anim { properties: "rsx,rsy" target: root to: 0 } - ExAnim { + Anim { property: "sw" target: root to: root.screen.width } - ExAnim { + Anim { property: "sh" target: root to: root.screen.height @@ -290,6 +290,7 @@ Item { anchors.fill: parent inkColor: root.inkColor layer.enabled: true + selection: selectionRect tool: root.mode === "edit" && root.tool !== "move" ? root.tool : "none" layer.effect: MultiEffect { @@ -301,7 +302,7 @@ Item { } } - Rectangle { + CustomRect { id: overlay anchors.fill: parent @@ -338,12 +339,11 @@ Item { } } - Rectangle { + CustomRect { id: border border.color: Colors.palette.m3primary border.width: root.realBorderWidth - color: "transparent" implicitHeight: selectionRect.implicitHeight + root.realBorderWidth * 2 implicitWidth: selectionRect.implicitWidth + root.realBorderWidth * 2 radius: root.realRounding > 0 ? root.realRounding + root.realBorderWidth : 0 @@ -360,10 +360,13 @@ Item { SelectionHandles { id: handles + border: root.realBorderWidth selH: root.sh selW: root.sw selX: root.rsx selY: root.rsy + selection: selectionRect + tool: root.tool visible: root.mode === "edit" && root.tool === "move" onBoundsChanged: (x, y, w, h) => { @@ -401,7 +404,4 @@ Item { onToolSelected: t => root.tool = t onUndoRequested: annotations.undo() } - - component ExAnim: Anim { - } } diff --git a/Helpers/Picker/AnnotationCanvas.qml b/Helpers/Picker/AnnotationCanvas.qml index 7ff98b0..32e251f 100755 --- a/Helpers/Picker/AnnotationCanvas.qml +++ b/Helpers/Picker/AnnotationCanvas.qml @@ -8,13 +8,13 @@ Item { property color inkColor: "#ff3b30" property real inkWidth: 4 + required property Rectangle selection property string tool: "none" signal changed function clearAll() { - committedCanvas.clear(); - previewCanvas.clear(); + canvas.clear(); changed(); } @@ -54,89 +54,82 @@ Item { } function undo() { - committedCanvas.undoLastGroup(); + canvas.undoLastGroup(); changed(); } anchors.fill: parent StrokeCanvas { - id: committedCanvas + id: canvas anchors.fill: parent penColor: root.inkColor penWidth: root.inkWidth } - StrokeCanvas { - id: previewCanvas - - anchors.fill: parent - penColor: root.inkColor - penWidth: root.inkWidth - } - - PointHandler { + MouseArea { id: drawHandler property real startX property real startY - property bool started: false acceptedButtons: Qt.LeftButton | Qt.RightButton + anchors.fill: parent + cursorShape: undefined enabled: root.tool !== "none" + hoverEnabled: true - onActiveChanged: { - if (active) + onPositionChanged: e => { + const x = e.x; + const y = e.y; + + canvas.hoverPoint = Qt.point(x, y); + + if (!pressed) return; if (root.tool === "pen") { - committedCanvas.endStroke(); - } else if (started) { - const pts = root.sampleShape(root.tool, startX, startY, point.position.x, point.position.y); - if (pts.length > 0) { - committedCanvas.beginStroke(pts[0][0], pts[0][1], true); - for (let i = 1; i < pts.length; i++) - committedCanvas.appendPoint(pts[i][0], pts[i][1]); - committedCanvas.endStroke(); - } - previewCanvas.clear(); - } - started = false; - root.changed(); - } - onPointChanged: { - if (!active) + canvas.appendPoint(x, y); return; + } - if (point.pressedButtons & Qt.RightButton) { - root.clearAll(); - return; + const pts = root.sampleShape(root.tool, startX, startY, x, y); + if (pts.length > 0) { + canvas.beginStroke(pts[0][0], pts[0][1], true); + for (let i = 1; i < pts.length; i++) + canvas.appendPoint(pts[i][0], pts[i][1]); } + } + onPressed: e => { + if (e.button & Qt.LeftButton) { + startX = e.x; + startY = e.y; + canvas.beginStroke(e.x, e.y); + } else if (e.button & Qt.RightButton) { + root.clearAll(); + } + } + onPressedChanged: if (!pressed) + canvas.endStroke() + } + + HoverHandler { + cursorShape: { + if (root.tool === "none") + return undefined; const x = point.position.x; const y = point.position.y; + const withinXWidth = x > root.selection.x && x < root.selection.x + root.selection.implicitWidth; + const withinYHeight = y > root.selection.y && y < root.selection.y + root.selection.implicitHeight; - if (root.tool === "pen") { - if (!started) { - started = true; - committedCanvas.beginStroke(x, y); - return; - } - committedCanvas.appendPoint(x, y); - return; - } - - if (!started) { - started = true; - startX = point.pressPosition.x; - startY = point.pressPosition.y; - } - const pts = root.sampleShape(root.tool, startX, startY, x, y); - if (pts.length > 0) { - previewCanvas.beginStroke(pts[0][0], pts[0][1], true); - for (let i = 1; i < pts.length; i++) - previewCanvas.appendPoint(pts[i][0], pts[i][1]); + if (withinXWidth && withinYHeight) { + canvas.showHover(x, y); + return Qt.BlankCursor; + } else { + canvas.hideHover(); + return undefined; } } } diff --git a/Helpers/Picker/SelectionHandles.qml b/Helpers/Picker/SelectionHandles.qml index eca4b0f..d79ae71 100755 --- a/Helpers/Picker/SelectionHandles.qml +++ b/Helpers/Picker/SelectionHandles.qml @@ -5,6 +5,7 @@ import QtQuick Item { id: root + required property int border property color handleBorder: "#3584e4" property color handleFill: "white" property real hs: 12 @@ -13,6 +14,8 @@ Item { required property real selW required property real selX required property real selY + required property Rectangle selection + required property string tool signal boundsChanged(x: real, y: real, w: real, h: real) signal dragFinished @@ -29,6 +32,7 @@ Item { property real accumX: 0 property real accumY: 0 + cursorShape: undefined target: null onActiveChanged: { @@ -45,51 +49,63 @@ Item { } } + HoverHandler { + cursorShape: { + if (root.tool !== "move") + return undefined; + + if (moveHandler.centroid.pressedButtons & Qt.LeftButton) + return Qt.ClosedHandCursor; + else + return Qt.OpenHandCursor; + } + } + Handle { edge: "tl" - x: -root.hs / 2 - y: -root.hs / 2 + x: -(root.hs + root.border) / 2 + y: -(root.hs + root.border) / 2 } Handle { edge: "t" x: root.width / 2 - root.hs / 2 - y: -root.hs / 2 + y: -(root.hs + root.border) / 2 } Handle { edge: "tr" - x: root.width - root.hs / 2 - y: -root.hs / 2 + x: root.width - root.hs / 2 + root.border / 2 + y: -(root.hs + root.border) / 2 } Handle { edge: "r" - x: root.width - root.hs / 2 + x: root.width - root.hs / 2 + root.border / 2 y: root.height / 2 - root.hs / 2 } Handle { edge: "br" - x: root.width - root.hs / 2 - y: root.height - root.hs / 2 + x: root.width - root.hs / 2 + root.border / 2 + y: root.height - root.hs / 2 + root.border / 2 } Handle { edge: "b" x: root.width / 2 - root.hs / 2 - y: root.height - root.hs / 2 + y: root.height - root.hs / 2 + root.border / 2 } Handle { edge: "bl" - x: -root.hs / 2 - y: root.height - root.hs / 2 + x: -(root.hs + root.border) / 2 + y: root.height - root.hs / 2 + root.border / 2 } Handle { edge: "l" - x: -root.hs / 2 + x: -(root.hs + root.border) / 2 y: root.height / 2 - root.hs / 2 } diff --git a/Plugins/ZShell/Internal/strokecanvasrenderer.cpp b/Plugins/ZShell/Internal/strokecanvasrenderer.cpp index 3a77d76..c109728 100644 --- a/Plugins/ZShell/Internal/strokecanvasrenderer.cpp +++ b/Plugins/ZShell/Internal/strokecanvasrenderer.cpp @@ -1,6 +1,7 @@ #include "strokecanvasrenderer.hpp" #include "strokecanvasitem.hpp" #include +#include namespace ZShell::internal { @@ -148,14 +149,27 @@ void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem* item) { m_penColor = canvas->m_penColor; m_penWidth = canvas->m_penWidth; - while (m_strokes.size() < canvas->m_strokes.size()) - m_strokes.append(canvas->m_strokes[m_strokes.size()]); + // Detect removed strokes: present in renderer but no longer in canvas + QSet canvasGroupIds; + for (const auto& stroke : canvas->m_strokes) + canvasGroupIds.insert(stroke.groupId); - if (canvas->m_strokes.isEmpty() && !m_strokes.isEmpty()) { - for (const auto& stroke : m_strokes) - if (stroke.groupId >= 0) - m_pendingGroupRemovals.append(stroke.groupId); - m_strokes.clear(); + for (int i = m_strokes.size() - 1; i >= 0; --i) { + if (!canvasGroupIds.contains(m_strokes[i].groupId)) { + if (m_strokes[i].groupId >= 0) + m_pendingGroupRemovals.append(m_strokes[i].groupId); + m_strokes.remove(i); + } + } + + // Detect added strokes: present in canvas but not yet in renderer + QSet rendererGroupIds; + for (const auto& stroke : m_strokes) + rendererGroupIds.insert(stroke.groupId); + + for (const auto& stroke : canvas->m_strokes) { + if (!rendererGroupIds.contains(stroke.groupId)) + m_strokes.append(stroke); } m_currentStroke = canvas->m_currentStroke; From f1918172c377855fb8ba6dd82c694de8d4f05490 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 4 Oct 2026 13:48:50 +0200 Subject: [PATCH 66/90] fix: merge two strokecanvas into one + better cursorShape and pointer handling --- Helpers/Picker.qml | 25 +++-- Helpers/Picker/AnnotationCanvas.qml | 103 ++++++++---------- Helpers/Picker/SelectionHandles.qml | 40 +++++-- .../ZShell/Internal/strokecanvasrenderer.cpp | 28 +++-- 4 files changed, 110 insertions(+), 86 deletions(-) diff --git a/Helpers/Picker.qml b/Helpers/Picker.qml index 4519fe7..a2f5c4f 100755 --- a/Helpers/Picker.qml +++ b/Helpers/Picker.qml @@ -129,22 +129,22 @@ Item { Behavior on rsx { enabled: !selectArea.pressed && root.mode === "select" - ExAnim {} + Anim {} } Behavior on rsy { enabled: !selectArea.pressed && root.mode === "select" - ExAnim {} + Anim {} } Behavior on sh { enabled: !selectArea.pressed && root.mode === "select" - ExAnim {} + Anim {} } Behavior on sw { enabled: !selectArea.pressed && root.mode === "select" - ExAnim {} + Anim {} } Behavior on opacity { Anim { @@ -236,19 +236,19 @@ Item { to: 0 } - ExAnim { + Anim { properties: "rsx,rsy" target: root to: 0 } - ExAnim { + Anim { property: "sw" target: root to: root.screen.width } - ExAnim { + Anim { property: "sh" target: root to: root.screen.height @@ -286,6 +286,7 @@ Item { anchors.fill: parent inkColor: root.inkColor layer.enabled: true + selection: selectionRect tool: root.mode === "edit" && root.tool !== "move" ? root.tool : "none" layer.effect: MultiEffect { @@ -297,7 +298,7 @@ Item { } } - Rectangle { + CustomRect { id: overlay anchors.fill: parent @@ -334,12 +335,11 @@ Item { } } - Rectangle { + CustomRect { id: border border.color: Colors.palette.m3primary border.width: root.realBorderWidth - color: "transparent" implicitHeight: selectionRect.implicitHeight + root.realBorderWidth * 2 implicitWidth: selectionRect.implicitWidth + root.realBorderWidth * 2 radius: root.realRounding > 0 ? root.realRounding + root.realBorderWidth : 0 @@ -355,10 +355,13 @@ Item { SelectionHandles { id: handles + border: root.realBorderWidth selH: root.sh selW: root.sw selX: root.rsx selY: root.rsy + selection: selectionRect + tool: root.tool visible: root.mode === "edit" && root.tool === "move" onBoundsChanged: (x, y, w, h) => { @@ -396,6 +399,4 @@ Item { onToolSelected: t => root.tool = t onUndoRequested: annotations.undo() } - - component ExAnim: Anim {} } diff --git a/Helpers/Picker/AnnotationCanvas.qml b/Helpers/Picker/AnnotationCanvas.qml index 7ff98b0..32e251f 100755 --- a/Helpers/Picker/AnnotationCanvas.qml +++ b/Helpers/Picker/AnnotationCanvas.qml @@ -8,13 +8,13 @@ Item { property color inkColor: "#ff3b30" property real inkWidth: 4 + required property Rectangle selection property string tool: "none" signal changed function clearAll() { - committedCanvas.clear(); - previewCanvas.clear(); + canvas.clear(); changed(); } @@ -54,89 +54,82 @@ Item { } function undo() { - committedCanvas.undoLastGroup(); + canvas.undoLastGroup(); changed(); } anchors.fill: parent StrokeCanvas { - id: committedCanvas + id: canvas anchors.fill: parent penColor: root.inkColor penWidth: root.inkWidth } - StrokeCanvas { - id: previewCanvas - - anchors.fill: parent - penColor: root.inkColor - penWidth: root.inkWidth - } - - PointHandler { + MouseArea { id: drawHandler property real startX property real startY - property bool started: false acceptedButtons: Qt.LeftButton | Qt.RightButton + anchors.fill: parent + cursorShape: undefined enabled: root.tool !== "none" + hoverEnabled: true - onActiveChanged: { - if (active) + onPositionChanged: e => { + const x = e.x; + const y = e.y; + + canvas.hoverPoint = Qt.point(x, y); + + if (!pressed) return; if (root.tool === "pen") { - committedCanvas.endStroke(); - } else if (started) { - const pts = root.sampleShape(root.tool, startX, startY, point.position.x, point.position.y); - if (pts.length > 0) { - committedCanvas.beginStroke(pts[0][0], pts[0][1], true); - for (let i = 1; i < pts.length; i++) - committedCanvas.appendPoint(pts[i][0], pts[i][1]); - committedCanvas.endStroke(); - } - previewCanvas.clear(); - } - started = false; - root.changed(); - } - onPointChanged: { - if (!active) + canvas.appendPoint(x, y); return; + } - if (point.pressedButtons & Qt.RightButton) { - root.clearAll(); - return; + const pts = root.sampleShape(root.tool, startX, startY, x, y); + if (pts.length > 0) { + canvas.beginStroke(pts[0][0], pts[0][1], true); + for (let i = 1; i < pts.length; i++) + canvas.appendPoint(pts[i][0], pts[i][1]); } + } + onPressed: e => { + if (e.button & Qt.LeftButton) { + startX = e.x; + startY = e.y; + canvas.beginStroke(e.x, e.y); + } else if (e.button & Qt.RightButton) { + root.clearAll(); + } + } + onPressedChanged: if (!pressed) + canvas.endStroke() + } + + HoverHandler { + cursorShape: { + if (root.tool === "none") + return undefined; const x = point.position.x; const y = point.position.y; + const withinXWidth = x > root.selection.x && x < root.selection.x + root.selection.implicitWidth; + const withinYHeight = y > root.selection.y && y < root.selection.y + root.selection.implicitHeight; - if (root.tool === "pen") { - if (!started) { - started = true; - committedCanvas.beginStroke(x, y); - return; - } - committedCanvas.appendPoint(x, y); - return; - } - - if (!started) { - started = true; - startX = point.pressPosition.x; - startY = point.pressPosition.y; - } - const pts = root.sampleShape(root.tool, startX, startY, x, y); - if (pts.length > 0) { - previewCanvas.beginStroke(pts[0][0], pts[0][1], true); - for (let i = 1; i < pts.length; i++) - previewCanvas.appendPoint(pts[i][0], pts[i][1]); + if (withinXWidth && withinYHeight) { + canvas.showHover(x, y); + return Qt.BlankCursor; + } else { + canvas.hideHover(); + return undefined; } } } diff --git a/Helpers/Picker/SelectionHandles.qml b/Helpers/Picker/SelectionHandles.qml index eca4b0f..d79ae71 100755 --- a/Helpers/Picker/SelectionHandles.qml +++ b/Helpers/Picker/SelectionHandles.qml @@ -5,6 +5,7 @@ import QtQuick Item { id: root + required property int border property color handleBorder: "#3584e4" property color handleFill: "white" property real hs: 12 @@ -13,6 +14,8 @@ Item { required property real selW required property real selX required property real selY + required property Rectangle selection + required property string tool signal boundsChanged(x: real, y: real, w: real, h: real) signal dragFinished @@ -29,6 +32,7 @@ Item { property real accumX: 0 property real accumY: 0 + cursorShape: undefined target: null onActiveChanged: { @@ -45,51 +49,63 @@ Item { } } + HoverHandler { + cursorShape: { + if (root.tool !== "move") + return undefined; + + if (moveHandler.centroid.pressedButtons & Qt.LeftButton) + return Qt.ClosedHandCursor; + else + return Qt.OpenHandCursor; + } + } + Handle { edge: "tl" - x: -root.hs / 2 - y: -root.hs / 2 + x: -(root.hs + root.border) / 2 + y: -(root.hs + root.border) / 2 } Handle { edge: "t" x: root.width / 2 - root.hs / 2 - y: -root.hs / 2 + y: -(root.hs + root.border) / 2 } Handle { edge: "tr" - x: root.width - root.hs / 2 - y: -root.hs / 2 + x: root.width - root.hs / 2 + root.border / 2 + y: -(root.hs + root.border) / 2 } Handle { edge: "r" - x: root.width - root.hs / 2 + x: root.width - root.hs / 2 + root.border / 2 y: root.height / 2 - root.hs / 2 } Handle { edge: "br" - x: root.width - root.hs / 2 - y: root.height - root.hs / 2 + x: root.width - root.hs / 2 + root.border / 2 + y: root.height - root.hs / 2 + root.border / 2 } Handle { edge: "b" x: root.width / 2 - root.hs / 2 - y: root.height - root.hs / 2 + y: root.height - root.hs / 2 + root.border / 2 } Handle { edge: "bl" - x: -root.hs / 2 - y: root.height - root.hs / 2 + x: -(root.hs + root.border) / 2 + y: root.height - root.hs / 2 + root.border / 2 } Handle { edge: "l" - x: -root.hs / 2 + x: -(root.hs + root.border) / 2 y: root.height / 2 - root.hs / 2 } diff --git a/Plugins/ZShell/Internal/strokecanvasrenderer.cpp b/Plugins/ZShell/Internal/strokecanvasrenderer.cpp index 3a77d76..c109728 100644 --- a/Plugins/ZShell/Internal/strokecanvasrenderer.cpp +++ b/Plugins/ZShell/Internal/strokecanvasrenderer.cpp @@ -1,6 +1,7 @@ #include "strokecanvasrenderer.hpp" #include "strokecanvasitem.hpp" #include +#include namespace ZShell::internal { @@ -148,14 +149,27 @@ void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem* item) { m_penColor = canvas->m_penColor; m_penWidth = canvas->m_penWidth; - while (m_strokes.size() < canvas->m_strokes.size()) - m_strokes.append(canvas->m_strokes[m_strokes.size()]); + // Detect removed strokes: present in renderer but no longer in canvas + QSet canvasGroupIds; + for (const auto& stroke : canvas->m_strokes) + canvasGroupIds.insert(stroke.groupId); - if (canvas->m_strokes.isEmpty() && !m_strokes.isEmpty()) { - for (const auto& stroke : m_strokes) - if (stroke.groupId >= 0) - m_pendingGroupRemovals.append(stroke.groupId); - m_strokes.clear(); + for (int i = m_strokes.size() - 1; i >= 0; --i) { + if (!canvasGroupIds.contains(m_strokes[i].groupId)) { + if (m_strokes[i].groupId >= 0) + m_pendingGroupRemovals.append(m_strokes[i].groupId); + m_strokes.remove(i); + } + } + + // Detect added strokes: present in canvas but not yet in renderer + QSet rendererGroupIds; + for (const auto& stroke : m_strokes) + rendererGroupIds.insert(stroke.groupId); + + for (const auto& stroke : canvas->m_strokes) { + if (!rendererGroupIds.contains(stroke.groupId)) + m_strokes.append(stroke); } m_currentStroke = canvas->m_currentStroke; From 2c88b7abcb28160520d7b3c3b37f804e463b544c Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 4 Oct 2026 18:20:10 +0200 Subject: [PATCH 67/90] move picker to main panelwindow to reduce vram usage --- Components/ScreenState.qml | 3 + Drawers/Windows.qml | 21 ++++++- Helpers/AreaPicker.qml | 88 --------------------------- Helpers/Picker.qml | 93 +++++++++++++++++++++-------- Helpers/Picker/AnnotationCanvas.qml | 12 ++-- Helpers/Picker/SelectionHandles.qml | 16 ++++- Modules/Shortcuts.qml | 17 ++++++ Services/ShellState.qml | 7 ++- shell.qml | 4 -- 9 files changed, 133 insertions(+), 128 deletions(-) delete mode 100644 Helpers/AreaPicker.qml diff --git a/Components/ScreenState.qml b/Components/ScreenState.qml index 663dfe1..9885c51 100644 --- a/Components/ScreenState.qml +++ b/Components/ScreenState.qml @@ -34,4 +34,7 @@ PersistentProperties { // Drawing state property color penColor: "white" property int penWidth: 4 + + // Screenshot state + property bool screenshot } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index 84b53e5..d4f03ef 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -56,11 +56,10 @@ CustomWindow { property color surfaceColor: Colors.tPalette.m3surface WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: screenState.launcher || screenState.settings || screenState.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: screenState.screenshot ? WlrKeyboardFocus.Exclusive : screenState.launcher || screenState.settings || screenState.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top color: "transparent" - contentItem.focus: true - mask: screenState.isDrawing ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions) + mask: screenState.isDrawing || screenState.screenshot ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions) name: "Bar" Behavior on fsTransitionProg { @@ -430,6 +429,22 @@ CustomWindow { } } + LazyLoader { + id: loader + + property bool closing: !root.screenState.screenshot + + onClosingChanged: if (!closing) + activeAsync = true + + Picker { + loader: loader + parent: root.contentItem + screen: root.screen + screenState: root.screenState + } + } + component PanelBg: BlobRect2 { property real deformAmount: 0.15 required property Item panel diff --git a/Helpers/AreaPicker.qml b/Helpers/AreaPicker.qml deleted file mode 100644 index ce15fab..0000000 --- a/Helpers/AreaPicker.qml +++ /dev/null @@ -1,88 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import Quickshell.Wayland -import Quickshell.Io -import Quickshell.Hyprland -import ZShell -import qs.Components - -Scope { - LazyLoader { - id: root - - property bool closing - property bool freeze - - Variants { - model: Quickshell.screens - - PanelWindow { - id: win - - required property ShellScreen modelData - - WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: root.closing ? WlrKeyboardFocus.None : WlrKeyboardFocus.Exclusive - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.namespace: "areapicker" - color: "transparent" - mask: root.closing ? empty : null - screen: modelData - - anchors { - bottom: true - left: true - right: true - top: true - } - - Region { - id: empty - - } - - Picker { - loader: root - screen: win.modelData - } - } - } - } - - IpcHandler { - function open(): void { - root.freeze = false; - root.closing = false; - root.activeAsync = true; - } - - function openFreeze(): void { - root.freeze = true; - root.closing = false; - root.activeAsync = true; - } - - target: "picker" - } - - CustomShortcut { - name: "screenshot" - - onPressed: { - root.freeze = false; - root.closing = false; - root.activeAsync = true; - } - } - - CustomShortcut { - name: "screenshotFreeze" - - onPressed: { - root.freeze = true; - root.closing = false; - root.activeAsync = true; - } - } -} diff --git a/Helpers/Picker.qml b/Helpers/Picker.qml index a7683c7..5c3a08d 100755 --- a/Helpers/Picker.qml +++ b/Helpers/Picker.qml @@ -41,6 +41,7 @@ Item { property real rsy: Math.min(sy, ey) readonly property real scaleRatio: Hypr.monitorFor(screen).scale required property ShellScreen screen + required property ScreenState screenState property real sh: Math.abs(sy - ey) readonly property color shadowColor: Hypr.options.decoration.shadow?.color ?? "black" readonly property bool shadowEnabled: Hypr.options.decoration.shadow.enabled @@ -97,10 +98,6 @@ Item { const args = Config.screenshot.mode === "auto" ? ["--rounding", `${rounding}`, "--radius", root.cornerRadius, "--shadow", root.shadowEnabled, "--shadow-blur", `${shadow_blur}`, "--shadow-color", `${r},${g},${b},${a}`, "--shadow-offset-x", root.shadowOffset[0], "--shadow-offset-y", root.shadowOffset[1]] : []; const postCmd = Config.screenshot.enable_pp ? ["zshell-img-tools", "--scale", root.scaleRatio, ...args, "--image"] : null; - overlay.visible = border.visible = false; - handles.visible = false; - toolbar.visible = false; - ZShellIo.saveItem(captureLayer, tmpfile, Qt.rect(Math.ceil(rsx), Math.ceil(rsy), Math.floor(sw), Math.floor(sh)), path => { const finalize = finalPath => { if (copyToClipboard) { @@ -158,7 +155,7 @@ Item { Component.onCompleted: { Hypr.extras.refreshOptions(); - screencopy.active = true; + root.forceActiveFocus(); opacity = 1; @@ -223,13 +220,63 @@ Item { onReleased: root.enterEditMode() } + Connections { + function onClosingChanged(): void { + if (root.loader.closing) + closeAnim.start(); + else { + closeAnim.stop(); + returnAnim.start(); + } + } + + target: root.loader + } + + ParallelAnimation { + id: returnAnim + + Anim { + duration: 300 + property: "opacity" + target: root + to: 1 + } + + Anim { + property: "rsx" + target: root + to: Math.min(root.sx, root.ex) + } + + Anim { + property: "rsy" + target: root + to: Math.min(root.sy, root.ey) + } + + Anim { + property: "sw" + target: root + to: Math.abs(root.sx - root.ex) + } + + Anim { + property: "sh" + target: root + to: Math.abs(root.sy - root.ey) + } + } + SequentialAnimation { id: closeAnim - PropertyAction { - property: "closing" - target: root.loader - value: true + ScriptAction { + script: { + const states = ShellState.allStates(); + for (const s of states) + s.screenshot = false; + } } ParallelAnimation { @@ -271,17 +318,12 @@ Item { anchors.fill: parent - Loader { + ScreencopyView { id: screencopy - active: false anchors.fill: parent - asynchronous: true - - sourceComponent: ScreencopyView { - captureSource: root.screen - paintCursor: false - } + captureSource: root.screen + paintCursor: false } AnnotationCanvas { @@ -309,8 +351,7 @@ Item { color: "white" layer.enabled: true opacity: 0.3 - radius: root.realRounding - visible: screencopy.item?.hasContent ?? false + visible: screencopy.hasContent layer.effect: MultiEffect { maskEnabled: true @@ -347,7 +388,7 @@ Item { implicitHeight: selectionRect.implicitHeight + root.realBorderWidth * 2 implicitWidth: selectionRect.implicitWidth + root.realBorderWidth * 2 radius: root.realRounding > 0 ? root.realRounding + root.realBorderWidth : 0 - visible: screencopy.item?.hasContent ?? false + visible: screencopy.hasContent x: selectionRect.x - root.realBorderWidth y: selectionRect.y - root.realBorderWidth @@ -370,10 +411,14 @@ Item { visible: root.mode === "edit" && root.tool === "move" onBoundsChanged: (x, y, w, h) => { - root.sx = x; - root.sy = y; - root.ex = x + w; - root.ey = y + h; + let nx = Math.max(0, x); + let ny = Math.max(0, y); + let nw = Math.min(w, root.screen.width - nx); + let nh = Math.min(h, root.screen.height - ny); + root.sx = nx; + root.sy = ny; + root.ex = nx + nw; + root.ey = ny + nh; } onMoveBy: (dx, dy) => { const w = root.sw, h = root.sh; diff --git a/Helpers/Picker/AnnotationCanvas.qml b/Helpers/Picker/AnnotationCanvas.qml index 32e251f..4116eaf 100755 --- a/Helpers/Picker/AnnotationCanvas.qml +++ b/Helpers/Picker/AnnotationCanvas.qml @@ -59,6 +59,7 @@ Item { } anchors.fill: parent + enabled: tool !== "none" StrokeCanvas { id: canvas @@ -77,15 +78,12 @@ Item { acceptedButtons: Qt.LeftButton | Qt.RightButton anchors.fill: parent cursorShape: undefined - enabled: root.tool !== "none" - hoverEnabled: true + enabled: root.enabled onPositionChanged: e => { const x = e.x; const y = e.y; - canvas.hoverPoint = Qt.point(x, y); - if (!pressed) return; @@ -125,12 +123,16 @@ Item { const withinYHeight = y > root.selection.y && y < root.selection.y + root.selection.implicitHeight; if (withinXWidth && withinYHeight) { - canvas.showHover(x, y); + if (!drawHandler.pressed) + canvas.showHover(x, y); + else + canvas.showHover(drawHandler.mouseX, drawHandler.mouseY); return Qt.BlankCursor; } else { canvas.hideHover(); return undefined; } } + enabled: root.enabled } } diff --git a/Helpers/Picker/SelectionHandles.qml b/Helpers/Picker/SelectionHandles.qml index d79ae71..3359220 100755 --- a/Helpers/Picker/SelectionHandles.qml +++ b/Helpers/Picker/SelectionHandles.qml @@ -1,13 +1,15 @@ pragma ComponentBehavior: Bound import QtQuick +import ZShell.Internal +import qs.Services Item { id: root required property int border - property color handleBorder: "#3584e4" - property color handleFill: "white" + property color handleBorder: Colors.palette.m3primary + property color handleFill: Colors.palette.m3onPrimary property real hs: 12 property real minSize: 24 required property real selH @@ -26,6 +28,8 @@ Item { x: selX y: selY + Component.onDestruction: Cursor.clearOverride() + DragHandler { id: moveHandler @@ -33,6 +37,7 @@ Item { property real accumY: 0 cursorShape: undefined + dragThreshold: 0 target: null onActiveChanged: { @@ -54,7 +59,7 @@ Item { if (root.tool !== "move") return undefined; - if (moveHandler.centroid.pressedButtons & Qt.LeftButton) + if ((moveHandler.centroid.pressedButtons & Qt.LeftButton) && moveHandler.active) return Qt.ClosedHandCursor; else return Qt.OpenHandCursor; @@ -123,6 +128,8 @@ Item { z: 10 HoverHandler { + id: hover + cursorShape: { switch (handle.edge) { case "tl": @@ -148,6 +155,7 @@ Item { property real startX property real startY + dragThreshold: 0 grabPermissions: PointerHandler.CanTakeOverFromAnything | PointerHandler.TakeOverForbidden target: null @@ -157,8 +165,10 @@ Item { startY = root.y; startW = root.width; startH = root.height; + Cursor.setOverride(hover.cursorShape); } else { root.dragFinished(); + Cursor.clearOverride(); } } onTranslationChanged: { diff --git a/Modules/Shortcuts.qml b/Modules/Shortcuts.qml index ca227ca..1d11eb9 100644 --- a/Modules/Shortcuts.qml +++ b/Modules/Shortcuts.qml @@ -11,6 +11,18 @@ Scope { readonly property bool hasFullscreen: Hypr.focusedWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen === 2) ?? false property bool launcherInterrupted + CustomShortcut { + description: "Open screenshot" + name: "screenshot" + + onPressed: { + const states = ShellState.allStates(); + for (const s of states) { + s.screenshot = true; + } + } + } + CustomShortcut { description: "Toggle launcher" name: "toggle-launcher" @@ -26,6 +38,7 @@ Scope { } CustomShortcut { + description: "Toggle drawing" name: "toggle-drawing" onPressed: { @@ -35,6 +48,7 @@ Scope { } CustomShortcut { + description: "Toggle sidebar" name: "toggle-nc" onPressed: { @@ -44,6 +58,7 @@ Scope { } CustomShortcut { + description: "Show On-Screen-Display" name: "show-osd" onPressed: { @@ -53,6 +68,7 @@ Scope { } CustomShortcut { + description: "Toggle settings" name: "toggle-settings" onPressed: { @@ -62,6 +78,7 @@ Scope { } CustomShortcut { + description: "Toggle clipboard" name: "toggle-clipboard" onPressed: { diff --git a/Services/ShellState.qml b/Services/ShellState.qml index b806f4d..7d48f4b 100644 --- a/Services/ShellState.qml +++ b/Services/ShellState.qml @@ -6,6 +6,10 @@ import qs.Components import qs.Helpers Singleton { + function allStates(): list { + return [...states.instances]; + } + function anySidebarOpen(): bool { return states.instances.some(s => s.sidebar); } @@ -30,6 +34,7 @@ Singleton { model: Quickshell.screens - ScreenState {} + ScreenState { + } } } diff --git a/shell.qml b/shell.qml index f602e71..75bde5a 100644 --- a/shell.qml +++ b/shell.qml @@ -12,7 +12,6 @@ import qs.Modules import qs.Modules.Wallpaper import qs.Modules.Lock import qs.Drawers -import qs.Helpers import qs.Modules.Polkit import qs.Daemons @@ -29,9 +28,6 @@ ShellRoot { Wallpaper { } - AreaPicker { - } - Lock { id: lock } From f90e9bb1a0e1c3515d83266701012124802fcd12 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 5 Oct 2026 09:57:47 +0200 Subject: [PATCH 68/90] fix: all apps icon size and suppress warnings --- Modules/Settings/Common/ConnectedRect.qml | 2 +- Modules/Settings/Pages/Apps/AllApps.qml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Settings/Common/ConnectedRect.qml b/Modules/Settings/Common/ConnectedRect.qml index 12449af..04762a6 100644 --- a/Modules/Settings/Common/ConnectedRect.qml +++ b/Modules/Settings/Common/ConnectedRect.qml @@ -17,7 +17,7 @@ CustomRect { bottomLeftRadius: last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall bottomRightRadius: last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall color: Colors.tPalette.m3surfaceContainer - opacity: enabled && parent.enabled ? 1 : 0.5 + opacity: enabled && (parent?.enabled ?? false) ? 1 : 0.5 topLeftRadius: first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall topRightRadius: first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall diff --git a/Modules/Settings/Pages/Apps/AllApps.qml b/Modules/Settings/Pages/Apps/AllApps.qml index 4f9e685..228ffab 100644 --- a/Modules/Settings/Pages/Apps/AllApps.qml +++ b/Modules/Settings/Pages/Apps/AllApps.qml @@ -52,11 +52,11 @@ PageBase { anchors.leftMargin: Tokens.padding.largeIncreased anchors.margins: Tokens.padding.small anchors.rightMargin: Tokens.padding.largeIncreased - spacing: Tokens.spacing.small + spacing: Tokens.spacing.medium IconImage { asynchronous: true - implicitSize: Math.round(Tokens.font.icon.large * 1.8) + implicitSize: Math.round(Tokens.font.icon.large.pointSize * 1.8) source: Quickshell.iconPath(appItem.modelData.icon, "image-missing") } From 9f5e62f9c7cd9c941f5474fa13bf562795d4fee3 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 5 Oct 2026 19:31:15 +0200 Subject: [PATCH 69/90] feat: use custom wayland layer-shell to render wallpaper to reduce vram --- Modules/ServiceLoader.qml | 9 + Modules/Settings/Common/DialogRowButton.qml | 13 +- Modules/Wallpaper/WallBackground.qml | 51 -- Modules/Wallpaper/Wallpaper.qml | 67 +- Plugins/CMakeLists.txt | 22 +- Plugins/ZShell/Internal/CMakeLists.txt | 17 + .../protocols/wlr-layer-shell-unstable-v1.xml | 407 ++++++++++ Plugins/ZShell/Internal/wallpaperobject.cpp | 83 ++ Plugins/ZShell/Internal/wallpaperobject.hpp | 82 ++ Plugins/ZShell/Internal/wallpaperservice.cpp | 749 ++++++++++++++++++ Plugins/ZShell/Internal/wallpaperservice.hpp | 147 ++++ Plugins/ZShell/Internal/wallpapersurface.cpp | 147 ++++ Plugins/ZShell/Internal/wallpapersurface.hpp | 58 ++ Plugins/cmake/wayland-protocol.cmake | 58 ++ shell.qml | 7 +- 15 files changed, 1824 insertions(+), 93 deletions(-) create mode 100644 Modules/ServiceLoader.qml delete mode 100644 Modules/Wallpaper/WallBackground.qml create mode 100644 Plugins/ZShell/Internal/protocols/wlr-layer-shell-unstable-v1.xml create mode 100644 Plugins/ZShell/Internal/wallpaperobject.cpp create mode 100644 Plugins/ZShell/Internal/wallpaperobject.hpp create mode 100644 Plugins/ZShell/Internal/wallpaperservice.cpp create mode 100644 Plugins/ZShell/Internal/wallpaperservice.hpp create mode 100644 Plugins/ZShell/Internal/wallpapersurface.cpp create mode 100644 Plugins/ZShell/Internal/wallpapersurface.hpp create mode 100644 Plugins/cmake/wayland-protocol.cmake diff --git a/Modules/ServiceLoader.qml b/Modules/ServiceLoader.qml new file mode 100644 index 0000000..01a2092 --- /dev/null +++ b/Modules/ServiceLoader.qml @@ -0,0 +1,9 @@ +import Quickshell +import QtQuick +import qs.Modules.Wallpaper + +Scope { + Component.onCompleted: { + Wallpaper; + } +} diff --git a/Modules/Settings/Common/DialogRowButton.qml b/Modules/Settings/Common/DialogRowButton.qml index ee9d4cb..fc59f88 100644 --- a/Modules/Settings/Common/DialogRowButton.qml +++ b/Modules/Settings/Common/DialogRowButton.qml @@ -139,12 +139,13 @@ Item { id: dialogBg anchors.fill: parent - bottomLeftRadius: Tokens.rounding.largeIncreased - bottomRightRadius: Tokens.rounding.largeIncreased + bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + bottomRightRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall deformScale: 0 group: blobGroup opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) - radius: Tokens.rounding.extraSmall + topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall + topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall } RowButton { @@ -153,9 +154,11 @@ Item { anchors.left: parent.left anchors.right: parent.right color: "transparent" - height: Math.min(implicitHeight, parent.height) // Clamp to parent height due to overshoot anim + first: root.first + height: Math.min(implicitHeight, parent.height) icon: root.icon - last: true + last: root.last + subtext: root.subtext text: root.label transform: Matrix4x4 { diff --git a/Modules/Wallpaper/WallBackground.qml b/Modules/Wallpaper/WallBackground.qml deleted file mode 100644 index 76e44dd..0000000 --- a/Modules/Wallpaper/WallBackground.qml +++ /dev/null @@ -1,51 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import Quickshell.Hyprland -import QtQuick -import qs.Components -import qs.Helpers -import ZShell.Internal - -Item { - id: root - - readonly property var displayData: Wallpapers.getCrop(screen.name) - required property ShellScreen screen - property size screenResolution: Qt.size(screen.width * screenScale, screen.height * screenScale) - property real screenScale: Hyprland.monitorFor(screen).scale - - anchors.fill: parent - - Component.onCompleted: Hyprland.refreshMonitors() - - WallpaperImage { - id: img - - anchors.fill: parent - cropHeight: root.displayData?.height ?? 1.0 - cropWidth: root.displayData?.width ?? 1.0 - cropX: root.displayData?.x ?? 0.0 - cropY: root.displayData?.y ?? 0.0 - fadeDuration: 150 - screenResolution: root.screenResolution - source: Wallpapers.current - - Behavior on cropHeight { - Anim { - } - } - Behavior on cropWidth { - Anim { - } - } - Behavior on cropX { - Anim { - } - } - Behavior on cropY { - Anim { - } - } - } -} diff --git a/Modules/Wallpaper/Wallpaper.qml b/Modules/Wallpaper/Wallpaper.qml index 02fe54b..563e4e8 100644 --- a/Modules/Wallpaper/Wallpaper.qml +++ b/Modules/Wallpaper/Wallpaper.qml @@ -1,46 +1,49 @@ +pragma Singleton + import Quickshell import QtQuick -import Quickshell.Wayland -import ZShell.Config -import qs.Modules.DesktopIcons +import ZShell.Internal +import qs.Components +import qs.Helpers -Loader { - active: Config.background.enabled - asynchronous: false +Singleton { + id: root - sourceComponent: Variants { + WallpaperService { + id: wallpaper + + path: Wallpapers.current + } + + Variants { model: Quickshell.screens - PanelWindow { - id: root - + WallpaperObject { + readonly property var crops: Wallpapers.getCrop(modelData.name) required property var modelData - WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: WlrLayer.Background - WlrLayershell.namespace: "ZShell-Wallpaper" - color: "transparent" - screen: modelData - surfaceFormat.opaque: true + cropHeight: crops?.height ?? 1.0 + cropWidth: crops?.width ?? 1.0 + cropX: crops?.x ?? 0.0 + cropY: crops?.y ?? 0.0 + screen: modelData.name + service: wallpaper + zoom: crops?.zoom ?? 1.0 - anchors { - bottom: true - left: true - right: true - top: true + Behavior on cropHeight { + Anim { + } } - - WallBackground { - screen: root.screen + Behavior on cropWidth { + Anim { + } } - - Loader { - id: loader - - active: Config.general.desktopIcons - anchors.fill: parent - - sourceComponent: DesktopIcons { + Behavior on cropX { + Anim { + } + } + Behavior on cropY { + Anim { } } } diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 5c302ae..bca6758 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -1,5 +1,24 @@ -find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick QuickPrivate Concurrent Sql Network DBus CanvasPainter) +find_package(Qt6 REQUIRED COMPONENTS + ShaderTools + Core + Qml + Gui + GuiPrivate + Quick + QuickPrivate + Concurrent + Sql + Network + DBus + CanvasPainter + WaylandClient + WaylandClientPrivate +) find_package(PkgConfig REQUIRED) +pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client) +pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl) +pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl) +pkg_check_modules(GL REQUIRED IMPORTED_TARGET gl) 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) @@ -17,6 +36,7 @@ qt_standard_project_setup(REQUIRES 6.9) include(cmake/pch.cmake) include(cmake/qml-module.cmake) +include(cmake/wayland-protocol.cmake) add_library(zshell-util INTERFACE) target_include_directories(zshell-util INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index f789bba..62911fd 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -1,3 +1,9 @@ +wl_proto(wlp-layer-shell wlr-layer-shell-unstable-v1 "${CMAKE_CURRENT_SOURCE_DIR}/protocols") +wl_proto(wlp-viewporter viewporter "${WAYLAND_PROTOCOLS}/stable/viewporter") +wl_proto(wlp-fractional-scale fractional-scale-v1 "${WAYLAND_PROTOCOLS}/staging/fractional-scale") + +wl_proto(wlp-xdg-shell xdg-shell "${WAYLAND_PROTOCOLS}/stable/xdg-shell") + qml_module(ZShell-internal URI ZShell.Internal INCLUDE_PREFIX internal @@ -9,6 +15,9 @@ qml_module(ZShell-internal circularbuffer.hpp circularbuffer.cpp arcgauge.hpp arcgauge.cpp wallpaperimage.hpp wallpaperimage.cpp + wallpaperservice.hpp wallpaperservice.cpp + wallpapersurface.hpp wallpapersurface.cpp + wallpaperobject.hpp wallpaperobject.cpp lidwatcher.hpp lidwatcher.cpp visualizerbars.hpp visualizerbars.cpp linearindicatormanager.hpp linearindicatormanager.cpp @@ -23,4 +32,12 @@ qml_module(ZShell-internal Qt::Network Qt::DBus Qt::CanvasPainter + Qt::GuiPrivate + wlp-layer-shell + wlp-xdg-shell + wlp-viewporter + wlp-fractional-scale + PkgConfig::EGL + PkgConfig::WAYLAND_EGL + PkgConfig::GL ) diff --git a/Plugins/ZShell/Internal/protocols/wlr-layer-shell-unstable-v1.xml b/Plugins/ZShell/Internal/protocols/wlr-layer-shell-unstable-v1.xml new file mode 100644 index 0000000..283f391 --- /dev/null +++ b/Plugins/ZShell/Internal/protocols/wlr-layer-shell-unstable-v1.xml @@ -0,0 +1,407 @@ + + + + Copyright © 2017 Drew DeVault + + Permission to use, copy, modify, distribute, and sell this + software and its documentation for any purpose is hereby granted + without fee, provided that the above copyright notice appear in + all copies and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of + the copyright holders not be used in advertising or publicity + pertaining to distribution of the software without specific, + written prior permission. The copyright holders make no + representations about the suitability of this software for any + purpose. It is provided "as is" without express or implied + warranty. + + THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS + SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY + SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF + THIS SOFTWARE. + + + + + Clients can use this interface to assign the surface_layer role to + wl_surfaces. Such surfaces are assigned to a "layer" of the output and + rendered with a defined z-depth respective to each other. They may also be + anchored to the edges and corners of a screen and specify input handling + semantics. This interface should be suitable for the implementation of + many desktop shell components, and a broad number of other applications + that interact with the desktop. + + + + + Create a layer surface for an existing surface. This assigns the role of + layer_surface, or raises a protocol error if another role is already + assigned. + + Creating a layer surface from a wl_surface which has a buffer attached + or committed is a client error, and any attempts by a client to attach + or manipulate a buffer prior to the first layer_surface.configure call + must also be treated as errors. + + After creating a layer_surface object and setting it up, the client + must perform an initial commit without any buffer attached. + The compositor will reply with a layer_surface.configure event. + The client must acknowledge it and is then allowed to attach a buffer + to map the surface. + + You may pass NULL for output to allow the compositor to decide which + output to use. Generally this will be the one that the user most + recently interacted with. + + Clients can specify a namespace that defines the purpose of the layer + surface. + + + + + + + + + + + + + + + + + These values indicate which layers a surface can be rendered in. They + are ordered by z depth, bottom-most first. Traditional shell surfaces + will typically be rendered between the bottom and top layers. + Fullscreen shell surfaces are typically rendered at the top layer. + Multiple surfaces can share a single layer, and ordering within a + single layer is undefined. + + + + + + + + + + + + + This request indicates that the client will not use the layer_shell + object any more. Objects that have been created through this instance + are not affected. + + + + + + + An interface that may be implemented by a wl_surface, for surfaces that + are designed to be rendered as a layer of a stacked desktop-like + environment. + + Layer surface state (layer, size, anchor, exclusive zone, + margin, interactivity) is double-buffered, and will be applied at the + time wl_surface.commit of the corresponding wl_surface is called. + + Attaching a null buffer to a layer surface unmaps it. + + Unmapping a layer_surface means that the surface cannot be shown by the + compositor until it is explicitly mapped again. The layer_surface + returns to the state it had right after layer_shell.get_layer_surface. + The client can re-map the surface by performing a commit without any + buffer attached, waiting for a configure event and handling it as usual. + + + + + Sets the size of the surface in surface-local coordinates. The + compositor will display the surface centered with respect to its + anchors. + + If you pass 0 for either value, the compositor will assign it and + inform you of the assignment in the configure event. You must set your + anchor to opposite edges in the dimensions you omit; not doing so is a + protocol error. Both values are 0 by default. + + Size is double-buffered, see wl_surface.commit. + + + + + + + + Requests that the compositor anchor the surface to the specified edges + and corners. If two orthogonal edges are specified (e.g. 'top' and + 'left'), then the anchor point will be the intersection of the edges + (e.g. the top left corner of the output); otherwise the anchor point + will be centered on that edge, or in the center if none is specified. + + Anchor is double-buffered, see wl_surface.commit. + + + + + + + Requests that the compositor avoids occluding an area with other + surfaces. The compositor's use of this information is + implementation-dependent - do not assume that this region will not + actually be occluded. + + A positive value is only meaningful if the surface is anchored to one + edge or an edge and both perpendicular edges. If the surface is not + anchored, anchored to only two perpendicular edges (a corner), anchored + to only two parallel edges or anchored to all edges, a positive value + will be treated the same as zero. + + A positive zone is the distance from the edge in surface-local + coordinates to consider exclusive. + + Surfaces that do not wish to have an exclusive zone may instead specify + how they should interact with surfaces that do. If set to zero, the + surface indicates that it would like to be moved to avoid occluding + surfaces with a positive exclusive zone. If set to -1, the surface + indicates that it would not like to be moved to accommodate for other + surfaces, and the compositor should extend it all the way to the edges + it is anchored to. + + For example, a panel might set its exclusive zone to 10, so that + maximized shell surfaces are not shown on top of it. A notification + might set its exclusive zone to 0, so that it is moved to avoid + occluding the panel, but shell surfaces are shown underneath it. A + wallpaper or lock screen might set their exclusive zone to -1, so that + they stretch below or over the panel. + + The default value is 0. + + Exclusive zone is double-buffered, see wl_surface.commit. + + + + + + + Requests that the surface be placed some distance away from the anchor + point on the output, in surface-local coordinates. Setting this value + for edges you are not anchored to has no effect. + + The exclusive zone includes the margin. + + Margin is double-buffered, see wl_surface.commit. + + + + + + + + + + Types of keyboard interaction possible for layer shell surfaces. The + rationale for this is twofold: (1) some applications are not interested + in keyboard events and not allowing them to be focused can improve the + desktop experience; (2) some applications will want to take exclusive + keyboard focus. + + + + + This value indicates that this surface is not interested in keyboard + events and the compositor should never assign it the keyboard focus. + + This is the default value, set for newly created layer shell surfaces. + + This is useful for e.g. desktop widgets that display information or + only have interaction with non-keyboard input devices. + + + + + Request exclusive keyboard focus if this surface is above the shell surface layer. + + For the top and overlay layers, the seat will always give + exclusive keyboard focus to the top-most layer which has keyboard + interactivity set to exclusive. If this layer contains multiple + surfaces with keyboard interactivity set to exclusive, the compositor + determines the one receiving keyboard events in an implementation- + defined manner. In this case, no guarantee is made when this surface + will receive keyboard focus (if ever). + + For the bottom and background layers, the compositor is allowed to use + normal focus semantics. + + This setting is mainly intended for applications that need to ensure + they receive all keyboard events, such as a lock screen or a password + prompt. + + + + + This requests the compositor to allow this surface to be focused and + unfocused by the user in an implementation-defined manner. The user + should be able to unfocus this surface even regardless of the layer + it is on. + + Typically, the compositor will want to use its normal mechanism to + manage keyboard focus between layer shell surfaces with this setting + and regular toplevels on the desktop layer (e.g. click to focus). + Nevertheless, it is possible for a compositor to require a special + interaction to focus or unfocus layer shell surfaces (e.g. requiring + a click even if focus follows the mouse normally, or providing a + keybinding to switch focus between layers). + + This setting is mainly intended for desktop shell components (e.g. + panels) that allow keyboard interaction. Using this option can allow + implementing a desktop shell that can be fully usable without the + mouse. + + + + + + + Set how keyboard events are delivered to this surface. By default, + layer shell surfaces do not receive keyboard events; this request can + be used to change this. + + This setting is inherited by child surfaces set by the get_popup + request. + + Layer surfaces receive pointer, touch, and tablet events normally. If + you do not want to receive them, set the input region on your surface + to an empty region. + + Keyboard interactivity is double-buffered, see wl_surface.commit. + + + + + + + This assigns an xdg_popup's parent to this layer_surface. This popup + should have been created via xdg_surface::get_popup with the parent set + to NULL, and this request must be invoked before committing the popup's + initial state. + + See the documentation of xdg_popup for more details about what an + xdg_popup is and how it is used. + + + + + + + When a configure event is received, if a client commits the + surface in response to the configure event, then the client + must make an ack_configure request sometime before the commit + request, passing along the serial of the configure event. + + If the client receives multiple configure events before it + can respond to one, it only has to ack the last configure event. + + A client is not required to commit immediately after sending + an ack_configure request - it may even ack_configure several times + before its next surface commit. + + A client may send multiple ack_configure requests before committing, but + only the last request sent before a commit indicates which configure + event the client really is responding to. + + + + + + + This request destroys the layer surface. + + + + + + The configure event asks the client to resize its surface. + + Clients should arrange their surface for the new states, and then send + an ack_configure request with the serial sent in this configure event at + some point before committing the new surface. + + The client is free to dismiss all but the last configure event it + received. + + The width and height arguments specify the size of the window in + surface-local coordinates. + + The size is a hint, in the sense that the client is free to ignore it if + it doesn't resize, pick a smaller size (to satisfy aspect ratio or + resize in steps of NxM pixels). If the client picks a smaller size and + is anchored to two opposite anchors (e.g. 'top' and 'bottom'), the + surface will be centered on this axis. + + If the width or height arguments are zero, it means the client should + decide its own window dimension. + + + + + + + + + The closed event is sent by the compositor when the surface will no + longer be shown. The output may have been destroyed or the user may + have asked for it to be removed. Further changes to the surface will be + ignored. The client should destroy the resource after receiving this + event, and create a new surface if they so choose. + + + + + + + + + + + + + + + + + + + + + + + Change the layer that the surface is rendered on. + + Layer is double-buffered, see wl_surface.commit. + + + + + + + + + Requests an edge for the exclusive zone to apply. The exclusive + edge will be automatically deduced from anchor points when possible, + but when the surface is anchored to a corner, it will be necessary + to set it explicitly to disambiguate, as it is not possible to deduce + which one of the two corner edges should be used. + + The edge must be one the surface is anchored to, otherwise the + invalid_exclusive_edge protocol error will be raised. + + + + + diff --git a/Plugins/ZShell/Internal/wallpaperobject.cpp b/Plugins/ZShell/Internal/wallpaperobject.cpp new file mode 100644 index 0000000..b11057e --- /dev/null +++ b/Plugins/ZShell/Internal/wallpaperobject.cpp @@ -0,0 +1,83 @@ +#include "wallpaperobject.hpp" + +namespace ZShell::internal { + +WallpaperObject::WallpaperObject(QObject* parent) : QObject(parent) {} + +WallpaperObject::~WallpaperObject() { + if (m_service && !m_screen.isEmpty()) m_service->removeScreen(m_screen); +} + +void WallpaperObject::registerScreen() { + if (!m_service || m_screen.isEmpty()) return; + + m_service->addScreen(m_screen); +} + +void WallpaperObject::setService(WallpaperService* service) { + if (m_service == service) return; + m_service = service; + emit serviceChanged(); + + registerScreen(); + push(); +} + +void WallpaperObject::setScreen(const QString& screen) { + if (m_screen == screen) return; + m_screen = screen; + emit screenChanged(); + + registerScreen(); + push(); +} + +void WallpaperObject::setCropX(qreal x) { + if (qFuzzyCompare(m_cropX, x)) return; + m_cropX = x; + emit cropXChanged(); + push(); +} + +void WallpaperObject::setCropY(qreal y) { + if (qFuzzyCompare(m_cropY, y)) return; + m_cropY = y; + emit cropYChanged(); + push(); +} + +void WallpaperObject::setCropWidth(qreal w) { + if (qFuzzyCompare(m_cropWidth, w)) return; + m_cropWidth = w; + emit cropWidthChanged(); + push(); +} + +void WallpaperObject::setCropHeight(qreal h) { + if (qFuzzyCompare(m_cropHeight, h)) return; + m_cropHeight = h; + emit cropHeightChanged(); + push(); +} + +void WallpaperObject::setZoom(qreal zoom) { + if (qFuzzyCompare(m_zoom, zoom)) return; + m_zoom = zoom; + emit zoomChanged(); + push(); +} + +void WallpaperObject::push() { + if (!m_service || m_screen.isEmpty()) return; + + QVariantMap crop; + crop.insert("x", m_cropX); + crop.insert("y", m_cropY); + crop.insert("width", m_cropWidth); + crop.insert("height", m_cropHeight); + crop.insert("zoom", m_zoom); + + m_service->setCrop(m_screen, crop); +} + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperobject.hpp b/Plugins/ZShell/Internal/wallpaperobject.hpp new file mode 100644 index 0000000..eb3d8a4 --- /dev/null +++ b/Plugins/ZShell/Internal/wallpaperobject.hpp @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "wallpaperservice.hpp" + +namespace ZShell::internal { + +class WallpaperObject : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + WallpaperService* service READ service WRITE setService NOTIFY + serviceChanged) + Q_PROPERTY(QString screen READ screen WRITE setScreen NOTIFY screenChanged) + + Q_PROPERTY(qreal cropX READ cropX WRITE setCropX NOTIFY cropXChanged) + Q_PROPERTY(qreal cropY READ cropY WRITE setCropY NOTIFY cropYChanged) + Q_PROPERTY( + qreal cropWidth READ cropWidth WRITE setCropWidth NOTIFY + cropWidthChanged) + Q_PROPERTY( + qreal cropHeight READ cropHeight WRITE setCropHeight NOTIFY + cropHeightChanged) + Q_PROPERTY(qreal zoom READ zoom WRITE setZoom NOTIFY zoomChanged) + + public: + explicit WallpaperObject(QObject* parent = nullptr); + ~WallpaperObject() override; + + WallpaperService* service() const { return m_service; } + void setService(WallpaperService* service); + + QString screen() const { return m_screen; } + void setScreen(const QString& screen); + + qreal cropX() const { return m_cropX; } + void setCropX(qreal x); + + qreal cropY() const { return m_cropY; } + void setCropY(qreal y); + + qreal cropWidth() const { return m_cropWidth; } + void setCropWidth(qreal w); + + qreal cropHeight() const { return m_cropHeight; } + void setCropHeight(qreal h); + + qreal zoom() const { return m_zoom; } + void setZoom(qreal zoom); + + signals: + void serviceChanged(); + void screenChanged(); + void cropXChanged(); + void cropYChanged(); + void cropWidthChanged(); + void cropHeightChanged(); + void zoomChanged(); + + private: + void registerScreen(); + + void push(); + + QPointer m_service; + QString m_screen; + + qreal m_cropX = 0.0; + qreal m_cropY = 0.0; + qreal m_cropWidth = 1.0; + qreal m_cropHeight = 1.0; + qreal m_zoom = 1.0; +}; + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperservice.cpp b/Plugins/ZShell/Internal/wallpaperservice.cpp new file mode 100644 index 0000000..9933218 --- /dev/null +++ b/Plugins/ZShell/Internal/wallpaperservice.cpp @@ -0,0 +1,749 @@ +#define EGL_EGLEXT_PROTOTYPES +#define GL_GLEXT_PROTOTYPES + +#include "wallpaperservice.hpp" + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include + +#include + +namespace ZShell::internal { + +namespace { + +const char* kVertexShader = R"( +#version 300 es +void main() { + // The corners are (-1,-1), (3,-1) and (-1,3), which cover the whole buffer. + vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); +} +)"; + +const char* kFragmentShader = R"( +#version 300 es +precision highp float; +uniform sampler2D source; +uniform vec2 size; +uniform vec4 crop; +out vec4 outColor; +void main() { + // The pixels are stored top-down, so the fragment coordinate is flipped. + vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; + outColor = texture(source, crop.xy + p * crop.zw); +} +)"; + +constexpr int kWaitTimeoutMs = 20; + +void logShader(uint32_t object, bool program) { + int ok = 0; + if (program) + glGetProgramiv(object, GL_LINK_STATUS, &ok); + else + glGetShaderiv(object, GL_COMPILE_STATUS, &ok); + + if (ok) return; + + char info[512] = {}; + if (program) + glGetProgramInfoLog(object, int(sizeof(info)), nullptr, info); + else + glGetShaderInfoLog(object, int(sizeof(info)), nullptr, info); + + qWarning() << "WallpaperService:" << (program ? "linking" : "compiling") + << "failed:" << info; +} + +QVariantMap cropFor(const QVariantMap& crops, const QString& screen) { + QVariantMap crop = crops.value(screen).toMap(); + + if (crop.isEmpty()) { + crop.insert("x", 0.0); + crop.insert("y", 0.0); + crop.insert("width", 1.0); + crop.insert("height", 1.0); + crop.insert("zoom", 1.0); + } + + return crop; +} + +} // namespace + +WallpaperService::WallpaperService(QObject* parent) : QObject(parent) {} + +WallpaperService::~WallpaperService() { + m_stop = true; + wake(); + + if (m_thread.joinable()) m_thread.join(); + + for (auto* surface : m_surfaces) + delete surface; + m_surfaces.clear(); + + if (m_eglDisplay != EGL_NO_DISPLAY && m_context != EGL_NO_CONTEXT) { + if (eglMakeCurrent( + m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) { + if (m_texture) glDeleteTextures(1, &m_texture); + if (m_program) glDeleteProgram(m_program); + } + + eglDestroyContext(m_eglDisplay, m_context); + } + + if (m_queue) wl_event_queue_destroy(m_queue); + + if (m_wake[0] >= 0) { + close(m_wake[0]); + close(m_wake[1]); + } + + if (m_layerShell) m_layerShell->destroy(); + if (m_viewporter) m_viewporter->destroy(); + if (m_scaleManager) m_scaleManager->destroy(); + if (m_registry) wl_registry_destroy(m_registry); +} + +QString WallpaperService::path() const { + std::lock_guard lock(m_mutex); + return m_path; +} + +void WallpaperService::setPath(const QString& path) { + if (m_path == path) return; + + { + std::lock_guard lock(m_mutex); + m_path = path; + + m_forceDecode = true; + m_imageChanged = true; + } + + emit pathChanged(); + + start(); + queueAll(); +} + +void WallpaperService::addScreen(const QString& screen) { + { + std::lock_guard lock(m_mutex); + + if (m_screens.contains(screen)) return; + + m_screens.append(screen); + } + + start(); + + queueAll(); +} + +void WallpaperService::removeScreen(const QString& screen) { + { + std::lock_guard lock(m_mutex); + + if (!m_screens.removeOne(screen)) return; + } + + queueAll(); +} + +const QVariantMap& WallpaperService::crops() const { + std::lock_guard lock(m_mutex); + return m_crops; +} + +void WallpaperService::setCrops(const QVariantMap& crops) { + { + std::lock_guard lock(m_mutex); + + if (m_crops == crops) return; + + m_crops = crops; + } + + emit cropsChanged(); + queueAll(); +} + +void WallpaperService::setCrop(const QString& screen, const QVariantMap& crop) { + WallpaperSurface* surface = nullptr; + + { + std::lock_guard lock(m_mutex); + + if (m_crops.value(screen) == crop) return; + + m_crops.insert(screen, crop); + + for (auto* candidate : m_surfaces) { + if (candidate->screen() == screen) { + surface = candidate; + break; + } + } + } + + emit cropsChanged(); + + if (surface) queue(surface); +} + +void WallpaperService::redraw() { + queueAll(); +} + +void WallpaperService::start() { + { + std::lock_guard lock(m_mutex); + + if (started || m_path.isEmpty() || m_screens.isEmpty()) return; + + started = true; + } + + auto* interface = + qApp->nativeInterface(); + if (!interface) { + qWarning() << "WallpaperService: this is not a Wayland session"; + return; + } + + m_display = interface->display(); + m_compositor = interface->compositor(); + if (!m_display || !m_compositor) return; + + m_queue = wl_display_create_queue(m_display); + if (!m_queue) return; + + if (pipe2(m_wake, O_CLOEXEC) < 0) { + qWarning() << "WallpaperService: could not make the pipe to wake on"; + return; + } + + bindGlobals(); + + if (!setupGl()) return; + + m_drawing = true; + m_thread = std::thread([this] { loop(); }); + + wake(); +} + +void WallpaperService::wake() { + if (m_wake[1] < 0) return; + + char byte = 0; + + if (::write(m_wake[1], &byte, 1) < 0) + qWarning() << "WallpaperService: could not wake the drawing thread"; +} + +void WallpaperService::queue(WallpaperSurface* surface) { + { + std::lock_guard lock(m_mutex); + m_pending.push_back(surface); + } + + wake(); +} + +void WallpaperService::queueAll() { + { + std::lock_guard lock(m_mutex); + + for (auto* surface : m_surfaces) + m_pending.push_back(surface); + } + + wake(); +} + +void WallpaperService::loop() { + while (!m_stop) { + present(); + + if (wl_display_prepare_read(m_display) == 0) { + wl_display_flush(m_display); + + struct pollfd fds[2] = { + {wl_display_get_fd(m_display), POLLIN, 0}, + {m_wake[0], POLLIN, 0}, + }; + + int ready = poll(fds, 2, kWaitTimeoutMs); + + if (ready > 0 && fds[0].revents) { + wl_display_read_events(m_display); + continue; + } + + if (ready > 0 && fds[1].revents) { + char byte = 0; + ::read(m_wake[0], &byte, 1); + } + + wl_display_cancel_read(m_display); + continue; + } + + struct pollfd fds[1] = {{m_wake[0], POLLIN, 0}}; + + if (poll(fds, 1, kWaitTimeoutMs) <= 0) continue; + + char byte = 0; + ::read(m_wake[0], &byte, 1); + } +} + +void WallpaperService::present() { + wl_display_dispatch_queue_pending(m_display, m_queue); + + addSurfaces(); + + ensureDecodeSize(); + + std::vector pending; + { + std::lock_guard lock(m_mutex); + pending.swap(m_pending); + } + + for (auto* surface : pending) + drawSurface(surface); +} + +void WallpaperService::addSurfaces() { + if (!started || !m_layerShell || !m_viewporter || !m_scaleManager) return; + + QList gone; + QList needed; + + { + std::lock_guard lock(m_mutex); + + QList kept; + for (auto* surface : m_surfaces) { + if (m_screens.contains(surface->screen())) + kept.append(surface); + else + gone.append(surface); + } + + m_surfaces = kept; + + for (const auto& screen : m_screens) { + bool exists = false; + for (auto* surface : m_surfaces) { + if (surface->screen() == screen) { + exists = true; + break; + } + } + + if (!exists) needed.append(screen); + } + } + + for (auto* surface : gone) + delete surface; + + for (const auto& screen : needed) + addSurface(screen); +} + +void WallpaperService::registryGlobal( + void* data, + struct wl_registry* registry, + uint32_t name, + const char* interface, + uint32_t version) { + auto* service = static_cast(data); + + void* proxy = nullptr; + + if (strcmp(interface, "zwlr_layer_shell_v1") == 0) { + proxy = new QtWayland::zwlr_layer_shell_v1( + registry, int(name), int(version)); + } else if (strcmp(interface, "wp_viewporter") == 0) { + proxy = new QtWayland::wp_viewporter(registry, int(name), int(version)); + } else if (strcmp(interface, "wp_fractional_scale_manager_v1") == 0) { + proxy = new QtWayland::wp_fractional_scale_manager_v1( + registry, int(name), int(version)); + } else { + return; + } + + service->storeGlobal(interface, proxy); + + service->queueAll(); +} + +void WallpaperService::storeGlobal(const char* interface, void* proxy) { + std::lock_guard lock(m_mutex); + + if (strcmp(interface, "zwlr_layer_shell_v1") == 0) + m_layerShell = static_cast(proxy); + else if (strcmp(interface, "wp_viewporter") == 0) + m_viewporter = static_cast(proxy); + else if (strcmp(interface, "wp_fractional_scale_manager_v1") == 0) + m_scaleManager = + static_cast(proxy); +} + +void WallpaperService::bindGlobals() { + m_registry = wl_display_get_registry(m_display); + + static const struct wl_registry_listener listener = { + registryGlobal, + nullptr, + }; + + wl_registry_add_listener(m_registry, &listener, this); +} + +bool WallpaperService::setupGl() { + const char* extensions = eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS); + if (extensions && strstr(extensions, "EGL_EXT_platform_wayland")) { + m_eglDisplay = + eglGetPlatformDisplay(EGL_PLATFORM_WAYLAND_EXT, m_display, nullptr); + } else { + m_eglDisplay = + eglGetDisplay(reinterpret_cast(m_display)); + } + + if (m_eglDisplay == EGL_NO_DISPLAY) { + qWarning() << "WallpaperService: no EGL display"; + return false; + } + + EGLint major = 0, minor = 0; + if (!eglInitialize(m_eglDisplay, &major, &minor)) { + qWarning() << "WallpaperService: eglInitialize failed"; + return false; + } + + const EGLint configAttributes[] = { + EGL_SURFACE_TYPE, + EGL_WINDOW_BIT, + EGL_RENDERABLE_TYPE, + EGL_OPENGL_ES2_BIT, + EGL_RED_SIZE, + 8, + EGL_GREEN_SIZE, + 8, + EGL_BLUE_SIZE, + 8, + EGL_ALPHA_SIZE, + 8, + EGL_NONE}; + + EGLint count = 0; + if (!eglChooseConfig(m_eglDisplay, configAttributes, &m_config, 1, &count) || + count == 0) { + qWarning() << "WallpaperService: no EGL config for window surfaces"; + return false; + } + + const EGLint contextAttributes[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_NONE}; + m_context = eglCreateContext( + m_eglDisplay, m_config, EGL_NO_CONTEXT, contextAttributes); + if (m_context == EGL_NO_CONTEXT) { + qWarning() << "WallpaperService: eglCreateContext failed"; + return false; + } + + return true; +} + +uint32_t WallpaperService::createProgram() { + uint32_t vertex = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(vertex, 1, &kVertexShader, nullptr); + glCompileShader(vertex); + logShader(vertex, false); + + uint32_t fragment = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(fragment, 1, &kFragmentShader, nullptr); + glCompileShader(fragment); + logShader(fragment, false); + + uint32_t program = glCreateProgram(); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + glLinkProgram(program); + logShader(program, true); + + if (glGetUniformLocation(program, "size") < 0 || + glGetUniformLocation(program, "crop") < 0) { + qWarning() << "WallpaperService: the program does not have the " + "uniforms it needs"; + glDeleteProgram(program); + return 0; + } + + m_sizeUniform = glGetUniformLocation(program, "size"); + m_cropUniform = glGetUniformLocation(program, "crop"); + + glDeleteShader(vertex); + glDeleteShader(fragment); + return program; +} + +void WallpaperService::uploadImage() { + if (m_image.isNull()) return; + + if (m_texture == 0) { + glGenTextures(1, &m_texture); + glBindTexture(GL_TEXTURE_2D, m_texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + } else { + glBindTexture(GL_TEXTURE_2D, m_texture); + } + + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D( + GL_TEXTURE_2D, + 0, + GL_RGBA8, + m_image.width(), + m_image.height(), + 0, + GL_RGBA, + GL_UNSIGNED_BYTE, + m_image.constBits()); +} + +bool WallpaperService::decode(const QString& path) { + if (path.isEmpty()) return false; + + QImageReader reader(path); + reader.setAutoTransform(true); + + QSize size = reader.size(); + if (size.isValid()) m_sourceSize = size; + + if (!m_decodeSize.isEmpty() && !m_sourceSize.isEmpty()) + reader.setScaledSize( + m_sourceSize.scaled(m_decodeSize, Qt::KeepAspectRatioByExpanding)); + + QImage image = reader.read(); + if (image.isNull()) { + qWarning() << "WallpaperService: could not read the image" << path; + return false; + } + + m_image = image.convertToFormat(QImage::Format_RGBA8888); + return !m_image.isNull(); +} + +bool WallpaperService::ensureDecodeSize() { + QSize largest; + QString path; + + { + std::lock_guard lock(m_mutex); + + largest = largestBufferSize(); + if (largest.isEmpty()) return false; + + if (!m_forceDecode && !m_decodeSize.isEmpty() && + qint64(largest.width()) * largest.height() <= + qint64(m_decodeSize.width()) * m_decodeSize.height()) + return false; + + QSize target = + m_sourceSize.isEmpty() + ? largest + : m_sourceSize.scaled(largest, Qt::KeepAspectRatioByExpanding); + + if (!m_forceDecode && !m_image.isNull() && target == m_image.size()) + return false; + + m_forceDecode = false; + m_decodeSize = largest; + path = m_path; + } + + if (!decode(path)) return false; + + { + std::lock_guard lock(m_mutex); + m_imageChanged = true; + } + + return true; +} + +QSize WallpaperService::largestBufferSize() { + QSize largest; + + for (auto* surface : m_surfaces) { + QSize buffer = surface->bufferSize(); + if (buffer.isEmpty()) continue; + + if (largest.isEmpty() || qint64(buffer.width()) * buffer.height() > + qint64(largest.width()) * largest.height()) + largest = buffer; + } + + return largest; +} + +QRectF WallpaperService::sourceRect( + const QVariantMap& crops, const QString& screen, const QSize& size) const { + QVariantMap crop = cropFor(crops, screen); + + qreal zoom = crop.value("zoom", 1.0).toDouble(); + if (zoom <= 0.0) + zoom = 1.0; + else if (zoom > 5.0) + zoom = 5.0; + + QRectF rect( + crop.value("x", 0.0).toDouble(), + crop.value("y", 0.0).toDouble(), + crop.value("width", 1.0).toDouble() / zoom, + crop.value("height", 1.0).toDouble() / zoom); + + if (size.isEmpty() || rect.isEmpty() || m_image.isNull()) return rect; + + qreal imageWidth = qreal(m_image.width()); + qreal imageHeight = qreal(m_image.height()); + + QRectF pixels( + rect.x() * imageWidth, + rect.y() * imageHeight, + rect.width() * imageWidth, + rect.height() * imageHeight); + + qreal targetRatio = qreal(size.width()) / qreal(size.height()); + qreal rectRatio = pixels.width() / pixels.height(); + + if (rectRatio > targetRatio) { + qreal newWidth = pixels.height() * targetRatio; + pixels.setX(pixels.x() + (pixels.width() - newWidth) / 2.0); + pixels.setWidth(newWidth); + } else if (rectRatio < targetRatio) { + qreal newHeight = pixels.width() / targetRatio; + pixels.setY(pixels.y() + (pixels.height() - newHeight) / 2.0); + pixels.setHeight(newHeight); + } + + return QRectF( + pixels.x() / imageWidth, + pixels.y() / imageHeight, + pixels.width() / imageWidth, + pixels.height() / imageHeight); +} + +void WallpaperService::drawSurface(WallpaperSurface* surface) { + EGLSurface target = surface->eglSurface(); + if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE || + surface->bufferSize().isEmpty()) + return; + + if (!eglMakeCurrent(m_eglDisplay, target, target, m_context)) { + qWarning() << "WallpaperService: the context cannot be made current"; + return; + } + + QVariantMap crops; + QSize size; + bool upload = false; + + { + std::lock_guard lock(m_mutex); + + crops = m_crops; + size = surface->bufferSize(); + + upload = m_imageChanged; + m_imageChanged = false; + } + + if (size.isEmpty()) return; + + if (m_program == 0) m_program = createProgram(); + if (m_texture == 0 || upload) uploadImage(); + + if (m_program == 0 || m_texture == 0 || m_image.isNull()) return; + + glUseProgram(m_program); + glBindTexture(GL_TEXTURE_2D, m_texture); + + glUniform2f( + GLint(m_sizeUniform), GLfloat(size.width()), GLfloat(size.height())); + + QRectF rect = sourceRect(crops, surface->screen(), size); + glUniform4f( + GLint(m_cropUniform), + GLfloat(rect.x()), + GLfloat(rect.y()), + GLfloat(rect.width()), + GLfloat(rect.height())); + + glViewport(0, 0, size.width(), size.height()); + glDrawArrays(GL_TRIANGLES, 0, 3); + + if (!eglSwapBuffers(m_eglDisplay, target)) + qWarning() << "WallpaperService: eglSwapBuffers failed"; +} + +void WallpaperService::addSurface(const QString& screen) { + if (!m_layerShell || !m_viewporter || !m_scaleManager) return; + + QScreen* qscreen = nullptr; + for (auto* candidate : QGuiApplication::screens()) { + if (candidate->name() == screen) { + qscreen = candidate; + break; + } + } + + if (!qscreen) { + qWarning() << "WallpaperService:" << screen << "is not a screen"; + return; + } + + auto* waylandScreen = + dynamic_cast(qscreen->handle()); + if (!waylandScreen || !waylandScreen->output()) { + qWarning() << "WallpaperService:" << screen + << "is not a Wayland screen"; + return; + } + + wl_surface* wlSurface = wl_compositor_create_surface(m_compositor); + if (!wlSurface) return; + + auto* surface = + new WallpaperSurface(this, wlSurface, waylandScreen->output(), screen); + + { + std::lock_guard lock(m_mutex); + m_surfaces.append(surface); + } +} + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperservice.hpp b/Plugins/ZShell/Internal/wallpaperservice.hpp new file mode 100644 index 0000000..a68d84d --- /dev/null +++ b/Plugins/ZShell/Internal/wallpaperservice.hpp @@ -0,0 +1,147 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "wallpapersurface.hpp" + +namespace ZShell::internal { + +class WallpaperSurface; + +class WallpaperService : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED) + Q_PROPERTY(QVariantMap crops READ crops WRITE setCrops NOTIFY cropsChanged) + + public: + explicit WallpaperService(QObject* parent = nullptr); + ~WallpaperService() override; + + [[nodiscard]] QString path() const; + void setPath(const QString& path); + + void addScreen(const QString& screen); + void removeScreen(const QString& screen); + + [[nodiscard]] const QVariantMap& crops() const; + void setCrops(const QVariantMap& crops); + + Q_INVOKABLE void redraw(); + + Q_INVOKABLE void setCrop(const QString& screen, const QVariantMap& crop); + + signals: + void pathChanged(); + void cropsChanged(); + + private: + friend class WallpaperSurface; + + void bindGlobals(); + static void registryGlobal( + void* data, + struct wl_registry* registry, + uint32_t name, + const char* interface, + uint32_t version); + + void storeGlobal(const char* interface, void* proxy); + + [[nodiscard]] bool setupGl(); + [[nodiscard]] uint32_t createProgram(); + + void uploadImage(); + + bool decode(const QString& path); + + bool ensureDecodeSize(); + + [[nodiscard]] QSize largestBufferSize(); + + void queue(WallpaperSurface* surface); + void queueAll(); + + void startDrawing(); + void loop(); + + void present(); + + void drawSurface(WallpaperSurface* surface); + + void wake(); + + [[nodiscard]] wl_event_queue* eventQueue() const { return m_queue; } + + [[nodiscard]] QRectF sourceRect( + const QVariantMap& crops, + const QString& screen, + const QSize& size) const; + + void start(); + + void addSurfaces(); + void addSurface(const QString& screen); + + QString m_path; + QStringList m_screens; + QVariantMap m_crops; + + QImage m_image; + + QSize m_sourceSize; + QSize m_decodeSize; + + wl_display* m_display = nullptr; + wl_registry* m_registry = nullptr; + wl_compositor* m_compositor = nullptr; + QtWayland::zwlr_layer_shell_v1* m_layerShell = nullptr; + QtWayland::wp_viewporter* m_viewporter = nullptr; + QtWayland::wp_fractional_scale_manager_v1* m_scaleManager = nullptr; + + EGLDisplay m_eglDisplay = EGL_NO_DISPLAY; + EGLConfig m_config = nullptr; + EGLContext m_context = EGL_NO_CONTEXT; + uint32_t m_texture = 0; + uint32_t m_program = 0; + int m_sizeUniform = -1; + int m_cropUniform = -1; + + QList m_surfaces; + + std::thread m_thread; + mutable std::recursive_mutex m_mutex; + std::vector m_pending; + wl_event_queue* m_queue = nullptr; + + int m_wake[2] = {-1, -1}; + + bool m_stop = false; + bool m_drawing = false; + + bool started = false; + + bool m_forceDecode = false; + bool m_imageChanged = false; +}; + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpapersurface.cpp b/Plugins/ZShell/Internal/wallpapersurface.cpp new file mode 100644 index 0000000..8fe99e6 --- /dev/null +++ b/Plugins/ZShell/Internal/wallpapersurface.cpp @@ -0,0 +1,147 @@ +#include "wallpapersurface.hpp" + +#include + +#include + +#include "wallpaperservice.hpp" + +namespace ZShell::internal { + +const QString kNamespace = "ZShell-Wallpaper"; + +void setQueue(void* proxy, struct wl_event_queue* queue) { + wl_proxy_set_queue(static_cast(proxy), queue); +} + +WallpaperSurface::WallpaperSurface( + WallpaperService* service, + wl_surface* surface, + wl_output* output, + const QString& screen) + : m_service(service), m_screen(screen), m_surface(surface) { + if (!surface) { + qWarning() << "WallpaperSurface: no surface for" << screen; + return; + } + + setQueue(m_surface, m_service->eventQueue()); + + auto* layer = m_service->m_layerShell->get_layer_surface( + m_surface, + output, + QtWayland::zwlr_layer_shell_v1::layer_background, + kNamespace); + setQueue(layer, m_service->eventQueue()); + QtWayland::zwlr_layer_surface_v1::init(layer); + + set_size(0, 0); + set_anchor(anchor_top | anchor_bottom | anchor_left | anchor_right); + set_exclusive_zone(-1); + set_keyboard_interactivity(keyboard_interactivity_none); + set_margin(0, 0, 0, 0); + + auto* viewport = m_service->m_viewporter->get_viewport(m_surface); + setQueue(viewport, m_service->eventQueue()); + QtWayland::wp_viewport::init(viewport); + + auto* scale = m_service->m_scaleManager->get_fractional_scale(m_surface); + setQueue(scale, m_service->eventQueue()); + QtWayland::wp_fractional_scale_v1::init(scale); + + wl_surface_commit(m_surface); +} + +WallpaperSurface::~WallpaperSurface() { + if (m_eglSurface != EGL_NO_SURFACE) + eglDestroySurface(m_service->m_eglDisplay, m_eglSurface); + + if (m_window) wl_egl_window_destroy(m_window); + + if (QtWayland::wp_viewport::isInitialized()) + QtWayland::wp_viewport::destroy(); + if (QtWayland::wp_fractional_scale_v1::isInitialized()) + QtWayland::wp_fractional_scale_v1::destroy(); + if (QtWayland::zwlr_layer_surface_v1::isInitialized()) + QtWayland::zwlr_layer_surface_v1::destroy(); + + if (m_surface) wl_surface_destroy(m_surface); +} + +void WallpaperSurface::redraw() { + m_service->queue(this); +} + +void WallpaperSurface::zwlr_layer_surface_v1_configure( + uint32_t serial, uint32_t width, uint32_t height) { + ack_configure(serial); + + if (width == 0 || height == 0) return; + + m_size = QSize(int(width), int(height)); + + if (!configured) configured = true; + + updateBuffer(); +} + +void WallpaperSurface::wp_fractional_scale_v1_preferred_scale(uint32_t scale) { + if (scale == 0) return; + + qreal newScale = qreal(scale) / 120.0; + if (qFuzzyCompare(newScale, m_scale)) return; + + m_scale = newScale; + updateBuffer(); +} + +void WallpaperSurface::zwlr_layer_surface_v1_closed() {} + +void WallpaperSurface::updateBuffer() { + if (m_size.isEmpty()) return; + + QSize buffer = QSize( + int(std::llround(m_size.width() * m_scale)), + int(std::llround(m_size.height() * m_scale))); + + if (buffer.isEmpty()) return; + + if (m_bufferSize != buffer) { + m_bufferSize = buffer; + + if (m_window) { + wl_egl_window_resize( + m_window, buffer.width(), buffer.height(), 0, 0); + } else { + m_window = + wl_egl_window_create(m_surface, buffer.width(), buffer.height()); + if (!m_window) { + qWarning() + << "WallpaperSurface: could not make a window surface for" + << m_screen; + return; + } + } + + if (m_eglSurface == EGL_NO_SURFACE) { + m_eglSurface = eglCreateWindowSurface( + m_service->m_eglDisplay, + m_service->m_config, + reinterpret_cast(m_window), + nullptr); + if (m_eglSurface == EGL_NO_SURFACE) { + qWarning() + << "WallpaperSurface: eglCreateWindowSurface failed for" + << m_screen; + return; + } + } + } + + if (QtWayland::wp_viewport::isInitialized()) + set_destination(m_size.width(), m_size.height()); + + m_service->queue(this); +} + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpapersurface.hpp b/Plugins/ZShell/Internal/wallpapersurface.hpp new file mode 100644 index 0000000..d534c99 --- /dev/null +++ b/Plugins/ZShell/Internal/wallpapersurface.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace ZShell::internal { + +class WallpaperService; + +class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1, + public QtWayland::wp_viewport, + public QtWayland::wp_fractional_scale_v1 { + public: + WallpaperSurface( + WallpaperService* service, + wl_surface* surface, + wl_output* output, + const QString& screen); + ~WallpaperSurface() override; + + [[nodiscard]] const QString& screen() const { return m_screen; } + + [[nodiscard]] const QSize& size() const { return m_size; } + [[nodiscard]] const QSize& bufferSize() const { return m_bufferSize; } + [[nodiscard]] EGLSurface eglSurface() const { return m_eglSurface; } + + void redraw(); + + private: + void zwlr_layer_surface_v1_configure( + uint32_t serial, uint32_t width, uint32_t height) override; + void zwlr_layer_surface_v1_closed() override; + void wp_fractional_scale_v1_preferred_scale(uint32_t scale) override; + + void updateBuffer(); + + WallpaperService* m_service; + QString m_screen; + wl_surface* m_surface; + wl_egl_window* m_window = nullptr; + EGLSurface m_eglSurface = EGL_NO_SURFACE; + + QSize m_size; + QSize m_bufferSize; + qreal m_scale = 1.0; + + bool configured = false; +}; + +} // namespace ZShell::internal diff --git a/Plugins/cmake/wayland-protocol.cmake b/Plugins/cmake/wayland-protocol.cmake new file mode 100644 index 0000000..76b6989 --- /dev/null +++ b/Plugins/cmake/wayland-protocol.cmake @@ -0,0 +1,58 @@ +find_program(QTWAYLANDSCANNER_EXECUTABLE qtwaylandscanner REQUIRED) +find_program(WAYLAND_SCANNER_EXECUTABLE wayland-scanner REQUIRED) + +pkg_get_variable(WAYLAND_PROTOCOLS wayland-protocols pkgdatadir) +if(NOT WAYLAND_PROTOCOLS) + message(FATAL_ERROR "wayland-protocols not found") +endif() + +function(wl_proto arg_TARGET arg_PROTOCOL arg_XML_DIR) + set(out_dir "${CMAKE_BINARY_DIR}/wayland-protocols/${arg_TARGET}") + file(MAKE_DIRECTORY "${out_dir}") + + set(xml "${arg_XML_DIR}/${arg_PROTOCOL}.xml") + + add_custom_command( + OUTPUT "${out_dir}/wayland-${arg_PROTOCOL}-client-protocol.h" + "${out_dir}/wayland-${arg_PROTOCOL}.c" + COMMAND ${WAYLAND_SCANNER_EXECUTABLE} client-header "${xml}" + "${out_dir}/wayland-${arg_PROTOCOL}-client-protocol.h" + COMMAND ${WAYLAND_SCANNER_EXECUTABLE} private-code "${xml}" + "${out_dir}/wayland-${arg_PROTOCOL}.c" + DEPENDS "${xml}" + COMMENT "Generating ${arg_PROTOCOL} protocol files" + ) + + add_custom_command( + OUTPUT "${out_dir}/qwayland-${arg_PROTOCOL}.h" + "${out_dir}/qwayland-${arg_PROTOCOL}.cpp" + COMMAND ${QTWAYLANDSCANNER_EXECUTABLE} client-header "${xml}" + > "${out_dir}/qwayland-${arg_PROTOCOL}.h" + COMMAND ${QTWAYLANDSCANNER_EXECUTABLE} client-code "${xml}" + > "${out_dir}/qwayland-${arg_PROTOCOL}.cpp" + DEPENDS "${xml}" "${out_dir}/wayland-${arg_PROTOCOL}-client-protocol.h" + COMMENT "Generating ${arg_PROTOCOL} client wrapper" + ) + + add_library( + ${arg_TARGET} + STATIC + "${out_dir}/wayland-${arg_PROTOCOL}.c" + "${out_dir}/qwayland-${arg_PROTOCOL}.cpp" + ) + set_source_files_properties( + "${out_dir}/qwayland-${arg_PROTOCOL}.cpp" + PROPERTIES COMPILE_OPTIONS + "-Wno-old-style-cast;-Wno-sign-conversion;-Wno-conversion;-Wno-shadow" + ) + set_target_properties(${arg_TARGET} PROPERTIES POSITION_INDEPENDENT_CODE ON) + target_include_directories(${arg_TARGET} PUBLIC "${out_dir}") + target_link_libraries( + ${arg_TARGET} + PUBLIC + Qt::Core + Qt::WaylandClient + Qt::WaylandClientPrivate + PkgConfig::WAYLAND_CLIENT + ) +endfunction() diff --git a/shell.qml b/shell.qml index 75bde5a..57f6e6b 100644 --- a/shell.qml +++ b/shell.qml @@ -3,7 +3,6 @@ // @ pragma DefaultEnv QSG_RHI_BACKEND=vulkan //@ pragma DefaultEnv QSG_NO_VSYNC=1 //@ pragma DefaultEnv QS_NO_RELOAD_POPUP=1 -//@ pragma DefaultEnv QT_SCALE_FACTOR_ROUNDING_POLICY=Round //@ pragma DefaultEnv QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 //@ pragma DropExpensiveFonts import Quickshell @@ -25,9 +24,6 @@ ShellRoot { Drawers { } - Wallpaper { - } - Lock { id: lock } @@ -48,4 +44,7 @@ ShellRoot { component: BatteryService { } } + + ServiceLoader { + } } From 11e94fbb4b8ee1635a44cf1d1c0a0403acda9fe6 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 5 Oct 2026 19:31:15 +0200 Subject: [PATCH 70/90] Merge branch 'main' into feat/llm-chat-tab --- Modules/ServiceLoader.qml | 9 + Modules/Wallpaper/WallBackground.qml | 51 -- Modules/Wallpaper/Wallpaper.qml | 67 +- Plugins/CMakeLists.txt | 22 +- Plugins/ZShell/Internal/CMakeLists.txt | 17 + .../protocols/wlr-layer-shell-unstable-v1.xml | 407 ++++++++++ Plugins/ZShell/Internal/wallpaperobject.cpp | 83 ++ Plugins/ZShell/Internal/wallpaperobject.hpp | 82 ++ Plugins/ZShell/Internal/wallpaperservice.cpp | 749 ++++++++++++++++++ Plugins/ZShell/Internal/wallpaperservice.hpp | 147 ++++ Plugins/ZShell/Internal/wallpapersurface.cpp | 147 ++++ Plugins/ZShell/Internal/wallpapersurface.hpp | 58 ++ Plugins/cmake/wayland-protocol.cmake | 58 ++ shell.qml | 7 +- 14 files changed, 1816 insertions(+), 88 deletions(-) create mode 100644 Modules/ServiceLoader.qml delete mode 100644 Modules/Wallpaper/WallBackground.qml create mode 100644 Plugins/ZShell/Internal/protocols/wlr-layer-shell-unstable-v1.xml create mode 100644 Plugins/ZShell/Internal/wallpaperobject.cpp create mode 100644 Plugins/ZShell/Internal/wallpaperobject.hpp create mode 100644 Plugins/ZShell/Internal/wallpaperservice.cpp create mode 100644 Plugins/ZShell/Internal/wallpaperservice.hpp create mode 100644 Plugins/ZShell/Internal/wallpapersurface.cpp create mode 100644 Plugins/ZShell/Internal/wallpapersurface.hpp create mode 100644 Plugins/cmake/wayland-protocol.cmake diff --git a/Modules/ServiceLoader.qml b/Modules/ServiceLoader.qml new file mode 100644 index 0000000..01a2092 --- /dev/null +++ b/Modules/ServiceLoader.qml @@ -0,0 +1,9 @@ +import Quickshell +import QtQuick +import qs.Modules.Wallpaper + +Scope { + Component.onCompleted: { + Wallpaper; + } +} diff --git a/Modules/Wallpaper/WallBackground.qml b/Modules/Wallpaper/WallBackground.qml deleted file mode 100644 index 76e44dd..0000000 --- a/Modules/Wallpaper/WallBackground.qml +++ /dev/null @@ -1,51 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import Quickshell.Hyprland -import QtQuick -import qs.Components -import qs.Helpers -import ZShell.Internal - -Item { - id: root - - readonly property var displayData: Wallpapers.getCrop(screen.name) - required property ShellScreen screen - property size screenResolution: Qt.size(screen.width * screenScale, screen.height * screenScale) - property real screenScale: Hyprland.monitorFor(screen).scale - - anchors.fill: parent - - Component.onCompleted: Hyprland.refreshMonitors() - - WallpaperImage { - id: img - - anchors.fill: parent - cropHeight: root.displayData?.height ?? 1.0 - cropWidth: root.displayData?.width ?? 1.0 - cropX: root.displayData?.x ?? 0.0 - cropY: root.displayData?.y ?? 0.0 - fadeDuration: 150 - screenResolution: root.screenResolution - source: Wallpapers.current - - Behavior on cropHeight { - Anim { - } - } - Behavior on cropWidth { - Anim { - } - } - Behavior on cropX { - Anim { - } - } - Behavior on cropY { - Anim { - } - } - } -} diff --git a/Modules/Wallpaper/Wallpaper.qml b/Modules/Wallpaper/Wallpaper.qml index 02fe54b..563e4e8 100644 --- a/Modules/Wallpaper/Wallpaper.qml +++ b/Modules/Wallpaper/Wallpaper.qml @@ -1,46 +1,49 @@ +pragma Singleton + import Quickshell import QtQuick -import Quickshell.Wayland -import ZShell.Config -import qs.Modules.DesktopIcons +import ZShell.Internal +import qs.Components +import qs.Helpers -Loader { - active: Config.background.enabled - asynchronous: false +Singleton { + id: root - sourceComponent: Variants { + WallpaperService { + id: wallpaper + + path: Wallpapers.current + } + + Variants { model: Quickshell.screens - PanelWindow { - id: root - + WallpaperObject { + readonly property var crops: Wallpapers.getCrop(modelData.name) required property var modelData - WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: WlrLayer.Background - WlrLayershell.namespace: "ZShell-Wallpaper" - color: "transparent" - screen: modelData - surfaceFormat.opaque: true + cropHeight: crops?.height ?? 1.0 + cropWidth: crops?.width ?? 1.0 + cropX: crops?.x ?? 0.0 + cropY: crops?.y ?? 0.0 + screen: modelData.name + service: wallpaper + zoom: crops?.zoom ?? 1.0 - anchors { - bottom: true - left: true - right: true - top: true + Behavior on cropHeight { + Anim { + } } - - WallBackground { - screen: root.screen + Behavior on cropWidth { + Anim { + } } - - Loader { - id: loader - - active: Config.general.desktopIcons - anchors.fill: parent - - sourceComponent: DesktopIcons { + Behavior on cropX { + Anim { + } + } + Behavior on cropY { + Anim { } } } diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 5c302ae..bca6758 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -1,5 +1,24 @@ -find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick QuickPrivate Concurrent Sql Network DBus CanvasPainter) +find_package(Qt6 REQUIRED COMPONENTS + ShaderTools + Core + Qml + Gui + GuiPrivate + Quick + QuickPrivate + Concurrent + Sql + Network + DBus + CanvasPainter + WaylandClient + WaylandClientPrivate +) find_package(PkgConfig REQUIRED) +pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client) +pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl) +pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl) +pkg_check_modules(GL REQUIRED IMPORTED_TARGET gl) 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) @@ -17,6 +36,7 @@ qt_standard_project_setup(REQUIRES 6.9) include(cmake/pch.cmake) include(cmake/qml-module.cmake) +include(cmake/wayland-protocol.cmake) add_library(zshell-util INTERFACE) target_include_directories(zshell-util INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index f789bba..62911fd 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -1,3 +1,9 @@ +wl_proto(wlp-layer-shell wlr-layer-shell-unstable-v1 "${CMAKE_CURRENT_SOURCE_DIR}/protocols") +wl_proto(wlp-viewporter viewporter "${WAYLAND_PROTOCOLS}/stable/viewporter") +wl_proto(wlp-fractional-scale fractional-scale-v1 "${WAYLAND_PROTOCOLS}/staging/fractional-scale") + +wl_proto(wlp-xdg-shell xdg-shell "${WAYLAND_PROTOCOLS}/stable/xdg-shell") + qml_module(ZShell-internal URI ZShell.Internal INCLUDE_PREFIX internal @@ -9,6 +15,9 @@ qml_module(ZShell-internal circularbuffer.hpp circularbuffer.cpp arcgauge.hpp arcgauge.cpp wallpaperimage.hpp wallpaperimage.cpp + wallpaperservice.hpp wallpaperservice.cpp + wallpapersurface.hpp wallpapersurface.cpp + wallpaperobject.hpp wallpaperobject.cpp lidwatcher.hpp lidwatcher.cpp visualizerbars.hpp visualizerbars.cpp linearindicatormanager.hpp linearindicatormanager.cpp @@ -23,4 +32,12 @@ qml_module(ZShell-internal Qt::Network Qt::DBus Qt::CanvasPainter + Qt::GuiPrivate + wlp-layer-shell + wlp-xdg-shell + wlp-viewporter + wlp-fractional-scale + PkgConfig::EGL + PkgConfig::WAYLAND_EGL + PkgConfig::GL ) diff --git a/Plugins/ZShell/Internal/protocols/wlr-layer-shell-unstable-v1.xml b/Plugins/ZShell/Internal/protocols/wlr-layer-shell-unstable-v1.xml new file mode 100644 index 0000000..283f391 --- /dev/null +++ b/Plugins/ZShell/Internal/protocols/wlr-layer-shell-unstable-v1.xml @@ -0,0 +1,407 @@ + + + + Copyright © 2017 Drew DeVault + + Permission to use, copy, modify, distribute, and sell this + software and its documentation for any purpose is hereby granted + without fee, provided that the above copyright notice appear in + all copies and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of + the copyright holders not be used in advertising or publicity + pertaining to distribution of the software without specific, + written prior permission. The copyright holders make no + representations about the suitability of this software for any + purpose. It is provided "as is" without express or implied + warranty. + + THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS + SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY + SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF + THIS SOFTWARE. + + + + + Clients can use this interface to assign the surface_layer role to + wl_surfaces. Such surfaces are assigned to a "layer" of the output and + rendered with a defined z-depth respective to each other. They may also be + anchored to the edges and corners of a screen and specify input handling + semantics. This interface should be suitable for the implementation of + many desktop shell components, and a broad number of other applications + that interact with the desktop. + + + + + Create a layer surface for an existing surface. This assigns the role of + layer_surface, or raises a protocol error if another role is already + assigned. + + Creating a layer surface from a wl_surface which has a buffer attached + or committed is a client error, and any attempts by a client to attach + or manipulate a buffer prior to the first layer_surface.configure call + must also be treated as errors. + + After creating a layer_surface object and setting it up, the client + must perform an initial commit without any buffer attached. + The compositor will reply with a layer_surface.configure event. + The client must acknowledge it and is then allowed to attach a buffer + to map the surface. + + You may pass NULL for output to allow the compositor to decide which + output to use. Generally this will be the one that the user most + recently interacted with. + + Clients can specify a namespace that defines the purpose of the layer + surface. + + + + + + + + + + + + + + + + + These values indicate which layers a surface can be rendered in. They + are ordered by z depth, bottom-most first. Traditional shell surfaces + will typically be rendered between the bottom and top layers. + Fullscreen shell surfaces are typically rendered at the top layer. + Multiple surfaces can share a single layer, and ordering within a + single layer is undefined. + + + + + + + + + + + + + This request indicates that the client will not use the layer_shell + object any more. Objects that have been created through this instance + are not affected. + + + + + + + An interface that may be implemented by a wl_surface, for surfaces that + are designed to be rendered as a layer of a stacked desktop-like + environment. + + Layer surface state (layer, size, anchor, exclusive zone, + margin, interactivity) is double-buffered, and will be applied at the + time wl_surface.commit of the corresponding wl_surface is called. + + Attaching a null buffer to a layer surface unmaps it. + + Unmapping a layer_surface means that the surface cannot be shown by the + compositor until it is explicitly mapped again. The layer_surface + returns to the state it had right after layer_shell.get_layer_surface. + The client can re-map the surface by performing a commit without any + buffer attached, waiting for a configure event and handling it as usual. + + + + + Sets the size of the surface in surface-local coordinates. The + compositor will display the surface centered with respect to its + anchors. + + If you pass 0 for either value, the compositor will assign it and + inform you of the assignment in the configure event. You must set your + anchor to opposite edges in the dimensions you omit; not doing so is a + protocol error. Both values are 0 by default. + + Size is double-buffered, see wl_surface.commit. + + + + + + + + Requests that the compositor anchor the surface to the specified edges + and corners. If two orthogonal edges are specified (e.g. 'top' and + 'left'), then the anchor point will be the intersection of the edges + (e.g. the top left corner of the output); otherwise the anchor point + will be centered on that edge, or in the center if none is specified. + + Anchor is double-buffered, see wl_surface.commit. + + + + + + + Requests that the compositor avoids occluding an area with other + surfaces. The compositor's use of this information is + implementation-dependent - do not assume that this region will not + actually be occluded. + + A positive value is only meaningful if the surface is anchored to one + edge or an edge and both perpendicular edges. If the surface is not + anchored, anchored to only two perpendicular edges (a corner), anchored + to only two parallel edges or anchored to all edges, a positive value + will be treated the same as zero. + + A positive zone is the distance from the edge in surface-local + coordinates to consider exclusive. + + Surfaces that do not wish to have an exclusive zone may instead specify + how they should interact with surfaces that do. If set to zero, the + surface indicates that it would like to be moved to avoid occluding + surfaces with a positive exclusive zone. If set to -1, the surface + indicates that it would not like to be moved to accommodate for other + surfaces, and the compositor should extend it all the way to the edges + it is anchored to. + + For example, a panel might set its exclusive zone to 10, so that + maximized shell surfaces are not shown on top of it. A notification + might set its exclusive zone to 0, so that it is moved to avoid + occluding the panel, but shell surfaces are shown underneath it. A + wallpaper or lock screen might set their exclusive zone to -1, so that + they stretch below or over the panel. + + The default value is 0. + + Exclusive zone is double-buffered, see wl_surface.commit. + + + + + + + Requests that the surface be placed some distance away from the anchor + point on the output, in surface-local coordinates. Setting this value + for edges you are not anchored to has no effect. + + The exclusive zone includes the margin. + + Margin is double-buffered, see wl_surface.commit. + + + + + + + + + + Types of keyboard interaction possible for layer shell surfaces. The + rationale for this is twofold: (1) some applications are not interested + in keyboard events and not allowing them to be focused can improve the + desktop experience; (2) some applications will want to take exclusive + keyboard focus. + + + + + This value indicates that this surface is not interested in keyboard + events and the compositor should never assign it the keyboard focus. + + This is the default value, set for newly created layer shell surfaces. + + This is useful for e.g. desktop widgets that display information or + only have interaction with non-keyboard input devices. + + + + + Request exclusive keyboard focus if this surface is above the shell surface layer. + + For the top and overlay layers, the seat will always give + exclusive keyboard focus to the top-most layer which has keyboard + interactivity set to exclusive. If this layer contains multiple + surfaces with keyboard interactivity set to exclusive, the compositor + determines the one receiving keyboard events in an implementation- + defined manner. In this case, no guarantee is made when this surface + will receive keyboard focus (if ever). + + For the bottom and background layers, the compositor is allowed to use + normal focus semantics. + + This setting is mainly intended for applications that need to ensure + they receive all keyboard events, such as a lock screen or a password + prompt. + + + + + This requests the compositor to allow this surface to be focused and + unfocused by the user in an implementation-defined manner. The user + should be able to unfocus this surface even regardless of the layer + it is on. + + Typically, the compositor will want to use its normal mechanism to + manage keyboard focus between layer shell surfaces with this setting + and regular toplevels on the desktop layer (e.g. click to focus). + Nevertheless, it is possible for a compositor to require a special + interaction to focus or unfocus layer shell surfaces (e.g. requiring + a click even if focus follows the mouse normally, or providing a + keybinding to switch focus between layers). + + This setting is mainly intended for desktop shell components (e.g. + panels) that allow keyboard interaction. Using this option can allow + implementing a desktop shell that can be fully usable without the + mouse. + + + + + + + Set how keyboard events are delivered to this surface. By default, + layer shell surfaces do not receive keyboard events; this request can + be used to change this. + + This setting is inherited by child surfaces set by the get_popup + request. + + Layer surfaces receive pointer, touch, and tablet events normally. If + you do not want to receive them, set the input region on your surface + to an empty region. + + Keyboard interactivity is double-buffered, see wl_surface.commit. + + + + + + + This assigns an xdg_popup's parent to this layer_surface. This popup + should have been created via xdg_surface::get_popup with the parent set + to NULL, and this request must be invoked before committing the popup's + initial state. + + See the documentation of xdg_popup for more details about what an + xdg_popup is and how it is used. + + + + + + + When a configure event is received, if a client commits the + surface in response to the configure event, then the client + must make an ack_configure request sometime before the commit + request, passing along the serial of the configure event. + + If the client receives multiple configure events before it + can respond to one, it only has to ack the last configure event. + + A client is not required to commit immediately after sending + an ack_configure request - it may even ack_configure several times + before its next surface commit. + + A client may send multiple ack_configure requests before committing, but + only the last request sent before a commit indicates which configure + event the client really is responding to. + + + + + + + This request destroys the layer surface. + + + + + + The configure event asks the client to resize its surface. + + Clients should arrange their surface for the new states, and then send + an ack_configure request with the serial sent in this configure event at + some point before committing the new surface. + + The client is free to dismiss all but the last configure event it + received. + + The width and height arguments specify the size of the window in + surface-local coordinates. + + The size is a hint, in the sense that the client is free to ignore it if + it doesn't resize, pick a smaller size (to satisfy aspect ratio or + resize in steps of NxM pixels). If the client picks a smaller size and + is anchored to two opposite anchors (e.g. 'top' and 'bottom'), the + surface will be centered on this axis. + + If the width or height arguments are zero, it means the client should + decide its own window dimension. + + + + + + + + + The closed event is sent by the compositor when the surface will no + longer be shown. The output may have been destroyed or the user may + have asked for it to be removed. Further changes to the surface will be + ignored. The client should destroy the resource after receiving this + event, and create a new surface if they so choose. + + + + + + + + + + + + + + + + + + + + + + + Change the layer that the surface is rendered on. + + Layer is double-buffered, see wl_surface.commit. + + + + + + + + + Requests an edge for the exclusive zone to apply. The exclusive + edge will be automatically deduced from anchor points when possible, + but when the surface is anchored to a corner, it will be necessary + to set it explicitly to disambiguate, as it is not possible to deduce + which one of the two corner edges should be used. + + The edge must be one the surface is anchored to, otherwise the + invalid_exclusive_edge protocol error will be raised. + + + + + diff --git a/Plugins/ZShell/Internal/wallpaperobject.cpp b/Plugins/ZShell/Internal/wallpaperobject.cpp new file mode 100644 index 0000000..b11057e --- /dev/null +++ b/Plugins/ZShell/Internal/wallpaperobject.cpp @@ -0,0 +1,83 @@ +#include "wallpaperobject.hpp" + +namespace ZShell::internal { + +WallpaperObject::WallpaperObject(QObject* parent) : QObject(parent) {} + +WallpaperObject::~WallpaperObject() { + if (m_service && !m_screen.isEmpty()) m_service->removeScreen(m_screen); +} + +void WallpaperObject::registerScreen() { + if (!m_service || m_screen.isEmpty()) return; + + m_service->addScreen(m_screen); +} + +void WallpaperObject::setService(WallpaperService* service) { + if (m_service == service) return; + m_service = service; + emit serviceChanged(); + + registerScreen(); + push(); +} + +void WallpaperObject::setScreen(const QString& screen) { + if (m_screen == screen) return; + m_screen = screen; + emit screenChanged(); + + registerScreen(); + push(); +} + +void WallpaperObject::setCropX(qreal x) { + if (qFuzzyCompare(m_cropX, x)) return; + m_cropX = x; + emit cropXChanged(); + push(); +} + +void WallpaperObject::setCropY(qreal y) { + if (qFuzzyCompare(m_cropY, y)) return; + m_cropY = y; + emit cropYChanged(); + push(); +} + +void WallpaperObject::setCropWidth(qreal w) { + if (qFuzzyCompare(m_cropWidth, w)) return; + m_cropWidth = w; + emit cropWidthChanged(); + push(); +} + +void WallpaperObject::setCropHeight(qreal h) { + if (qFuzzyCompare(m_cropHeight, h)) return; + m_cropHeight = h; + emit cropHeightChanged(); + push(); +} + +void WallpaperObject::setZoom(qreal zoom) { + if (qFuzzyCompare(m_zoom, zoom)) return; + m_zoom = zoom; + emit zoomChanged(); + push(); +} + +void WallpaperObject::push() { + if (!m_service || m_screen.isEmpty()) return; + + QVariantMap crop; + crop.insert("x", m_cropX); + crop.insert("y", m_cropY); + crop.insert("width", m_cropWidth); + crop.insert("height", m_cropHeight); + crop.insert("zoom", m_zoom); + + m_service->setCrop(m_screen, crop); +} + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperobject.hpp b/Plugins/ZShell/Internal/wallpaperobject.hpp new file mode 100644 index 0000000..eb3d8a4 --- /dev/null +++ b/Plugins/ZShell/Internal/wallpaperobject.hpp @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "wallpaperservice.hpp" + +namespace ZShell::internal { + +class WallpaperObject : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + WallpaperService* service READ service WRITE setService NOTIFY + serviceChanged) + Q_PROPERTY(QString screen READ screen WRITE setScreen NOTIFY screenChanged) + + Q_PROPERTY(qreal cropX READ cropX WRITE setCropX NOTIFY cropXChanged) + Q_PROPERTY(qreal cropY READ cropY WRITE setCropY NOTIFY cropYChanged) + Q_PROPERTY( + qreal cropWidth READ cropWidth WRITE setCropWidth NOTIFY + cropWidthChanged) + Q_PROPERTY( + qreal cropHeight READ cropHeight WRITE setCropHeight NOTIFY + cropHeightChanged) + Q_PROPERTY(qreal zoom READ zoom WRITE setZoom NOTIFY zoomChanged) + + public: + explicit WallpaperObject(QObject* parent = nullptr); + ~WallpaperObject() override; + + WallpaperService* service() const { return m_service; } + void setService(WallpaperService* service); + + QString screen() const { return m_screen; } + void setScreen(const QString& screen); + + qreal cropX() const { return m_cropX; } + void setCropX(qreal x); + + qreal cropY() const { return m_cropY; } + void setCropY(qreal y); + + qreal cropWidth() const { return m_cropWidth; } + void setCropWidth(qreal w); + + qreal cropHeight() const { return m_cropHeight; } + void setCropHeight(qreal h); + + qreal zoom() const { return m_zoom; } + void setZoom(qreal zoom); + + signals: + void serviceChanged(); + void screenChanged(); + void cropXChanged(); + void cropYChanged(); + void cropWidthChanged(); + void cropHeightChanged(); + void zoomChanged(); + + private: + void registerScreen(); + + void push(); + + QPointer m_service; + QString m_screen; + + qreal m_cropX = 0.0; + qreal m_cropY = 0.0; + qreal m_cropWidth = 1.0; + qreal m_cropHeight = 1.0; + qreal m_zoom = 1.0; +}; + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperservice.cpp b/Plugins/ZShell/Internal/wallpaperservice.cpp new file mode 100644 index 0000000..9933218 --- /dev/null +++ b/Plugins/ZShell/Internal/wallpaperservice.cpp @@ -0,0 +1,749 @@ +#define EGL_EGLEXT_PROTOTYPES +#define GL_GLEXT_PROTOTYPES + +#include "wallpaperservice.hpp" + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include + +#include + +namespace ZShell::internal { + +namespace { + +const char* kVertexShader = R"( +#version 300 es +void main() { + // The corners are (-1,-1), (3,-1) and (-1,3), which cover the whole buffer. + vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0); +} +)"; + +const char* kFragmentShader = R"( +#version 300 es +precision highp float; +uniform sampler2D source; +uniform vec2 size; +uniform vec4 crop; +out vec4 outColor; +void main() { + // The pixels are stored top-down, so the fragment coordinate is flipped. + vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size; + outColor = texture(source, crop.xy + p * crop.zw); +} +)"; + +constexpr int kWaitTimeoutMs = 20; + +void logShader(uint32_t object, bool program) { + int ok = 0; + if (program) + glGetProgramiv(object, GL_LINK_STATUS, &ok); + else + glGetShaderiv(object, GL_COMPILE_STATUS, &ok); + + if (ok) return; + + char info[512] = {}; + if (program) + glGetProgramInfoLog(object, int(sizeof(info)), nullptr, info); + else + glGetShaderInfoLog(object, int(sizeof(info)), nullptr, info); + + qWarning() << "WallpaperService:" << (program ? "linking" : "compiling") + << "failed:" << info; +} + +QVariantMap cropFor(const QVariantMap& crops, const QString& screen) { + QVariantMap crop = crops.value(screen).toMap(); + + if (crop.isEmpty()) { + crop.insert("x", 0.0); + crop.insert("y", 0.0); + crop.insert("width", 1.0); + crop.insert("height", 1.0); + crop.insert("zoom", 1.0); + } + + return crop; +} + +} // namespace + +WallpaperService::WallpaperService(QObject* parent) : QObject(parent) {} + +WallpaperService::~WallpaperService() { + m_stop = true; + wake(); + + if (m_thread.joinable()) m_thread.join(); + + for (auto* surface : m_surfaces) + delete surface; + m_surfaces.clear(); + + if (m_eglDisplay != EGL_NO_DISPLAY && m_context != EGL_NO_CONTEXT) { + if (eglMakeCurrent( + m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) { + if (m_texture) glDeleteTextures(1, &m_texture); + if (m_program) glDeleteProgram(m_program); + } + + eglDestroyContext(m_eglDisplay, m_context); + } + + if (m_queue) wl_event_queue_destroy(m_queue); + + if (m_wake[0] >= 0) { + close(m_wake[0]); + close(m_wake[1]); + } + + if (m_layerShell) m_layerShell->destroy(); + if (m_viewporter) m_viewporter->destroy(); + if (m_scaleManager) m_scaleManager->destroy(); + if (m_registry) wl_registry_destroy(m_registry); +} + +QString WallpaperService::path() const { + std::lock_guard lock(m_mutex); + return m_path; +} + +void WallpaperService::setPath(const QString& path) { + if (m_path == path) return; + + { + std::lock_guard lock(m_mutex); + m_path = path; + + m_forceDecode = true; + m_imageChanged = true; + } + + emit pathChanged(); + + start(); + queueAll(); +} + +void WallpaperService::addScreen(const QString& screen) { + { + std::lock_guard lock(m_mutex); + + if (m_screens.contains(screen)) return; + + m_screens.append(screen); + } + + start(); + + queueAll(); +} + +void WallpaperService::removeScreen(const QString& screen) { + { + std::lock_guard lock(m_mutex); + + if (!m_screens.removeOne(screen)) return; + } + + queueAll(); +} + +const QVariantMap& WallpaperService::crops() const { + std::lock_guard lock(m_mutex); + return m_crops; +} + +void WallpaperService::setCrops(const QVariantMap& crops) { + { + std::lock_guard lock(m_mutex); + + if (m_crops == crops) return; + + m_crops = crops; + } + + emit cropsChanged(); + queueAll(); +} + +void WallpaperService::setCrop(const QString& screen, const QVariantMap& crop) { + WallpaperSurface* surface = nullptr; + + { + std::lock_guard lock(m_mutex); + + if (m_crops.value(screen) == crop) return; + + m_crops.insert(screen, crop); + + for (auto* candidate : m_surfaces) { + if (candidate->screen() == screen) { + surface = candidate; + break; + } + } + } + + emit cropsChanged(); + + if (surface) queue(surface); +} + +void WallpaperService::redraw() { + queueAll(); +} + +void WallpaperService::start() { + { + std::lock_guard lock(m_mutex); + + if (started || m_path.isEmpty() || m_screens.isEmpty()) return; + + started = true; + } + + auto* interface = + qApp->nativeInterface(); + if (!interface) { + qWarning() << "WallpaperService: this is not a Wayland session"; + return; + } + + m_display = interface->display(); + m_compositor = interface->compositor(); + if (!m_display || !m_compositor) return; + + m_queue = wl_display_create_queue(m_display); + if (!m_queue) return; + + if (pipe2(m_wake, O_CLOEXEC) < 0) { + qWarning() << "WallpaperService: could not make the pipe to wake on"; + return; + } + + bindGlobals(); + + if (!setupGl()) return; + + m_drawing = true; + m_thread = std::thread([this] { loop(); }); + + wake(); +} + +void WallpaperService::wake() { + if (m_wake[1] < 0) return; + + char byte = 0; + + if (::write(m_wake[1], &byte, 1) < 0) + qWarning() << "WallpaperService: could not wake the drawing thread"; +} + +void WallpaperService::queue(WallpaperSurface* surface) { + { + std::lock_guard lock(m_mutex); + m_pending.push_back(surface); + } + + wake(); +} + +void WallpaperService::queueAll() { + { + std::lock_guard lock(m_mutex); + + for (auto* surface : m_surfaces) + m_pending.push_back(surface); + } + + wake(); +} + +void WallpaperService::loop() { + while (!m_stop) { + present(); + + if (wl_display_prepare_read(m_display) == 0) { + wl_display_flush(m_display); + + struct pollfd fds[2] = { + {wl_display_get_fd(m_display), POLLIN, 0}, + {m_wake[0], POLLIN, 0}, + }; + + int ready = poll(fds, 2, kWaitTimeoutMs); + + if (ready > 0 && fds[0].revents) { + wl_display_read_events(m_display); + continue; + } + + if (ready > 0 && fds[1].revents) { + char byte = 0; + ::read(m_wake[0], &byte, 1); + } + + wl_display_cancel_read(m_display); + continue; + } + + struct pollfd fds[1] = {{m_wake[0], POLLIN, 0}}; + + if (poll(fds, 1, kWaitTimeoutMs) <= 0) continue; + + char byte = 0; + ::read(m_wake[0], &byte, 1); + } +} + +void WallpaperService::present() { + wl_display_dispatch_queue_pending(m_display, m_queue); + + addSurfaces(); + + ensureDecodeSize(); + + std::vector pending; + { + std::lock_guard lock(m_mutex); + pending.swap(m_pending); + } + + for (auto* surface : pending) + drawSurface(surface); +} + +void WallpaperService::addSurfaces() { + if (!started || !m_layerShell || !m_viewporter || !m_scaleManager) return; + + QList gone; + QList needed; + + { + std::lock_guard lock(m_mutex); + + QList kept; + for (auto* surface : m_surfaces) { + if (m_screens.contains(surface->screen())) + kept.append(surface); + else + gone.append(surface); + } + + m_surfaces = kept; + + for (const auto& screen : m_screens) { + bool exists = false; + for (auto* surface : m_surfaces) { + if (surface->screen() == screen) { + exists = true; + break; + } + } + + if (!exists) needed.append(screen); + } + } + + for (auto* surface : gone) + delete surface; + + for (const auto& screen : needed) + addSurface(screen); +} + +void WallpaperService::registryGlobal( + void* data, + struct wl_registry* registry, + uint32_t name, + const char* interface, + uint32_t version) { + auto* service = static_cast(data); + + void* proxy = nullptr; + + if (strcmp(interface, "zwlr_layer_shell_v1") == 0) { + proxy = new QtWayland::zwlr_layer_shell_v1( + registry, int(name), int(version)); + } else if (strcmp(interface, "wp_viewporter") == 0) { + proxy = new QtWayland::wp_viewporter(registry, int(name), int(version)); + } else if (strcmp(interface, "wp_fractional_scale_manager_v1") == 0) { + proxy = new QtWayland::wp_fractional_scale_manager_v1( + registry, int(name), int(version)); + } else { + return; + } + + service->storeGlobal(interface, proxy); + + service->queueAll(); +} + +void WallpaperService::storeGlobal(const char* interface, void* proxy) { + std::lock_guard lock(m_mutex); + + if (strcmp(interface, "zwlr_layer_shell_v1") == 0) + m_layerShell = static_cast(proxy); + else if (strcmp(interface, "wp_viewporter") == 0) + m_viewporter = static_cast(proxy); + else if (strcmp(interface, "wp_fractional_scale_manager_v1") == 0) + m_scaleManager = + static_cast(proxy); +} + +void WallpaperService::bindGlobals() { + m_registry = wl_display_get_registry(m_display); + + static const struct wl_registry_listener listener = { + registryGlobal, + nullptr, + }; + + wl_registry_add_listener(m_registry, &listener, this); +} + +bool WallpaperService::setupGl() { + const char* extensions = eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS); + if (extensions && strstr(extensions, "EGL_EXT_platform_wayland")) { + m_eglDisplay = + eglGetPlatformDisplay(EGL_PLATFORM_WAYLAND_EXT, m_display, nullptr); + } else { + m_eglDisplay = + eglGetDisplay(reinterpret_cast(m_display)); + } + + if (m_eglDisplay == EGL_NO_DISPLAY) { + qWarning() << "WallpaperService: no EGL display"; + return false; + } + + EGLint major = 0, minor = 0; + if (!eglInitialize(m_eglDisplay, &major, &minor)) { + qWarning() << "WallpaperService: eglInitialize failed"; + return false; + } + + const EGLint configAttributes[] = { + EGL_SURFACE_TYPE, + EGL_WINDOW_BIT, + EGL_RENDERABLE_TYPE, + EGL_OPENGL_ES2_BIT, + EGL_RED_SIZE, + 8, + EGL_GREEN_SIZE, + 8, + EGL_BLUE_SIZE, + 8, + EGL_ALPHA_SIZE, + 8, + EGL_NONE}; + + EGLint count = 0; + if (!eglChooseConfig(m_eglDisplay, configAttributes, &m_config, 1, &count) || + count == 0) { + qWarning() << "WallpaperService: no EGL config for window surfaces"; + return false; + } + + const EGLint contextAttributes[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_NONE}; + m_context = eglCreateContext( + m_eglDisplay, m_config, EGL_NO_CONTEXT, contextAttributes); + if (m_context == EGL_NO_CONTEXT) { + qWarning() << "WallpaperService: eglCreateContext failed"; + return false; + } + + return true; +} + +uint32_t WallpaperService::createProgram() { + uint32_t vertex = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(vertex, 1, &kVertexShader, nullptr); + glCompileShader(vertex); + logShader(vertex, false); + + uint32_t fragment = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(fragment, 1, &kFragmentShader, nullptr); + glCompileShader(fragment); + logShader(fragment, false); + + uint32_t program = glCreateProgram(); + glAttachShader(program, vertex); + glAttachShader(program, fragment); + glLinkProgram(program); + logShader(program, true); + + if (glGetUniformLocation(program, "size") < 0 || + glGetUniformLocation(program, "crop") < 0) { + qWarning() << "WallpaperService: the program does not have the " + "uniforms it needs"; + glDeleteProgram(program); + return 0; + } + + m_sizeUniform = glGetUniformLocation(program, "size"); + m_cropUniform = glGetUniformLocation(program, "crop"); + + glDeleteShader(vertex); + glDeleteShader(fragment); + return program; +} + +void WallpaperService::uploadImage() { + if (m_image.isNull()) return; + + if (m_texture == 0) { + glGenTextures(1, &m_texture); + glBindTexture(GL_TEXTURE_2D, m_texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + } else { + glBindTexture(GL_TEXTURE_2D, m_texture); + } + + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D( + GL_TEXTURE_2D, + 0, + GL_RGBA8, + m_image.width(), + m_image.height(), + 0, + GL_RGBA, + GL_UNSIGNED_BYTE, + m_image.constBits()); +} + +bool WallpaperService::decode(const QString& path) { + if (path.isEmpty()) return false; + + QImageReader reader(path); + reader.setAutoTransform(true); + + QSize size = reader.size(); + if (size.isValid()) m_sourceSize = size; + + if (!m_decodeSize.isEmpty() && !m_sourceSize.isEmpty()) + reader.setScaledSize( + m_sourceSize.scaled(m_decodeSize, Qt::KeepAspectRatioByExpanding)); + + QImage image = reader.read(); + if (image.isNull()) { + qWarning() << "WallpaperService: could not read the image" << path; + return false; + } + + m_image = image.convertToFormat(QImage::Format_RGBA8888); + return !m_image.isNull(); +} + +bool WallpaperService::ensureDecodeSize() { + QSize largest; + QString path; + + { + std::lock_guard lock(m_mutex); + + largest = largestBufferSize(); + if (largest.isEmpty()) return false; + + if (!m_forceDecode && !m_decodeSize.isEmpty() && + qint64(largest.width()) * largest.height() <= + qint64(m_decodeSize.width()) * m_decodeSize.height()) + return false; + + QSize target = + m_sourceSize.isEmpty() + ? largest + : m_sourceSize.scaled(largest, Qt::KeepAspectRatioByExpanding); + + if (!m_forceDecode && !m_image.isNull() && target == m_image.size()) + return false; + + m_forceDecode = false; + m_decodeSize = largest; + path = m_path; + } + + if (!decode(path)) return false; + + { + std::lock_guard lock(m_mutex); + m_imageChanged = true; + } + + return true; +} + +QSize WallpaperService::largestBufferSize() { + QSize largest; + + for (auto* surface : m_surfaces) { + QSize buffer = surface->bufferSize(); + if (buffer.isEmpty()) continue; + + if (largest.isEmpty() || qint64(buffer.width()) * buffer.height() > + qint64(largest.width()) * largest.height()) + largest = buffer; + } + + return largest; +} + +QRectF WallpaperService::sourceRect( + const QVariantMap& crops, const QString& screen, const QSize& size) const { + QVariantMap crop = cropFor(crops, screen); + + qreal zoom = crop.value("zoom", 1.0).toDouble(); + if (zoom <= 0.0) + zoom = 1.0; + else if (zoom > 5.0) + zoom = 5.0; + + QRectF rect( + crop.value("x", 0.0).toDouble(), + crop.value("y", 0.0).toDouble(), + crop.value("width", 1.0).toDouble() / zoom, + crop.value("height", 1.0).toDouble() / zoom); + + if (size.isEmpty() || rect.isEmpty() || m_image.isNull()) return rect; + + qreal imageWidth = qreal(m_image.width()); + qreal imageHeight = qreal(m_image.height()); + + QRectF pixels( + rect.x() * imageWidth, + rect.y() * imageHeight, + rect.width() * imageWidth, + rect.height() * imageHeight); + + qreal targetRatio = qreal(size.width()) / qreal(size.height()); + qreal rectRatio = pixels.width() / pixels.height(); + + if (rectRatio > targetRatio) { + qreal newWidth = pixels.height() * targetRatio; + pixels.setX(pixels.x() + (pixels.width() - newWidth) / 2.0); + pixels.setWidth(newWidth); + } else if (rectRatio < targetRatio) { + qreal newHeight = pixels.width() / targetRatio; + pixels.setY(pixels.y() + (pixels.height() - newHeight) / 2.0); + pixels.setHeight(newHeight); + } + + return QRectF( + pixels.x() / imageWidth, + pixels.y() / imageHeight, + pixels.width() / imageWidth, + pixels.height() / imageHeight); +} + +void WallpaperService::drawSurface(WallpaperSurface* surface) { + EGLSurface target = surface->eglSurface(); + if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE || + surface->bufferSize().isEmpty()) + return; + + if (!eglMakeCurrent(m_eglDisplay, target, target, m_context)) { + qWarning() << "WallpaperService: the context cannot be made current"; + return; + } + + QVariantMap crops; + QSize size; + bool upload = false; + + { + std::lock_guard lock(m_mutex); + + crops = m_crops; + size = surface->bufferSize(); + + upload = m_imageChanged; + m_imageChanged = false; + } + + if (size.isEmpty()) return; + + if (m_program == 0) m_program = createProgram(); + if (m_texture == 0 || upload) uploadImage(); + + if (m_program == 0 || m_texture == 0 || m_image.isNull()) return; + + glUseProgram(m_program); + glBindTexture(GL_TEXTURE_2D, m_texture); + + glUniform2f( + GLint(m_sizeUniform), GLfloat(size.width()), GLfloat(size.height())); + + QRectF rect = sourceRect(crops, surface->screen(), size); + glUniform4f( + GLint(m_cropUniform), + GLfloat(rect.x()), + GLfloat(rect.y()), + GLfloat(rect.width()), + GLfloat(rect.height())); + + glViewport(0, 0, size.width(), size.height()); + glDrawArrays(GL_TRIANGLES, 0, 3); + + if (!eglSwapBuffers(m_eglDisplay, target)) + qWarning() << "WallpaperService: eglSwapBuffers failed"; +} + +void WallpaperService::addSurface(const QString& screen) { + if (!m_layerShell || !m_viewporter || !m_scaleManager) return; + + QScreen* qscreen = nullptr; + for (auto* candidate : QGuiApplication::screens()) { + if (candidate->name() == screen) { + qscreen = candidate; + break; + } + } + + if (!qscreen) { + qWarning() << "WallpaperService:" << screen << "is not a screen"; + return; + } + + auto* waylandScreen = + dynamic_cast(qscreen->handle()); + if (!waylandScreen || !waylandScreen->output()) { + qWarning() << "WallpaperService:" << screen + << "is not a Wayland screen"; + return; + } + + wl_surface* wlSurface = wl_compositor_create_surface(m_compositor); + if (!wlSurface) return; + + auto* surface = + new WallpaperSurface(this, wlSurface, waylandScreen->output(), screen); + + { + std::lock_guard lock(m_mutex); + m_surfaces.append(surface); + } +} + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperservice.hpp b/Plugins/ZShell/Internal/wallpaperservice.hpp new file mode 100644 index 0000000..a68d84d --- /dev/null +++ b/Plugins/ZShell/Internal/wallpaperservice.hpp @@ -0,0 +1,147 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "wallpapersurface.hpp" + +namespace ZShell::internal { + +class WallpaperSurface; + +class WallpaperService : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED) + Q_PROPERTY(QVariantMap crops READ crops WRITE setCrops NOTIFY cropsChanged) + + public: + explicit WallpaperService(QObject* parent = nullptr); + ~WallpaperService() override; + + [[nodiscard]] QString path() const; + void setPath(const QString& path); + + void addScreen(const QString& screen); + void removeScreen(const QString& screen); + + [[nodiscard]] const QVariantMap& crops() const; + void setCrops(const QVariantMap& crops); + + Q_INVOKABLE void redraw(); + + Q_INVOKABLE void setCrop(const QString& screen, const QVariantMap& crop); + + signals: + void pathChanged(); + void cropsChanged(); + + private: + friend class WallpaperSurface; + + void bindGlobals(); + static void registryGlobal( + void* data, + struct wl_registry* registry, + uint32_t name, + const char* interface, + uint32_t version); + + void storeGlobal(const char* interface, void* proxy); + + [[nodiscard]] bool setupGl(); + [[nodiscard]] uint32_t createProgram(); + + void uploadImage(); + + bool decode(const QString& path); + + bool ensureDecodeSize(); + + [[nodiscard]] QSize largestBufferSize(); + + void queue(WallpaperSurface* surface); + void queueAll(); + + void startDrawing(); + void loop(); + + void present(); + + void drawSurface(WallpaperSurface* surface); + + void wake(); + + [[nodiscard]] wl_event_queue* eventQueue() const { return m_queue; } + + [[nodiscard]] QRectF sourceRect( + const QVariantMap& crops, + const QString& screen, + const QSize& size) const; + + void start(); + + void addSurfaces(); + void addSurface(const QString& screen); + + QString m_path; + QStringList m_screens; + QVariantMap m_crops; + + QImage m_image; + + QSize m_sourceSize; + QSize m_decodeSize; + + wl_display* m_display = nullptr; + wl_registry* m_registry = nullptr; + wl_compositor* m_compositor = nullptr; + QtWayland::zwlr_layer_shell_v1* m_layerShell = nullptr; + QtWayland::wp_viewporter* m_viewporter = nullptr; + QtWayland::wp_fractional_scale_manager_v1* m_scaleManager = nullptr; + + EGLDisplay m_eglDisplay = EGL_NO_DISPLAY; + EGLConfig m_config = nullptr; + EGLContext m_context = EGL_NO_CONTEXT; + uint32_t m_texture = 0; + uint32_t m_program = 0; + int m_sizeUniform = -1; + int m_cropUniform = -1; + + QList m_surfaces; + + std::thread m_thread; + mutable std::recursive_mutex m_mutex; + std::vector m_pending; + wl_event_queue* m_queue = nullptr; + + int m_wake[2] = {-1, -1}; + + bool m_stop = false; + bool m_drawing = false; + + bool started = false; + + bool m_forceDecode = false; + bool m_imageChanged = false; +}; + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpapersurface.cpp b/Plugins/ZShell/Internal/wallpapersurface.cpp new file mode 100644 index 0000000..8fe99e6 --- /dev/null +++ b/Plugins/ZShell/Internal/wallpapersurface.cpp @@ -0,0 +1,147 @@ +#include "wallpapersurface.hpp" + +#include + +#include + +#include "wallpaperservice.hpp" + +namespace ZShell::internal { + +const QString kNamespace = "ZShell-Wallpaper"; + +void setQueue(void* proxy, struct wl_event_queue* queue) { + wl_proxy_set_queue(static_cast(proxy), queue); +} + +WallpaperSurface::WallpaperSurface( + WallpaperService* service, + wl_surface* surface, + wl_output* output, + const QString& screen) + : m_service(service), m_screen(screen), m_surface(surface) { + if (!surface) { + qWarning() << "WallpaperSurface: no surface for" << screen; + return; + } + + setQueue(m_surface, m_service->eventQueue()); + + auto* layer = m_service->m_layerShell->get_layer_surface( + m_surface, + output, + QtWayland::zwlr_layer_shell_v1::layer_background, + kNamespace); + setQueue(layer, m_service->eventQueue()); + QtWayland::zwlr_layer_surface_v1::init(layer); + + set_size(0, 0); + set_anchor(anchor_top | anchor_bottom | anchor_left | anchor_right); + set_exclusive_zone(-1); + set_keyboard_interactivity(keyboard_interactivity_none); + set_margin(0, 0, 0, 0); + + auto* viewport = m_service->m_viewporter->get_viewport(m_surface); + setQueue(viewport, m_service->eventQueue()); + QtWayland::wp_viewport::init(viewport); + + auto* scale = m_service->m_scaleManager->get_fractional_scale(m_surface); + setQueue(scale, m_service->eventQueue()); + QtWayland::wp_fractional_scale_v1::init(scale); + + wl_surface_commit(m_surface); +} + +WallpaperSurface::~WallpaperSurface() { + if (m_eglSurface != EGL_NO_SURFACE) + eglDestroySurface(m_service->m_eglDisplay, m_eglSurface); + + if (m_window) wl_egl_window_destroy(m_window); + + if (QtWayland::wp_viewport::isInitialized()) + QtWayland::wp_viewport::destroy(); + if (QtWayland::wp_fractional_scale_v1::isInitialized()) + QtWayland::wp_fractional_scale_v1::destroy(); + if (QtWayland::zwlr_layer_surface_v1::isInitialized()) + QtWayland::zwlr_layer_surface_v1::destroy(); + + if (m_surface) wl_surface_destroy(m_surface); +} + +void WallpaperSurface::redraw() { + m_service->queue(this); +} + +void WallpaperSurface::zwlr_layer_surface_v1_configure( + uint32_t serial, uint32_t width, uint32_t height) { + ack_configure(serial); + + if (width == 0 || height == 0) return; + + m_size = QSize(int(width), int(height)); + + if (!configured) configured = true; + + updateBuffer(); +} + +void WallpaperSurface::wp_fractional_scale_v1_preferred_scale(uint32_t scale) { + if (scale == 0) return; + + qreal newScale = qreal(scale) / 120.0; + if (qFuzzyCompare(newScale, m_scale)) return; + + m_scale = newScale; + updateBuffer(); +} + +void WallpaperSurface::zwlr_layer_surface_v1_closed() {} + +void WallpaperSurface::updateBuffer() { + if (m_size.isEmpty()) return; + + QSize buffer = QSize( + int(std::llround(m_size.width() * m_scale)), + int(std::llround(m_size.height() * m_scale))); + + if (buffer.isEmpty()) return; + + if (m_bufferSize != buffer) { + m_bufferSize = buffer; + + if (m_window) { + wl_egl_window_resize( + m_window, buffer.width(), buffer.height(), 0, 0); + } else { + m_window = + wl_egl_window_create(m_surface, buffer.width(), buffer.height()); + if (!m_window) { + qWarning() + << "WallpaperSurface: could not make a window surface for" + << m_screen; + return; + } + } + + if (m_eglSurface == EGL_NO_SURFACE) { + m_eglSurface = eglCreateWindowSurface( + m_service->m_eglDisplay, + m_service->m_config, + reinterpret_cast(m_window), + nullptr); + if (m_eglSurface == EGL_NO_SURFACE) { + qWarning() + << "WallpaperSurface: eglCreateWindowSurface failed for" + << m_screen; + return; + } + } + } + + if (QtWayland::wp_viewport::isInitialized()) + set_destination(m_size.width(), m_size.height()); + + m_service->queue(this); +} + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpapersurface.hpp b/Plugins/ZShell/Internal/wallpapersurface.hpp new file mode 100644 index 0000000..d534c99 --- /dev/null +++ b/Plugins/ZShell/Internal/wallpapersurface.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace ZShell::internal { + +class WallpaperService; + +class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1, + public QtWayland::wp_viewport, + public QtWayland::wp_fractional_scale_v1 { + public: + WallpaperSurface( + WallpaperService* service, + wl_surface* surface, + wl_output* output, + const QString& screen); + ~WallpaperSurface() override; + + [[nodiscard]] const QString& screen() const { return m_screen; } + + [[nodiscard]] const QSize& size() const { return m_size; } + [[nodiscard]] const QSize& bufferSize() const { return m_bufferSize; } + [[nodiscard]] EGLSurface eglSurface() const { return m_eglSurface; } + + void redraw(); + + private: + void zwlr_layer_surface_v1_configure( + uint32_t serial, uint32_t width, uint32_t height) override; + void zwlr_layer_surface_v1_closed() override; + void wp_fractional_scale_v1_preferred_scale(uint32_t scale) override; + + void updateBuffer(); + + WallpaperService* m_service; + QString m_screen; + wl_surface* m_surface; + wl_egl_window* m_window = nullptr; + EGLSurface m_eglSurface = EGL_NO_SURFACE; + + QSize m_size; + QSize m_bufferSize; + qreal m_scale = 1.0; + + bool configured = false; +}; + +} // namespace ZShell::internal diff --git a/Plugins/cmake/wayland-protocol.cmake b/Plugins/cmake/wayland-protocol.cmake new file mode 100644 index 0000000..76b6989 --- /dev/null +++ b/Plugins/cmake/wayland-protocol.cmake @@ -0,0 +1,58 @@ +find_program(QTWAYLANDSCANNER_EXECUTABLE qtwaylandscanner REQUIRED) +find_program(WAYLAND_SCANNER_EXECUTABLE wayland-scanner REQUIRED) + +pkg_get_variable(WAYLAND_PROTOCOLS wayland-protocols pkgdatadir) +if(NOT WAYLAND_PROTOCOLS) + message(FATAL_ERROR "wayland-protocols not found") +endif() + +function(wl_proto arg_TARGET arg_PROTOCOL arg_XML_DIR) + set(out_dir "${CMAKE_BINARY_DIR}/wayland-protocols/${arg_TARGET}") + file(MAKE_DIRECTORY "${out_dir}") + + set(xml "${arg_XML_DIR}/${arg_PROTOCOL}.xml") + + add_custom_command( + OUTPUT "${out_dir}/wayland-${arg_PROTOCOL}-client-protocol.h" + "${out_dir}/wayland-${arg_PROTOCOL}.c" + COMMAND ${WAYLAND_SCANNER_EXECUTABLE} client-header "${xml}" + "${out_dir}/wayland-${arg_PROTOCOL}-client-protocol.h" + COMMAND ${WAYLAND_SCANNER_EXECUTABLE} private-code "${xml}" + "${out_dir}/wayland-${arg_PROTOCOL}.c" + DEPENDS "${xml}" + COMMENT "Generating ${arg_PROTOCOL} protocol files" + ) + + add_custom_command( + OUTPUT "${out_dir}/qwayland-${arg_PROTOCOL}.h" + "${out_dir}/qwayland-${arg_PROTOCOL}.cpp" + COMMAND ${QTWAYLANDSCANNER_EXECUTABLE} client-header "${xml}" + > "${out_dir}/qwayland-${arg_PROTOCOL}.h" + COMMAND ${QTWAYLANDSCANNER_EXECUTABLE} client-code "${xml}" + > "${out_dir}/qwayland-${arg_PROTOCOL}.cpp" + DEPENDS "${xml}" "${out_dir}/wayland-${arg_PROTOCOL}-client-protocol.h" + COMMENT "Generating ${arg_PROTOCOL} client wrapper" + ) + + add_library( + ${arg_TARGET} + STATIC + "${out_dir}/wayland-${arg_PROTOCOL}.c" + "${out_dir}/qwayland-${arg_PROTOCOL}.cpp" + ) + set_source_files_properties( + "${out_dir}/qwayland-${arg_PROTOCOL}.cpp" + PROPERTIES COMPILE_OPTIONS + "-Wno-old-style-cast;-Wno-sign-conversion;-Wno-conversion;-Wno-shadow" + ) + set_target_properties(${arg_TARGET} PROPERTIES POSITION_INDEPENDENT_CODE ON) + target_include_directories(${arg_TARGET} PUBLIC "${out_dir}") + target_link_libraries( + ${arg_TARGET} + PUBLIC + Qt::Core + Qt::WaylandClient + Qt::WaylandClientPrivate + PkgConfig::WAYLAND_CLIENT + ) +endfunction() diff --git a/shell.qml b/shell.qml index 75bde5a..57f6e6b 100644 --- a/shell.qml +++ b/shell.qml @@ -3,7 +3,6 @@ // @ pragma DefaultEnv QSG_RHI_BACKEND=vulkan //@ pragma DefaultEnv QSG_NO_VSYNC=1 //@ pragma DefaultEnv QS_NO_RELOAD_POPUP=1 -//@ pragma DefaultEnv QT_SCALE_FACTOR_ROUNDING_POLICY=Round //@ pragma DefaultEnv QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 //@ pragma DropExpensiveFonts import Quickshell @@ -25,9 +24,6 @@ ShellRoot { Drawers { } - Wallpaper { - } - Lock { id: lock } @@ -48,4 +44,7 @@ ShellRoot { component: BatteryService { } } + + ServiceLoader { + } } From a00bbe50f2e8321ad3699e932bc9a3164a5e62c2 Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 6 Oct 2026 00:55:50 +0200 Subject: [PATCH 71/90] add rounding var --- shell.qml | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/shell.qml b/shell.qml index 57f6e6b..8c30296 100644 --- a/shell.qml +++ b/shell.qml @@ -3,6 +3,7 @@ // @ pragma DefaultEnv QSG_RHI_BACKEND=vulkan //@ pragma DefaultEnv QSG_NO_VSYNC=1 //@ pragma DefaultEnv QS_NO_RELOAD_POPUP=1 +//@ pragma DefaultEnv QT_SCALE_FACTOR_ROUNDING_POLICY=Round //@ pragma DefaultEnv QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 //@ pragma DropExpensiveFonts import Quickshell @@ -21,30 +22,25 @@ ShellRoot { settings.watchFiles: true - Drawers { - } + Drawers {} Lock { id: lock } - Shortcuts { - } + Shortcuts {} IdleMonitors { lock: lock } - Polkit { - } + Polkit {} LazyLoader { activeAsync: root.laptop - component: BatteryService { - } + component: BatteryService {} } - ServiceLoader { - } + ServiceLoader {} } From 376595cd0b16f095b94de6ee3f6c628a10bbda3f Mon Sep 17 00:00:00 2001 From: zach Date: Tue, 6 Oct 2026 13:30:27 +0200 Subject: [PATCH 72/90] fix: don't use zoom value when rendering wallpaper --- Helpers/Wallpapers.qml | 38 +++++++++++++++ Modules/ServiceLoader.qml | 4 +- Modules/Wallpaper/Wallpaper.qml | 51 -------------------- Plugins/ZShell/Internal/wallpaperservice.cpp | 10 +--- shell.qml | 1 - 5 files changed, 42 insertions(+), 62 deletions(-) delete mode 100644 Modules/Wallpaper/Wallpaper.qml diff --git a/Helpers/Wallpapers.qml b/Helpers/Wallpapers.qml index d02b01a..c335feb 100644 --- a/Helpers/Wallpapers.qml +++ b/Helpers/Wallpapers.qml @@ -5,7 +5,9 @@ import Quickshell import Quickshell.Io import QtQuick import ZShell.Models +import ZShell.Internal import ZShell.Config +import qs.Components import qs.Services import qs.Helpers import qs.Paths @@ -140,4 +142,40 @@ Searcher { } } } + + WallpaperService { + id: wallpaper + + path: root.current + } + + Variants { + model: Quickshell.screens + + WallpaperObject { + readonly property var crops: root.getCrop(modelData.name) + required property var modelData + + cropHeight: crops?.height ?? 1.0 + cropWidth: crops?.width ?? 1.0 + cropX: crops?.x ?? 0.0 + cropY: crops?.y ?? 0.0 + screen: modelData.name + service: wallpaper + zoom: crops?.zoom ?? 1.0 + + Behavior on cropHeight { + Anim {} + } + Behavior on cropWidth { + Anim {} + } + Behavior on cropX { + Anim {} + } + Behavior on cropY { + Anim {} + } + } + } } diff --git a/Modules/ServiceLoader.qml b/Modules/ServiceLoader.qml index 01a2092..4d8eae3 100644 --- a/Modules/ServiceLoader.qml +++ b/Modules/ServiceLoader.qml @@ -1,9 +1,9 @@ import Quickshell import QtQuick -import qs.Modules.Wallpaper +import qs.Helpers Scope { Component.onCompleted: { - Wallpaper; + Wallpapers; } } diff --git a/Modules/Wallpaper/Wallpaper.qml b/Modules/Wallpaper/Wallpaper.qml deleted file mode 100644 index 563e4e8..0000000 --- a/Modules/Wallpaper/Wallpaper.qml +++ /dev/null @@ -1,51 +0,0 @@ -pragma Singleton - -import Quickshell -import QtQuick -import ZShell.Internal -import qs.Components -import qs.Helpers - -Singleton { - id: root - - WallpaperService { - id: wallpaper - - path: Wallpapers.current - } - - Variants { - model: Quickshell.screens - - WallpaperObject { - readonly property var crops: Wallpapers.getCrop(modelData.name) - required property var modelData - - cropHeight: crops?.height ?? 1.0 - cropWidth: crops?.width ?? 1.0 - cropX: crops?.x ?? 0.0 - cropY: crops?.y ?? 0.0 - screen: modelData.name - service: wallpaper - zoom: crops?.zoom ?? 1.0 - - Behavior on cropHeight { - Anim { - } - } - Behavior on cropWidth { - Anim { - } - } - Behavior on cropX { - Anim { - } - } - Behavior on cropY { - Anim { - } - } - } - } -} diff --git a/Plugins/ZShell/Internal/wallpaperservice.cpp b/Plugins/ZShell/Internal/wallpaperservice.cpp index 9933218..40fb00b 100644 --- a/Plugins/ZShell/Internal/wallpaperservice.cpp +++ b/Plugins/ZShell/Internal/wallpaperservice.cpp @@ -614,17 +614,11 @@ QRectF WallpaperService::sourceRect( const QVariantMap& crops, const QString& screen, const QSize& size) const { QVariantMap crop = cropFor(crops, screen); - qreal zoom = crop.value("zoom", 1.0).toDouble(); - if (zoom <= 0.0) - zoom = 1.0; - else if (zoom > 5.0) - zoom = 5.0; - QRectF rect( crop.value("x", 0.0).toDouble(), crop.value("y", 0.0).toDouble(), - crop.value("width", 1.0).toDouble() / zoom, - crop.value("height", 1.0).toDouble() / zoom); + crop.value("width", 1.0).toDouble(), + crop.value("height", 1.0).toDouble()); if (size.isEmpty() || rect.isEmpty() || m_image.isNull()) return rect; diff --git a/shell.qml b/shell.qml index 57f6e6b..7b9edcf 100644 --- a/shell.qml +++ b/shell.qml @@ -8,7 +8,6 @@ import Quickshell import Quickshell.Services.UPower import qs.Modules -import qs.Modules.Wallpaper import qs.Modules.Lock import qs.Drawers import qs.Modules.Polkit From 909b5113fbaa8767cfbd3f8b892437cb91908561 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 7 Oct 2026 13:32:37 +0200 Subject: [PATCH 73/90] fix: closed panels don't add resistance to bounce + use qtrendering for text --- Components/CustomText.qml | 8 ++- Components/MaterialIcon.qml | 1 + Drawers/Panels.qml | 2 +- Modules/Bar/Components/Resource.qml | 48 +++++++++------ Modules/Dashboard/Content.qml | 63 +++++++++----------- Modules/Settings/Common/WallpaperCropper.qml | 4 ++ Modules/Settings/Pages.qml | 12 ---- Plugins/ZShell/Blobs/blobgroup2.cpp | 29 ++++++++- Plugins/ZShell/Blobs/blobgroup2.hpp | 9 ++- Plugins/ZShell/Blobs/blobmaterial.cpp | 7 ++- Plugins/ZShell/Blobs/blobmaterial.hpp | 5 ++ Plugins/ZShell/Blobs/blobrect2.hpp | 3 + Plugins/ZShell/Blobs/blobshape2.hpp | 4 ++ Plugins/ZShell/Blobs/shaders/blob.frag | 32 ++++++++-- 14 files changed, 146 insertions(+), 81 deletions(-) diff --git a/Components/CustomText.qml b/Components/CustomText.qml index 144665e..5445f5f 100644 --- a/Components/CustomText.qml +++ b/Components/CustomText.qml @@ -16,11 +16,12 @@ Text { color: Colors.palette.m3onSurface font: Tokens.font.body.small linkColor: Colors.palette.m3onPrimaryFixedVariant - renderType: Text.NativeRendering + renderType: Text.QtRendering textFormat: Text.PlainText Behavior on color { - CAnim {} + CAnim { + } } Behavior on text { enabled: root.animate @@ -32,7 +33,8 @@ Text { type: Anim.FastEffects } - PropertyAction {} + PropertyAction { + } TAnim { target: root diff --git a/Components/MaterialIcon.qml b/Components/MaterialIcon.qml index b93e38c..73ca9d9 100644 --- a/Components/MaterialIcon.qml +++ b/Components/MaterialIcon.qml @@ -9,5 +9,6 @@ CustomText { property int grade: Colors.light ? 0 : -25 property font fontStyle: Tokens.font.icon.small + renderType: Text.NativeRendering font: Tokens.font.icon.size(fontStyle.pointSize).weight(fontStyle.weight).vaxes(fontStyle.variableAxes).fill(fill.toFixed(1)).grade(grade).build() } diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 50dd9c8..f6542dd 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -151,7 +151,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top - clip: true + clip: offsetScale > 0 implicitHeight: dashboard.implicitHeight * (1 - offsetScale) implicitWidth: dashboard.implicitWidth diff --git a/Modules/Bar/Components/Resource.qml b/Modules/Bar/Components/Resource.qml index cce4285..b22d9e7 100644 --- a/Modules/Bar/Components/Resource.qml +++ b/Modules/Bar/Components/Resource.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Layouts -import QtQuick.Shapes +import Quickshell import qs.Components import ZShell.Config import qs.Services @@ -12,6 +12,7 @@ GridLayout { property real animatedPercentage: 0 readonly property real arcStartAngle: 0.75 * Math.PI readonly property real arcSweep: 1.5 * Math.PI + readonly property real dpr: (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1 required property bool horizontal property string icon required property color iconColor @@ -22,12 +23,17 @@ GridLayout { property bool warning: percentage * 100 >= warningThreshold property int warningThreshold: 80 + function snap(v) { + return Math.round(v * dpr) / dpr; + } + columnSpacing: Tokens.spacing.medium columns: horizontal ? -1 : 1 percentage: 0 Behavior on animatedPercentage { - Anim {} + Anim { + } } Component.onCompleted: animatedPercentage = percentage @@ -47,27 +53,35 @@ GridLayout { text: root.icon } - CustomClippingRect { + CustomRect { + id: track + Layout.preferredHeight: root.horizontal ? icon.implicitHeight - Tokens.padding.extraSmall : 4 Layout.preferredWidth: root.horizontal ? 4 : icon.implicitWidth color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 2) - radius: Tokens.rounding.full + radius: Math.min(width, height) / 2 - CustomRect { - id: fill + Item { + id: clipper - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: root.horizontal ? parent.right : undefined - anchors.top: root.horizontal ? undefined : parent.top - color: root.mainColor - implicitHeight: root.horizontal ? Math.ceil(root.percentage * parent.height) : 0 - implicitWidth: root.horizontal ? 0 : Math.ceil(root.percentage * parent.width) + anchors.bottom: root.horizontal ? parent.bottom : undefined + anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined + anchors.left: root.horizontal ? undefined : parent.left + anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter + clip: true + height: root.horizontal ? root.snap(root.percentage * track.height) : track.height * 2 + width: root.horizontal ? track.width * 2 : root.snap(root.percentage * track.width) - // Behavior on implicitHeight { - // Anim { - // } - // } + CustomRect { + anchors.bottom: root.horizontal ? parent.bottom : undefined + anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined + anchors.left: root.horizontal ? undefined : parent.left + anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter + color: root.mainColor + height: track.height + radius: track.radius + width: track.width + } } } } diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index 0ca16cc..97c322d 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -1,7 +1,6 @@ pragma ComponentBehavior: Bound import Quickshell -import Quickshell.Widgets import QtQuick import QtQuick.Layouts import ZShell.Config @@ -11,11 +10,6 @@ import qs.Components Item { id: root - readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 - readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins + viewWrapper.anchors.topMargin - - required property ScreenState screenState - readonly property var dashboardTabs: { const allTabs = [ { @@ -45,9 +39,12 @@ Item { ]; return allTabs.filter(tab => tab.enabled); } + readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins + viewWrapper.anchors.topMargin + readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 + required property ScreenState screenState - implicitWidth: nonAnimWidth implicitHeight: nonAnimHeight + implicitWidth: nonAnimWidth Behavior on implicitHeight { Anim { @@ -63,15 +60,14 @@ Item { Tabs { id: tabs - anchors.top: parent.top anchors.left: parent.left + anchors.margins: Tokens.padding.small anchors.right: parent.right - + anchors.top: parent.top + anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins) nonAnimWidth: view.implicitWidth screenState: root.screenState tabs: root.dashboardTabs - anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins) - anchors.margins: Tokens.padding.small } Item { @@ -79,13 +75,13 @@ Item { property int radius: Tokens.rounding.extraLarge - anchors.margins - anchors.top: tabs.bottom - anchors.left: parent.left - anchors.right: parent.right anchors.bottom: parent.bottom - anchors.topMargin: Tokens.spacing.small + anchors.left: parent.left anchors.margins: Tokens.padding.small - clip: true + anchors.right: parent.right + anchors.top: tabs.bottom + anchors.topMargin: Tokens.spacing.small + clip: view.currentIndex !== 3 || view.contentX !== (view.currentItem?.x ?? 0) Flickable { id: view @@ -97,16 +93,19 @@ Item { } anchors.fill: parent - - implicitWidth: currentItem?.implicitWidth ?? 0 - implicitHeight: currentItem?.implicitHeight ?? 0 - - contentX: currentItem?.x ?? 0 - contentWidth: row.implicitWidth contentHeight: row.implicitHeight - + contentWidth: row.implicitWidth + contentX: currentItem?.x ?? 0 + implicitHeight: currentItem?.implicitHeight ?? 0 + implicitWidth: currentItem?.implicitWidth ?? 0 interactive: false + Behavior on contentX { + Anim { + type: Anim.DefaultEffects + } + } + onContentXChanged: { if (!moving || !currentItem) return; @@ -124,10 +123,6 @@ Item { Repeater { id: repeater - model: ScriptModel { - values: root.dashboardTabs - } - delegate: Loader { id: paneLoader @@ -135,7 +130,6 @@ Item { required property var modelData Layout.alignment: Qt.AlignTop - sourceComponent: modelData.component Component.onCompleted: active = Qt.binding(() => { @@ -146,6 +140,9 @@ Item { return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); }) } + model: ScriptModel { + values: root.dashboardTabs + } } } @@ -153,8 +150,8 @@ Item { id: dashComponent Dash { - screenState: root.screenState radius: viewWrapper.radius + screenState: root.screenState } } @@ -170,8 +167,8 @@ Item { id: mediaComponent Media { - screenState: root.screenState radius: viewWrapper.radius + screenState: root.screenState } } @@ -182,12 +179,6 @@ Item { radius: viewWrapper.radius } } - - Behavior on contentX { - Anim { - type: Anim.DefaultEffects - } - } } } } diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index 393b3d9..27eaa47 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -272,6 +272,10 @@ Item { retainWhileLoading: true smooth: true source: Wallpapers.current + sourceSize.height: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return imageSlot.height * dpr; + } Behavior on opacity { Anim { diff --git a/Modules/Settings/Pages.qml b/Modules/Settings/Pages.qml index 4aacc6d..3235e04 100644 --- a/Modules/Settings/Pages.qml +++ b/Modules/Settings/Pages.qml @@ -42,18 +42,6 @@ Item { objectName: "PageContainer" Component.onCompleted: root.loadPage(root.sState.currentPageIdx) - - // CustomRect { - // anchors.fill: parent - // color: Qt.alpha(Colors.palette.m3shadow, 0.3) - // opacity: root.sState.dimmed ? 1 : 0 - // visible: opacity > 0 - // z: 0 - // - // Behavior on opacity { - // Anim {} - // } - // } } Connections { diff --git a/Plugins/ZShell/Blobs/blobgroup2.cpp b/Plugins/ZShell/Blobs/blobgroup2.cpp index 219d48c..2273733 100644 --- a/Plugins/ZShell/Blobs/blobgroup2.cpp +++ b/Plugins/ZShell/Blobs/blobgroup2.cpp @@ -189,6 +189,9 @@ void BlobGroup2::ensureDataBuilt() { const int count = static_cast(m_data.rects.size()); + // Which shape is the source for the bar edge. + resolveSources(entryShapes); + // Exclusion gates only the smooth blend. for (int i = 0; i < count; ++i) { int mask = 0; @@ -251,8 +254,8 @@ void BlobGroup2::ensureDataBuilt() { ri.radius[3] = std::max(ri.radius[3] * fTl, minR); } - // Inverted rect. The frame term is evaluated for every pixel in the bounds, - // so the group flag cannot be gated per shape. + // Inverted rect. The frame term is evaluated for every pixel in the bounds, so the + // group flag cannot be gated per shape. if (!m_invertedRect) return; auto* inv = static_cast(m_invertedRect); @@ -299,6 +302,28 @@ void BlobGroup2::ensureDataBuilt() { 2.0 * (static_cast(outerHH) + extent))); } +// A point on the bar edge can only have one source. A shape that is not pushing is not +// the source: it still fills its own area, but inside the border strip it must not add +// its own outline to a span another shape is already shaping. That is the case that +// matters — one panel at rest next to one that is bouncing — where the edge showed +// several steps instead of one smooth indent of the closing panel. Only one shape can be +// pushing at a time, so the flag is just that, and the shader gates the strip with it. +void BlobGroup2::resolveSources(const QVector& entryShapes) { + const int count = static_cast(m_data.rects.size()); + + // With nothing pushing there is no span being shaped, so a shape at rest keeps its + // own outline and nothing is gated. + bool anyPushing = false; + for (int i = 0; i < count; ++i) + if (entryShapes[i]->isMoving()) { + anyPushing = true; + break; + } + + for (int i = 0; i < count; ++i) + m_data.rects[i].source = !anyPushing || entryShapes[i]->isMoving(); +} + void BlobGroup2::fillMaterial(BlobMaterial* material) { material->m_paddedX = static_cast(m_bounds.x()); material->m_paddedY = static_cast(m_bounds.y()); diff --git a/Plugins/ZShell/Blobs/blobgroup2.hpp b/Plugins/ZShell/Blobs/blobgroup2.hpp index 9f922e6..988816e 100644 --- a/Plugins/ZShell/Blobs/blobgroup2.hpp +++ b/Plugins/ZShell/Blobs/blobgroup2.hpp @@ -22,9 +22,11 @@ class BlobInvertedRect2; // disagree with the first. That is what makes overlapping shapes read as one // surface - one value, one writer, one alpha per pixel. struct BlobGroupData { - // The shader reads rectData[80] = 16 entries, and excludeMask is a 32-bit - // mask. The shared list is capped at 16, in group order, so the cap is the - // same list for every pixel. + // The shader reads rectData[80] = 16 entries of 5 vec4, and excludeMask is a + // 32-bit mask. The shared list is capped at 16, in group order, so the cap is the + // same list for every pixel. Each entry is either a source for its span or not; the + // flag is in the third vec4 alongside the screen half-extents, so no extra data is + // needed. static constexpr int kMaxRects = 16; QVector rects; @@ -109,6 +111,7 @@ class BlobGroup2 : public QQuickItem { private: void ensurePhysicsUpdated(); void ensureDataBuilt(); + void resolveSources(const QVector& entryShapes); void fillMaterial(BlobMaterial* material); qreal m_smoothing = 32.0; diff --git a/Plugins/ZShell/Blobs/blobmaterial.cpp b/Plugins/ZShell/Blobs/blobmaterial.cpp index 528d617..cc21de9 100644 --- a/Plugins/ZShell/Blobs/blobmaterial.cpp +++ b/Plugins/ZShell/Blobs/blobmaterial.cpp @@ -92,7 +92,12 @@ bool BlobMaterialShader::updateUniformData( memcpy(&maskAsFloat, &r.excludeMask, sizeof(float)); const float d0[4] = {r.cx, r.cy, r.hw, r.hh}; const float d1[4] = {maskAsFloat, r.offsetX, r.offsetY, r.minEig}; - const float d3[4] = {r.screenHalfX, r.screenHalfY, 0.0f, 0.0f}; + const float d3[4] = { + r.screenHalfX, + r.screenHalfY, + r.source ? 1.0f : 0.0f, + 0.0f, + }; memcpy(buf->data() + base, d0, 16); memcpy(buf->data() + base + 16, d1, 16); memcpy(buf->data() + base + 32, r.invDeform, 16); diff --git a/Plugins/ZShell/Blobs/blobmaterial.hpp b/Plugins/ZShell/Blobs/blobmaterial.hpp index 19536a5..659a7e6 100644 --- a/Plugins/ZShell/Blobs/blobmaterial.hpp +++ b/Plugins/ZShell/Blobs/blobmaterial.hpp @@ -17,6 +17,11 @@ struct BlobRectData { // Bitmask of indices in this rect's m_cachedRects that mutually exclude (or are excluded by) this rect. // Used by the shader to skip smin between excluded pairs. int excludeMask = 0; + // Whether this entry is pushing on the bar edge. Only the shape that is pushing can + // shape the edge; a shape at rest still fills its own area, but the shader gives it + // no contribution inside the border strip, so it cannot add its own outline to a span + // another shape is shaping. + bool source = true; }; class BlobMaterial : public QSGMaterial { diff --git a/Plugins/ZShell/Blobs/blobrect2.hpp b/Plugins/ZShell/Blobs/blobrect2.hpp index 800b37b..2cd29dc 100644 --- a/Plugins/ZShell/Blobs/blobrect2.hpp +++ b/Plugins/ZShell/Blobs/blobrect2.hpp @@ -71,6 +71,9 @@ class BlobRect2 : public BlobShape2 { bool isExcluded(const BlobShape2* other) const override; bool isCornerExcluded(const BlobShape2* other) const override; + // The shape that is pushing is the one that can shape the bar edge. A shape that is + // retiring still counts, because the retire curve is still moving the surface. + bool isMoving() const override { return m_physicsActive || m_retiring; } void cornerRadii(float out[4]) const override; [[nodiscard]] qreal topLeftRadius() const { return m_topLeftRadius; } diff --git a/Plugins/ZShell/Blobs/blobshape2.hpp b/Plugins/ZShell/Blobs/blobshape2.hpp index 3035082..caadd8f 100644 --- a/Plugins/ZShell/Blobs/blobshape2.hpp +++ b/Plugins/ZShell/Blobs/blobshape2.hpp @@ -56,6 +56,10 @@ class BlobShape2 : public QQuickItem { virtual bool isExcluded(const BlobShape2*) const { return false; } + // Whether this shape is currently pushing on a border. Only the shape that is pushing + // can shape the bar edge, so this decides the source flag. + virtual bool isMoving() const { return false; } + virtual bool isCornerExcluded(const BlobShape2* /*other*/) const { return false; } diff --git a/Plugins/ZShell/Blobs/shaders/blob.frag b/Plugins/ZShell/Blobs/shaders/blob.frag index 1231bc6..791f272 100644 --- a/Plugins/ZShell/Blobs/shaders/blob.frag +++ b/Plugins/ZShell/Blobs/shaders/blob.frag @@ -65,6 +65,19 @@ float smaxSharpA(float a, float b, float k) { void main() { vec2 pixel = vec2(paddedX, paddedY) + qt_TexCoord0 * vec2(paddedW, paddedH); + // The frame distances are needed before the per-rect pass. In the border strip the + // boundary is the frame, displaced only by the shape that is pushing, so a shape that + // is not the source must not define the boundary there. It still fills the inner area, + // so this only gates the strip, which is the bar edge. + float dOuter = 0.0; + float dInner = 0.0; + bool inStrip = false; + if (hasInverted != 0) { + dOuter = sdBox(pixel, invertedOuter.xy, invertedOuter.zw) - 1.0; + dInner = sdRoundedBox(pixel, invertedInner.xy, invertedInner.zw, invertedRadius); + inStrip = dOuter < 0.0 && dInner > 0.0; + } + // Phase 1: compute per-rect SDF, and track which rect owns the pixel for the // per-shape path (BlobGroup). The single group draw (BlobGroup2) has no owner: // myIndex stays -2, so the ownership test is skipped and this pass writes every @@ -78,10 +91,17 @@ void main() { vec4 props = rectData[i * 5 + 1]; // excludeMask(int bits), offsetX, offsetY, minEig vec4 invDm = rectData[i * 5 + 2]; // inverse deform matrix - vec4 sh = rectData[i * 5 + 3]; // screenHalfX, screenHalfY, 0, 0 + vec4 sh = rectData[i * 5 + 3]; // screenHalfX, screenHalfY, isSource, 0 vec4 radii = rectData[i * 5 + 4]; // effective per-corner radii (tr, br, bl, tl) + // A shape that is not pushing does not shape the bar edge, so inside the strip it + // must not add its own outline to a span another shape is already shaping. + if (inStrip && sh.z < 0.5) { + dArr[i] = 1e10; + continue; + } + // Offset center for asymmetric deformation vec2 center = rect.xy + props.yz; @@ -182,10 +202,6 @@ void main() { } if (hasInverted != 0) { - float dOuter = sdBox(pixel, invertedOuter.xy, invertedOuter.zw) - 1.0; - float dInner = - sdRoundedBox(pixel, invertedInner.xy, invertedInner.zw, invertedRadius); - // Border sinks: track the opposite rect edge, clamped to border thickness float innerTop = invertedInner.y - invertedInner.w; float innerBot = invertedInner.y + invertedInner.w; @@ -198,6 +214,10 @@ void main() { float sinkValue = 0.0; for (int i = 0; i < rectCount; i++) { + // Only a shape that is pushing can move the border. + if (rectData[i * 5 + 3].z < 0.5) + continue; + vec4 rect = rectData[i * 5]; vec4 sinkProps = rectData[i * 5 + 1]; vec2 sinkSh = rectData[i * 5 + 3].xy; @@ -208,7 +228,7 @@ void main() { // Delay sink to absorb smin blend depth (cubic smin max = k/6) float preOff = smoothFactor * (1.0 / 6.0); - // Top border: track rect's BOTTOM edge, only within border thickness + // Top border: track rect's BOTTOM edge, clamped to border thickness float topPen = clamp(innerTop - (ctr.y + sinkSh.y) - preOff, 0.0, innerTop - outerTop); From 4d75b317cd8b0b9b354b48c00617bdd4f21aa4b7 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 7 Oct 2026 13:33:54 +0200 Subject: [PATCH 74/90] revert rounding var --- shell.qml | 1 - 1 file changed, 1 deletion(-) diff --git a/shell.qml b/shell.qml index ba9d49b..fcbc98b 100644 --- a/shell.qml +++ b/shell.qml @@ -3,7 +3,6 @@ // @ pragma DefaultEnv QSG_RHI_BACKEND=vulkan //@ pragma DefaultEnv QSG_NO_VSYNC=1 //@ pragma DefaultEnv QS_NO_RELOAD_POPUP=1 -//@ pragma DefaultEnv QT_SCALE_FACTOR_ROUNDING_POLICY=Round //@ pragma DefaultEnv QT_QUICK_FLICKABLE_WHEEL_DECELERATION=10000 //@ pragma DropExpensiveFonts import Quickshell From a13fa8442ba17b18c8ce495ff22e1c0f5ba5ad05 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 7 Oct 2026 22:37:46 +0200 Subject: [PATCH 75/90] fix: qt 6.12 build --- Plugins/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index bca6758..43f93e5 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -5,6 +5,7 @@ find_package(Qt6 REQUIRED COMPONENTS Gui GuiPrivate Quick + CanvasPainter QuickPrivate Concurrent Sql From aef7cd59183f060674e45c4f69c4db3b3d2488b0 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 7 Oct 2026 22:37:46 +0200 Subject: [PATCH 76/90] fix: qt 6.12 build --- Plugins/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index bca6758..43f93e5 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -5,6 +5,7 @@ find_package(Qt6 REQUIRED COMPONENTS Gui GuiPrivate Quick + CanvasPainter QuickPrivate Concurrent Sql From d70fb61e4aa39547005f55d12d2e2aec65929489 Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 8 Oct 2026 12:08:30 +0200 Subject: [PATCH 77/90] gemini glow fixes + positioning for window view --- .../Sidebar/Chat/ChatContent.qml | 12 +++- .../Notifications/Sidebar/Chat/ChatInput.qml | 6 +- .../Sidebar/Chat/Content/ChatHost.qml | 6 +- .../Sidebar/Chat/SidebarView.qml | 8 +-- Modules/Notifications/Sidebar/Content.qml | 60 +++++++++++++++++- assets/shaders/geminiglow.frag | 28 ++++++-- assets/shaders/geminiglow.frag.qsb | Bin 3721 -> 4278 bytes 7 files changed, 102 insertions(+), 18 deletions(-) diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 66cbf63..3fdf1db 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -541,13 +541,16 @@ Item { bg.border.color: Colors.palette.m3outlineVariant bg.color: Colors.tPalette.m3surfaceContainerLowest fastGlow: Chat.busy + glow.opacity: input.activeFocus && (!Chat.busy || ChatState.isWindow) font: Tokens.font.body.small implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) focus: true placeholderText: qsTr("Send a message") sendIcon.font: Tokens.font.icon.medium - sendIcon.icon: "arrow_upward" + sendIcon.icon: Chat.busy ? "stop" : "arrow_upward" sendIcon.padding: Tokens.padding.extraSmall + sendIcon.opacity: text || Chat.busy ? 1.0 : 0.5 + sendIcon.enabled: text || Chat.busy Component.onCompleted: { root.focusInput(); @@ -568,6 +571,11 @@ Item { } } } - onSendPressed: root.send(text) + onButtonPressed: { + if (Chat.busy) + Chat.stop(); + else + root.send(text); + } } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatInput.qml b/Modules/Notifications/Sidebar/Chat/ChatInput.qml index 34d2275..ceb4e77 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatInput.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatInput.qml @@ -8,10 +8,11 @@ TextAreaBase { readonly property alias bg: bg readonly property alias sendIcon: sendIcon + readonly property alias glow: glow property bool fastGlow: false - signal sendPressed + signal buttonPressed bottomPadding: Tokens.padding.large leftPadding: Tokens.padding.extraLarge @@ -39,6 +40,7 @@ TextAreaBase { GeminiGlow { id: glow glowSize: 32 + z: 5 opacity: root.activeFocus ? 1 : 0 visible: opacity > 0 @@ -131,7 +133,7 @@ TextAreaBase { } } - onClicked: root.sendPressed() + onClicked: root.buttonPressed() } TapHandler { diff --git a/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml b/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml index d708ce5..3ee1c15 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ChatHost.qml @@ -34,8 +34,12 @@ Item { }); const attach = () => { - incubator.object.anchors.fill = container; + incubator.object.anchors.top = container.top; + incubator.object.anchors.bottom = container.bottom; + incubator.object.anchors.margins = Tokens.padding.medium; + incubator.object.anchors.horizontalCenter = container.horizontalCenter; incubator.object.requestClose.connect(root.requestClose); + incubator.object.implicitWidth = Qt.binding(() => Math.max(Math.min(container.width - Tokens.padding.medium * 2, 800), Config.sidebar.sizes.width)); currentItem = incubator.object; root.loading = false; if (!noAnim) diff --git a/Modules/Notifications/Sidebar/Chat/SidebarView.qml b/Modules/Notifications/Sidebar/Chat/SidebarView.qml index afc93d9..748a3ae 100644 --- a/Modules/Notifications/Sidebar/Chat/SidebarView.qml +++ b/Modules/Notifications/Sidebar/Chat/SidebarView.qml @@ -126,9 +126,8 @@ Item { z: 0 anchors.top: parent.top anchors.bottom: parent.bottom - anchors.margins: Tokens.padding.medium anchors.right: parent.right - implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 - anchors.margins * 2 + implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 states: State { name: "wide" @@ -151,10 +150,7 @@ Item { ChatHost { id: convHost - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - implicitWidth: Math.max(Math.min(parent.width, 800), Config.sidebar.sizes.width) + anchors.fill: parent clip: true onLoadingChanged: if (!loading) diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 1a6cea8..41a6728 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -1,6 +1,7 @@ pragma ComponentBehavior: Bound import ZShell.Config +import ZShell.Llm import QtQuick import QtQuick.Layouts import qs.Components @@ -35,10 +36,10 @@ Item { visible: height > 0 } - CustomClippingRect { + Item { Layout.fillHeight: true Layout.fillWidth: true - radius: Tokens.rounding.small + clip: true Item { id: pages @@ -58,6 +59,7 @@ Item { } CustomRect { + id: bg anchors.fill: parent color: Colors.tPalette.m3surfaceContainerLow radius: Tokens.rounding.small @@ -96,11 +98,63 @@ Item { CustomRect { anchors.fill: parent color: Colors.tPalette.m3surfaceContainerLow - radius: Tokens.rounding.small + radius: Tokens.rounding.large ChatPanel { anchors.fill: parent } + + GeminiGlow { + innerGlow: 50 + z: 5 + opacity: Chat.busy ? 1.0 : 0.0 + glowSize: 0 + visible: opacity > 0 && !ChatState.isWindow + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: fadeMask + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Rectangle { + id: fadeMask + + readonly property real fadeStartPos: { + const h = Math.max(1, height); + const nh = (h - h / 3) / h; + return nh; + } + + anchors.fill: parent + layer.enabled: true + visible: false + + gradient: Gradient { + orientation: Gradient.Vertical + + GradientStop { + color: Qt.rgba(0, 0, 0, 0) + position: 0 + } + + GradientStop { + color: Qt.rgba(0, 0, 0, 0) + position: fadeMask.fadeStartPos + } + + GradientStop { + color: Qt.rgba(0, 0, 0, 1.0) + position: 1.0 + } + } + } } } } diff --git a/assets/shaders/geminiglow.frag b/assets/shaders/geminiglow.frag index ea19795..1edf7f7 100644 --- a/assets/shaders/geminiglow.frag +++ b/assets/shaders/geminiglow.frag @@ -50,6 +50,18 @@ void main() float r = clamp(radius, 0.0, min(rectSize.x, rectSize.y) * 0.5); float d = sdRoundBox(p, rectSize * 0.5, r); + // ~1px antialiasing width + float aa = max(fwidth(d), 0.75); + + // Coverage of the shape: 1 inside, 0 outside, soft across the edge + float inside = 1.0 - smoothstep(-aa, aa, d); + + // Only skip work when clearly outside and there's no outer glow + if (glowSize <= 0.0 && d > aa) { + fragColor = vec4(0.0); + return; + } + float ang = atan(p.y, p.x); float a2 = phase * TAU; float u = ang / TAU + 0.5 + phase; @@ -59,20 +71,28 @@ void main() float g = 0.0; if (d > 0.0) { float t = clamp(d / max(glowSize, 0.001), 0.0, 1.0); - g = pow(1.0 - t, 3.0); + g = (glowSize > 0.0) ? pow(1.0 - t, 3.0) : 0.0; } else if (innerGlow > 0.0) { - g = exp(d / innerGlow * 3.0); + float t = clamp(-d / innerGlow, 0.0, 1.0); + g = pow(1.0 - t, 3.0); } float pulse = 0.9 + 0.1 * sin(a2 * 2.0 + ang); g *= pulse; + // Line: inside only, with a soft inner edge AND a soft outer edge float line = 0.0; - if (lineWidth > 0.0) - line = 1.0 - smoothstep(0.0, lineWidth, abs(d)); + if (lineWidth > 0.0) { + float inner = 1.0 - smoothstep(lineWidth - aa, lineWidth + aa, -d); + line = inner * inside; + } float a = clamp(g * intensity + line * 0.9, 0.0, 1.0); + // Antialias the outer silhouette when there is no outer glow + if (glowSize <= 0.0) + a *= inside; + col = mix(col, vec3(1.0), line * 0.25); a += (hash(gl_FragCoord.xy) - 0.5) / 255.0; diff --git a/assets/shaders/geminiglow.frag.qsb b/assets/shaders/geminiglow.frag.qsb index 233240e6fd7552d50ef537e097b4a5c3c2609215..487934a0a09ce02c73c55682c7e6a8b00b3fd621 100644 GIT binary patch literal 4278 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 From 873b2eb01b2a66e3988c074b89bccd389d26deef Mon Sep 17 00:00:00 2001 From: zach Date: Thu, 8 Oct 2026 12:09:50 +0200 Subject: [PATCH 78/90] fix: dashboard tabs scroll now only loads previous item + next item --- Modules/Dashboard/Content.qml | 81 ++++++++++++++--------------------- 1 file changed, 33 insertions(+), 48 deletions(-) diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index 97c322d..69b0c2f 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -81,9 +81,9 @@ Item { anchors.right: parent.right anchors.top: tabs.bottom anchors.topMargin: Tokens.spacing.small - clip: view.currentIndex !== 3 || view.contentX !== (view.currentItem?.x ?? 0) + clip: view.currentIndex !== 3 || (!view.currentItem?.settled ?? true) - Flickable { + Item { id: view readonly property int currentIndex: root.screenState.dashboardTab @@ -93,56 +93,41 @@ Item { } anchors.fill: parent - contentHeight: row.implicitHeight - contentWidth: row.implicitWidth - contentX: currentItem?.x ?? 0 implicitHeight: currentItem?.implicitHeight ?? 0 implicitWidth: currentItem?.implicitWidth ?? 0 - interactive: false - Behavior on contentX { - Anim { - type: Anim.DefaultEffects + Repeater { + id: repeater + + delegate: Loader { + id: paneLoader + + required property int index + required property var modelData + + readonly property real targetX: index < view.currentIndex ? -view.implicitWidth : index > view.currentIndex ? view.implicitWidth : 0 + readonly property bool settled: x === targetX + + active: index === view.currentIndex || opacity > 0 + opacity: index === view.currentIndex ? 1 : 0 + sourceComponent: modelData.component + visible: opacity > 0 + x: targetX + y: 0 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on x { + Anim { + type: Anim.DefaultEffects + } + } } - } - - onContentXChanged: { - if (!moving || !currentItem) - return; - - const x = contentX - currentItem.x; - if (x > currentItem.implicitWidth / 2) - root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1); - else if (x < -currentItem.implicitWidth / 2) - root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); - } - - RowLayout { - id: row - - Repeater { - id: repeater - - delegate: Loader { - id: paneLoader - - required property int index - required property var modelData - - Layout.alignment: Qt.AlignTop - sourceComponent: modelData.component - - Component.onCompleted: active = Qt.binding(() => { - if (index === view.currentIndex) - return true; - const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); - const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); - return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); - }) - } - model: ScriptModel { - values: root.dashboardTabs - } + model: ScriptModel { + values: root.dashboardTabs } } From 38839172281329a9f7f54277fb587d16c3263784 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 11 Oct 2026 08:42:05 +0200 Subject: [PATCH 79/90] fix: ui no longer blocks when volumes are unreachable + add support for network and fuse volumes --- Components/Menu.qml | 25 +-- Modules/Resources/Cards/StorageCard.qml | 2 +- Plugins/ZShell/Services/CMakeLists.txt | 1 + Plugins/ZShell/Services/diskinfo.cpp | 12 ++ Plugins/ZShell/Services/diskinfo.hpp | 2 + Plugins/ZShell/Services/storage.cpp | 203 +++++++++++++++++++----- Plugins/ZShell/Services/storage.hpp | 34 ++++ 7 files changed, 226 insertions(+), 53 deletions(-) diff --git a/Components/Menu.qml b/Components/Menu.qml index dc546c3..6a3db28 100644 --- a/Components/Menu.qml +++ b/Components/Menu.qml @@ -124,7 +124,7 @@ MouseArea { model: root.items CustomRect { - id: item + id: menuItem readonly property bool active: modelData === root?.active required property int index @@ -142,13 +142,13 @@ MouseArea { } StateLayer { - color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface enabled: root.expanded onClicked: { - root.itemSelected(item.modelData); - root.active = item.modelData; - item.modelData.clicked(); + root.itemSelected(menuItem.modelData); + root.active = menuItem.modelData; + menuItem.modelData.clicked(); root.expanded = false; } } @@ -162,26 +162,27 @@ MouseArea { MaterialIcon { Layout.alignment: Qt.AlignVCenter - color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant - text: item.modelData?.icon ?? "" + color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant + text: menuItem.modelData?.icon ?? "" + visible: text !== "" } CustomText { Layout.alignment: Qt.AlignVCenter Layout.fillWidth: true - color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface - text: item.modelData?.text ?? "" + color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + text: menuItem.modelData?.text ?? "" } Loader { Layout.alignment: Qt.AlignVCenter - active: item.modelData?.trailingIcon.length > 0 + active: menuItem.modelData?.trailingIcon.length > 0 asynchronous: true visible: active sourceComponent: MaterialIcon { - color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant - text: item.modelData.trailingIcon + color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant + text: menuItem.modelData.trailingIcon } } } diff --git a/Modules/Resources/Cards/StorageCard.qml b/Modules/Resources/Cards/StorageCard.qml index 83b4ecc..a1a1e57 100644 --- a/Modules/Resources/Cards/StorageCard.qml +++ b/Modules/Resources/Cards/StorageCard.qml @@ -121,6 +121,6 @@ CustomRect { activeIcon: "storage" icon: modelData === Storage.primaryDisk ? "check" : "" - text: modelData.mount + text: modelData.name } } diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index 2e99e62..73c7cce 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -25,6 +25,7 @@ qml_module(ZShell-services LIBRARIES Qt6::Core Qt6::Qml + Qt6::Concurrent PkgConfig::Pipewire PkgConfig::Aubio PkgConfig::Cava diff --git a/Plugins/ZShell/Services/diskinfo.cpp b/Plugins/ZShell/Services/diskinfo.cpp index dae8468..dfaff61 100644 --- a/Plugins/ZShell/Services/diskinfo.cpp +++ b/Plugins/ZShell/Services/diskinfo.cpp @@ -24,6 +24,18 @@ QString DiskInfo::mount() const { return m_mount; } +// Display label. mount stays the full path because it is the identity used to +// match entries across ticks, so this only shortens what the UI shows. For +// physical disks mount is already the device name and is returned unchanged. +QString DiskInfo::name() const { + const qsizetype slash = m_mount.lastIndexOf(QLatin1Char('/')); + if (slash < 0) { + return m_mount; + } + const QString leaf = m_mount.mid(slash + 1); + return leaf.isEmpty() ? m_mount : leaf; +} + qreal DiskInfo::used() const { return static_cast(m_usedBytes) / kKib; } diff --git a/Plugins/ZShell/Services/diskinfo.hpp b/Plugins/ZShell/Services/diskinfo.hpp index 3b51b0b..4bae0bb 100644 --- a/Plugins/ZShell/Services/diskinfo.hpp +++ b/Plugins/ZShell/Services/diskinfo.hpp @@ -11,6 +11,7 @@ class DiskInfo : public QObject { QML_UNCREATABLE("DiskInfo is created by DiskUsage") Q_PROPERTY(QString mount READ mount CONSTANT) + Q_PROPERTY(QString name READ name CONSTANT) Q_PROPERTY(qreal used READ used NOTIFY usedChanged) Q_PROPERTY(qreal total READ total NOTIFY totalChanged) Q_PROPERTY(qreal free READ free NOTIFY freeChanged) @@ -26,6 +27,7 @@ class DiskInfo : public QObject { QObject* parent = nullptr); [[nodiscard]] QString mount() const; + [[nodiscard]] QString name() const; [[nodiscard]] qreal used() const; [[nodiscard]] qreal total() const; [[nodiscard]] qreal free() const; diff --git a/Plugins/ZShell/Services/storage.cpp b/Plugins/ZShell/Services/storage.cpp index 4e9d52f..5a3884a 100644 --- a/Plugins/ZShell/Services/storage.cpp +++ b/Plugins/ZShell/Services/storage.cpp @@ -5,9 +5,12 @@ #include #include #include +#include #include #include #include +#include +#include #include #include @@ -17,12 +20,27 @@ namespace ZShell::services { namespace { +// A scan that finishes quickly is not worth showing as loading. Only a scan that +// stays in flight this long is reported, so the UI does not flicker every tick. +constexpr int kLoadingDelayMs = 300; + struct Accum { quint64 usedBytes = 0; quint64 totalBytes = 0; bool hasRoot = false; }; +QThreadPool* storagePool() { + // Single thread: a scan blocked on an unreachable mount should not take a + // worker away from the global pool. + static QThreadPool* pool = [] { + auto* p = new QThreadPool(); + p->setMaxThreadCount(1); + return p; + }(); + return pool; +} + [[nodiscard]] QString sysfsRealPath(uint major, uint minor) { const QString link = QStringLiteral("/sys/dev/block/%1:%2").arg(major).arg(minor); @@ -103,7 +121,14 @@ QStringList resolveByDevt(uint major, uint minor, int depth) { } // namespace -Storage::Storage(QObject* parent) : TickingService(parent) {} +Storage::Storage(QObject* parent) : TickingService(parent) { + m_loadingTimer.setSingleShot(true); + QObject::connect(&m_loadingTimer, &QTimer::timeout, this, [this] { + if (m_watcher) { + setLoading(true); + } + }); +} qreal Storage::percentage() const { qreal totalUsed = 0.0; @@ -115,6 +140,18 @@ qreal Storage::percentage() const { return totalSize > 0.0 ? totalUsed / totalSize : 0.0; } +bool Storage::loading() const { + return m_loading; +} + +void Storage::setLoading(bool value) { + if (m_loading == value) { + return; + } + m_loading = value; + Q_EMIT loadingChanged(); +} + bool Storage::sameOrder(const QList& a, const QList& b) { if (a.size() != b.size()) { return false; @@ -162,18 +199,41 @@ DiskInfo* Storage::primaryDisk() const { bool Storage::isPseudoFs(QByteArrayView fsType) { static constexpr const char* kPseudo[] = { - "tmpfs", "devtmpfs", "proc", "sysfs", "cgroup", - "cgroup2", "overlay", "squashfs", "devpts", "mqueue", - "ramfs", "rpc_pipefs", "autofs", "configfs", "debugfs", - "tracefs", "securityfs", "pstore", "bpf", "binfmt_misc", - "hugetlbfs", "fusectl", "efivarfs", "selinuxfs", + "tmpfs", + "devtmpfs", + "proc", + "sysfs", + "cgroup", + "cgroup2", + "overlay", + "squashfs", + "devpts", + "mqueue", + "ramfs", + "rpc_pipefs", + "autofs", + "configfs", + "debugfs", + "tracefs", + "securityfs", + "pstore", + "bpf", + "binfmt_misc", + "hugetlbfs", + "fusectl", + "efivarfs", + "selinuxfs", + // Real FUSE mounts (rclone, sshfs, ...) are reported like any other + // volume, so only the virtual ones are excluded here. + "fuse.gvfsd-fuse", + "fuse.portal", }; for (const char* p : kPseudo) { if (fsType == QByteArrayView(p)) { return true; } } - return fsType.startsWith(QByteArrayView("fuse.")); + return false; } QStringList Storage::resolveToPhysicalDisks(const QString& devicePath) { @@ -190,18 +250,14 @@ QStringList Storage::resolveToPhysicalDisks(const QString& devicePath) { return resolveByDevt(major(st.st_rdev), minor(st.st_rdev)); } -void Storage::tick() { - const qreal prevPercentage = percentage(); - QHash byDisk; - - // Multiple mounts can share a single backing filesystem (btrfs subvolumes, - // bind mounts, etc.) and each one reports identical bytesTotal/bytesAvailable. - // Dedupe by source device so the filesystem only contributes once per disk. +StorageScan Storage::scanVolumes() { struct DeviceEntry { quint64 totalBytes = 0; quint64 usedBytes = 0; bool hasRoot = false; - QByteArray device; + // Mount point of the first mount seen for this device. Used as the entry + // key when the device has no backing block device (network mount). + QString mount; }; QHash byDevice; @@ -215,38 +271,120 @@ void Storage::tick() { continue; } - const QByteArray device = v.device(); const auto totalBytes = static_cast(v.bytesTotal()); const auto availBytes = static_cast(v.bytesAvailable()); const quint64 usedBytes = totalBytes > availBytes ? totalBytes - availBytes : 0; const bool isRoot = v.rootPath() == QStringLiteral("/"); - DeviceEntry& e = byDevice[device]; - e.device = device; + DeviceEntry& e = byDevice[v.device()]; e.totalBytes = totalBytes; e.usedBytes = usedBytes; e.hasRoot = e.hasRoot || isRoot; + if (e.mount.isEmpty()) { + e.mount = v.rootPath(); + } } + QHash byEntry; + + // Multiple mounts can share a single backing filesystem (btrfs subvolumes, + // bind mounts, etc.) and each one reports identical bytesTotal/bytesAvailable. + // Dedupe by source device so the filesystem only contributes once per disk. for (auto it = byDevice.constBegin(); it != byDevice.constEnd(); ++it) { const DeviceEntry& e = it.value(); const QStringList disks = - resolveToPhysicalDisks(QString::fromLocal8Bit(e.device)); + resolveToPhysicalDisks(QString::fromLocal8Bit(it.key())); if (disks.isEmpty()) { + // Network mounts (and any other filesystem with no backing block + // device) have no physical disk to attribute the usage to, so the + // mount itself becomes the entry. + Accum& a = byEntry[e.mount]; + a.usedBytes += e.usedBytes; + a.totalBytes += e.totalBytes; + a.hasRoot = a.hasRoot || e.hasRoot; continue; } for (const QString& d : disks) { if (d.startsWith(QStringLiteral("zram"))) { continue; } - Accum& a = byDisk[d]; + Accum& a = byEntry[d]; a.usedBytes += e.usedBytes; a.totalBytes += e.totalBytes; a.hasRoot = a.hasRoot || e.hasRoot; } } + StorageScan scan; + scan.reserve(byEntry.size()); + for (auto it = byEntry.constBegin(); it != byEntry.constEnd(); ++it) { + StorageEntry entry; + entry.mount = it.key(); + entry.usedBytes = it.value().usedBytes; + entry.totalBytes = it.value().totalBytes; + entry.hasRoot = it.value().hasRoot; + scan.append(entry); + } + + std::sort( + scan.begin(), + scan.end(), + [](const StorageEntry& a, const StorageEntry& b) { + if (a.hasRoot != b.hasRoot) { + return a.hasRoot; + } + return a.mount < b.mount; + }); + + return scan; +} + +void Storage::tick() { + if (m_watcher) { + // Still waiting on a mount that has not answered. Starting another scan + // here would just queue blocked threads behind the first. + return; + } + + beginScan(); +} + +void Storage::beginScan() { + m_loadingTimer.start(kLoadingDelayMs); + + m_watcher = new QFutureWatcher(this); + QObject::connect( + m_watcher, + &QFutureWatcher::finished, + this, + &Storage::onScanFinished); + + m_watcher->setFuture( + QtConcurrent::run(storagePool(), &Storage::scanVolumes)); +} + +void Storage::onScanFinished() { + QFutureWatcher* watcher = m_watcher; + m_watcher = nullptr; + if (!watcher) { + return; + } + StorageScan scan = watcher->result(); + watcher->deleteLater(); + + m_loadingTimer.stop(); + + // scan is already ordered (root disk first), so applying it in order keeps the + // QML model in the same order as before. + applyScan(scan); + + setLoading(false); +} + +void Storage::applyScan(const StorageScan& scan) { + const qreal prevPercentage = percentage(); + QHash existing; existing.reserve(m_disks.size()); for (DiskInfo* d : std::as_const(m_disks)) { @@ -254,32 +392,17 @@ void Storage::tick() { } QList next; - next.reserve(byDisk.size()); - for (auto it = byDisk.constBegin(); it != byDisk.constEnd(); ++it) { - if (DiskInfo* survivor = existing.take(it.key())) { - survivor->update( - it.value().usedBytes, - it.value().totalBytes, - it.value().hasRoot); + next.reserve(scan.size()); + for (const StorageEntry& e : scan) { + if (DiskInfo* survivor = existing.take(e.mount)) { + survivor->update(e.usedBytes, e.totalBytes, e.hasRoot); next.append(survivor); } else { next.append(new DiskInfo( - it.key(), - it.value().usedBytes, - it.value().totalBytes, - it.value().hasRoot, - this)); + e.mount, e.usedBytes, e.totalBytes, e.hasRoot, this)); } } - std::sort( - next.begin(), next.end(), [](const DiskInfo* a, const DiskInfo* b) { - if (a->hasRoot() != b->hasRoot()) { - return a->hasRoot(); - } - return a->mount() < b->mount(); - }); - bool manualCleared = false; if (DiskInfo* m = m_manualPrimaryDisk.data(); m && existing.contains(m->mount())) { diff --git a/Plugins/ZShell/Services/storage.hpp b/Plugins/ZShell/Services/storage.hpp index 0c61466..bd964ec 100644 --- a/Plugins/ZShell/Services/storage.hpp +++ b/Plugins/ZShell/Services/storage.hpp @@ -7,10 +7,27 @@ #include #include #include +#include +#include #include +#include + +template class QFutureWatcher; namespace ZShell::services { +struct StorageEntry { + // Key for the entry: sysfs block device name, or mount point for a mount with + // no backing block device (network mount). This is what DiskInfo reports as + // its mount, so the UI can show either. + QString mount; + quint64 usedBytes = 0; + quint64 totalBytes = 0; + bool hasRoot = false; +}; + +using StorageScan = QVector; + class Storage : public TickingService { Q_OBJECT QML_ELEMENT @@ -26,6 +43,7 @@ class Storage : public TickingService { Q_PROPERTY( ZShell::services::DiskInfo* primaryDisk READ primaryDisk NOTIFY primaryDiskChanged) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) public: explicit Storage(QObject* parent = nullptr); @@ -35,12 +53,14 @@ class Storage : public TickingService { [[nodiscard]] DiskInfo* manualPrimaryDisk() const; void setManualPrimaryDisk(DiskInfo* disk); [[nodiscard]] DiskInfo* primaryDisk() const; + [[nodiscard]] bool loading() const; signals: void disksChanged(); void percentageChanged(); void manualPrimaryDiskChanged(); void primaryDiskChanged(); + void loadingChanged(); protected: void tick() override; @@ -55,8 +75,22 @@ class Storage : public TickingService { static qsizetype disksCount(QQmlListProperty* prop); static DiskInfo* disksAt(QQmlListProperty* prop, qsizetype i); + // Runs on a worker thread. statvfs on a mount whose backing storage is + // unreachable can block for the whole network timeout, so the scan must not + // run on the GUI thread. The result is plain data, applied on this object's + // thread once it arrives. + [[nodiscard]] static StorageScan scanVolumes(); + + void beginScan(); + void onScanFinished(); + void applyScan(const StorageScan& scan); + void setLoading(bool value); + QList m_disks; QPointer m_manualPrimaryDisk; + QFutureWatcher* m_watcher = nullptr; + QTimer m_loadingTimer; + bool m_loading = false; }; } // namespace ZShell::services From 255db9e5334528759d6f42f70579cbe75068a223 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 12 Oct 2026 15:54:32 +0200 Subject: [PATCH 80/90] fix: ignore stale coordinate data for panels + convert wavyline to use new qt 6.12 canvas painter api --- Drawers/Windows.qml | 3 + Modules/Bar/Bar.qml | 7 +- Modules/Resources/Cards/MemoryCard.qml | 5 +- Plugins/ZShell/Blobs/blobgroup2.cpp | 36 ++---- Plugins/ZShell/Blobs/blobgroup2.hpp | 9 +- Plugins/ZShell/Blobs/blobmaterial.cpp | 7 +- Plugins/ZShell/Blobs/blobmaterial.hpp | 5 - Plugins/ZShell/Blobs/blobrect2.hpp | 23 +++- Plugins/ZShell/Blobs/blobshape2.hpp | 4 +- Plugins/ZShell/Blobs/shaders/blob.frag | 32 +---- Plugins/ZShell/Components/CMakeLists.txt | 2 + Plugins/ZShell/Components/wavyline.cpp | 105 ++-------------- Plugins/ZShell/Components/wavyline.hpp | 20 +-- .../ZShell/Components/wavylinerenderer.cpp | 116 ++++++++++++++++++ .../ZShell/Components/wavylinerenderer.hpp | 32 +++++ Plugins/ZShell/Internal/CMakeLists.txt | 1 - 16 files changed, 222 insertions(+), 185 deletions(-) create mode 100644 Plugins/ZShell/Components/wavylinerenderer.cpp create mode 100644 Plugins/ZShell/Components/wavylinerenderer.hpp diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index d4f03ef..b4b543c 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -248,6 +248,7 @@ CustomWindow { PanelBg { id: osdBg + atRestOverride: panels.osd.offsetScale < 0.99 deformAmount: 0.1 implicitHeight: panels.osd.height implicitWidth: panels.osd.width @@ -296,6 +297,7 @@ CustomWindow { property real extraExtent: 0.2 + atRestOverride: panels.resources.offsetScale < 0.99 deformAmount: 0.05 implicitHeight: panels.resources.height * (1 + extraExtent) implicitWidth: panels.resources.width * (1 + extraExtent) @@ -449,6 +451,7 @@ CustomWindow { property real deformAmount: 0.15 required property Item panel + atRestOverride: panel?.offsetScale < 0.99 ?? false damping: 28.3 deformScale: (deformAmount * Config.appearance.deform.scale) / 10000 group: blobGroup diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index bd4df6f..856b391 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -17,12 +17,12 @@ GridLayout { required property Wrapper popouts required property ClipWrapper popoutsWrapper required property ShellScreen screen - readonly property int vPadding: 6 required property ScreenState screenState + readonly property int vPadding: 6 function axisCenterOf(item: Item): real { const c = item.mapToItem(root, item.implicitWidth / 2, item.implicitHeight / 2); - return horizontal ? c.x : c.y; + return horizontal ? Math.floor(c.x) : Math.floor(c.y); } function checkPopout(pos: real): void { @@ -210,7 +210,8 @@ GridLayout { roleValue: "network" delegate: EntryWrapper { - NetworkWidget {} + NetworkWidget { + } } } diff --git a/Modules/Resources/Cards/MemoryCard.qml b/Modules/Resources/Cards/MemoryCard.qml index bb56af0..366cfa9 100644 --- a/Modules/Resources/Cards/MemoryCard.qml +++ b/Modules/Resources/Cards/MemoryCard.qml @@ -48,10 +48,11 @@ CustomRect { implicitSize: usageColumn.implicitHeight + thickness + Tokens.padding.largeIncreased * 2 startAngle: -225 sweepAngle: 270 - value: Memory.percentage + value: Memory.percentage / 100 Behavior on clampedVal { - Anim {} + Anim { + } } ColumnLayout { diff --git a/Plugins/ZShell/Blobs/blobgroup2.cpp b/Plugins/ZShell/Blobs/blobgroup2.cpp index 2273733..0f642ed 100644 --- a/Plugins/ZShell/Blobs/blobgroup2.cpp +++ b/Plugins/ZShell/Blobs/blobgroup2.cpp @@ -138,11 +138,16 @@ void BlobGroup2::ensureDataBuilt() { const double extent = static_cast(smoothFactor) * 1.5 + static_cast(m_shadowRadius); - // Group order, no pruning: the shader sees this list for every pixel. + // Group order, no pruning by proximity: the shader sees this list for every pixel. A + // shape that is not pushing is not in the list at all: shapes here are data for the + // group draw, so leaving a panel at rest out means it cannot define any part of the + // edge. A closing panel is still in the list while its curve runs out, even with no + // size left, which is what keeps the bounce. // entryShapes maps a shared entry back to the shape it came from. QVector entryShapes; for (auto* shape : std::as_const(m_shapes)) { + if (!shape->isMoving()) continue; if (shape->width() <= 0 || shape->height() <= 0) continue; if (static_cast(m_data.rects.size()) >= BlobGroupData::kMaxRects) continue; @@ -189,9 +194,6 @@ void BlobGroup2::ensureDataBuilt() { const int count = static_cast(m_data.rects.size()); - // Which shape is the source for the bar edge. - resolveSources(entryShapes); - // Exclusion gates only the smooth blend. for (int i = 0; i < count; ++i) { int mask = 0; @@ -254,8 +256,8 @@ void BlobGroup2::ensureDataBuilt() { ri.radius[3] = std::max(ri.radius[3] * fTl, minR); } - // Inverted rect. The frame term is evaluated for every pixel in the bounds, so the - // group flag cannot be gated per shape. + // Inverted rect. The frame term is evaluated for every pixel in the bounds, + // so the group flag cannot be gated per shape. if (!m_invertedRect) return; auto* inv = static_cast(m_invertedRect); @@ -302,28 +304,6 @@ void BlobGroup2::ensureDataBuilt() { 2.0 * (static_cast(outerHH) + extent))); } -// A point on the bar edge can only have one source. A shape that is not pushing is not -// the source: it still fills its own area, but inside the border strip it must not add -// its own outline to a span another shape is already shaping. That is the case that -// matters — one panel at rest next to one that is bouncing — where the edge showed -// several steps instead of one smooth indent of the closing panel. Only one shape can be -// pushing at a time, so the flag is just that, and the shader gates the strip with it. -void BlobGroup2::resolveSources(const QVector& entryShapes) { - const int count = static_cast(m_data.rects.size()); - - // With nothing pushing there is no span being shaped, so a shape at rest keeps its - // own outline and nothing is gated. - bool anyPushing = false; - for (int i = 0; i < count; ++i) - if (entryShapes[i]->isMoving()) { - anyPushing = true; - break; - } - - for (int i = 0; i < count; ++i) - m_data.rects[i].source = !anyPushing || entryShapes[i]->isMoving(); -} - void BlobGroup2::fillMaterial(BlobMaterial* material) { material->m_paddedX = static_cast(m_bounds.x()); material->m_paddedY = static_cast(m_bounds.y()); diff --git a/Plugins/ZShell/Blobs/blobgroup2.hpp b/Plugins/ZShell/Blobs/blobgroup2.hpp index 988816e..9f922e6 100644 --- a/Plugins/ZShell/Blobs/blobgroup2.hpp +++ b/Plugins/ZShell/Blobs/blobgroup2.hpp @@ -22,11 +22,9 @@ class BlobInvertedRect2; // disagree with the first. That is what makes overlapping shapes read as one // surface - one value, one writer, one alpha per pixel. struct BlobGroupData { - // The shader reads rectData[80] = 16 entries of 5 vec4, and excludeMask is a - // 32-bit mask. The shared list is capped at 16, in group order, so the cap is the - // same list for every pixel. Each entry is either a source for its span or not; the - // flag is in the third vec4 alongside the screen half-extents, so no extra data is - // needed. + // The shader reads rectData[80] = 16 entries, and excludeMask is a 32-bit + // mask. The shared list is capped at 16, in group order, so the cap is the + // same list for every pixel. static constexpr int kMaxRects = 16; QVector rects; @@ -111,7 +109,6 @@ class BlobGroup2 : public QQuickItem { private: void ensurePhysicsUpdated(); void ensureDataBuilt(); - void resolveSources(const QVector& entryShapes); void fillMaterial(BlobMaterial* material); qreal m_smoothing = 32.0; diff --git a/Plugins/ZShell/Blobs/blobmaterial.cpp b/Plugins/ZShell/Blobs/blobmaterial.cpp index cc21de9..528d617 100644 --- a/Plugins/ZShell/Blobs/blobmaterial.cpp +++ b/Plugins/ZShell/Blobs/blobmaterial.cpp @@ -92,12 +92,7 @@ bool BlobMaterialShader::updateUniformData( memcpy(&maskAsFloat, &r.excludeMask, sizeof(float)); const float d0[4] = {r.cx, r.cy, r.hw, r.hh}; const float d1[4] = {maskAsFloat, r.offsetX, r.offsetY, r.minEig}; - const float d3[4] = { - r.screenHalfX, - r.screenHalfY, - r.source ? 1.0f : 0.0f, - 0.0f, - }; + const float d3[4] = {r.screenHalfX, r.screenHalfY, 0.0f, 0.0f}; memcpy(buf->data() + base, d0, 16); memcpy(buf->data() + base + 16, d1, 16); memcpy(buf->data() + base + 32, r.invDeform, 16); diff --git a/Plugins/ZShell/Blobs/blobmaterial.hpp b/Plugins/ZShell/Blobs/blobmaterial.hpp index 659a7e6..19536a5 100644 --- a/Plugins/ZShell/Blobs/blobmaterial.hpp +++ b/Plugins/ZShell/Blobs/blobmaterial.hpp @@ -17,11 +17,6 @@ struct BlobRectData { // Bitmask of indices in this rect's m_cachedRects that mutually exclude (or are excluded by) this rect. // Used by the shader to skip smin between excluded pairs. int excludeMask = 0; - // Whether this entry is pushing on the bar edge. Only the shape that is pushing can - // shape the edge; a shape at rest still fills its own area, but the shader gives it - // no contribution inside the border strip, so it cannot add its own outline to a span - // another shape is shaping. - bool source = true; }; class BlobMaterial : public QSGMaterial { diff --git a/Plugins/ZShell/Blobs/blobrect2.hpp b/Plugins/ZShell/Blobs/blobrect2.hpp index 2cd29dc..16832f0 100644 --- a/Plugins/ZShell/Blobs/blobrect2.hpp +++ b/Plugins/ZShell/Blobs/blobrect2.hpp @@ -22,6 +22,9 @@ class BlobRect2 : public BlobShape2 { Q_PROPERTY( QQmlListProperty excludeCorners READ excludeCorners NOTIFY excludeCornersChanged) + Q_PROPERTY( + bool atRestOverride READ atRestOverride WRITE setAtRestOverride NOTIFY + atRestOverrideChanged) Q_PROPERTY( qreal topLeftRadius READ topLeftRadius WRITE setTopLeftRadius NOTIFY topLeftRadiusChanged) @@ -69,11 +72,24 @@ class BlobRect2 : public BlobShape2 { QQmlListProperty exclude(); QQmlListProperty excludeCorners(); + [[nodiscard]] bool atRestOverride() const { return m_atRestOverride; } + + void setAtRestOverride(bool o) { + if (m_atRestOverride == o) return; + m_atRestOverride = o; + emit atRestOverrideChanged(); + if (m_group) m_group->markDirty(); + } + bool isExcluded(const BlobShape2* other) const override; bool isCornerExcluded(const BlobShape2* other) const override; // The shape that is pushing is the one that can shape the bar edge. A shape that is // retiring still counts, because the retire curve is still moving the surface. - bool isMoving() const override { return m_physicsActive || m_retiring; } + // atRestOverride keeps the shape in the group's data even at rest; when it is false + // the actual spring state decides, so it is not an override of the state itself. + bool isMoving() const override { + return m_atRestOverride || m_physicsActive || m_retiring; + } void cornerRadii(float out[4]) const override; [[nodiscard]] qreal topLeftRadius() const { return m_topLeftRadius; } @@ -100,6 +116,7 @@ class BlobRect2 : public BlobShape2 { void deformScaleChanged(); void excludeChanged(); void excludeCornersChanged(); + void atRestOverrideChanged(); void topLeftRadiusChanged(); void topRightRadiusChanged(); void bottomLeftRadiusChanged(); @@ -119,6 +136,10 @@ class BlobRect2 : public BlobShape2 { bool m_physicsActive = false; bool m_hasPrevPos = false; + // Set from QML. Keeps the shape in the group's data while it is at rest; when it is + // false the spring state decides on its own. + bool m_atRestOverride = false; + bool m_retiring = false; QElapsedTimer m_retireElapsed; float m_retire00 = 1.0f; diff --git a/Plugins/ZShell/Blobs/blobshape2.hpp b/Plugins/ZShell/Blobs/blobshape2.hpp index caadd8f..230b660 100644 --- a/Plugins/ZShell/Blobs/blobshape2.hpp +++ b/Plugins/ZShell/Blobs/blobshape2.hpp @@ -56,8 +56,8 @@ class BlobShape2 : public QQuickItem { virtual bool isExcluded(const BlobShape2*) const { return false; } - // Whether this shape is currently pushing on a border. Only the shape that is pushing - // can shape the bar edge, so this decides the source flag. + // Whether this shape is part of the group's data. Only a pushing shape is, so a shape + // at rest has no part in the draw. BlobRect2 also honours the QML atRestOverride. virtual bool isMoving() const { return false; } virtual bool isCornerExcluded(const BlobShape2* /*other*/) const { diff --git a/Plugins/ZShell/Blobs/shaders/blob.frag b/Plugins/ZShell/Blobs/shaders/blob.frag index 791f272..1231bc6 100644 --- a/Plugins/ZShell/Blobs/shaders/blob.frag +++ b/Plugins/ZShell/Blobs/shaders/blob.frag @@ -65,19 +65,6 @@ float smaxSharpA(float a, float b, float k) { void main() { vec2 pixel = vec2(paddedX, paddedY) + qt_TexCoord0 * vec2(paddedW, paddedH); - // The frame distances are needed before the per-rect pass. In the border strip the - // boundary is the frame, displaced only by the shape that is pushing, so a shape that - // is not the source must not define the boundary there. It still fills the inner area, - // so this only gates the strip, which is the bar edge. - float dOuter = 0.0; - float dInner = 0.0; - bool inStrip = false; - if (hasInverted != 0) { - dOuter = sdBox(pixel, invertedOuter.xy, invertedOuter.zw) - 1.0; - dInner = sdRoundedBox(pixel, invertedInner.xy, invertedInner.zw, invertedRadius); - inStrip = dOuter < 0.0 && dInner > 0.0; - } - // Phase 1: compute per-rect SDF, and track which rect owns the pixel for the // per-shape path (BlobGroup). The single group draw (BlobGroup2) has no owner: // myIndex stays -2, so the ownership test is skipped and this pass writes every @@ -91,17 +78,10 @@ void main() { vec4 props = rectData[i * 5 + 1]; // excludeMask(int bits), offsetX, offsetY, minEig vec4 invDm = rectData[i * 5 + 2]; // inverse deform matrix - vec4 sh = rectData[i * 5 + 3]; // screenHalfX, screenHalfY, isSource, 0 + vec4 sh = rectData[i * 5 + 3]; // screenHalfX, screenHalfY, 0, 0 vec4 radii = rectData[i * 5 + 4]; // effective per-corner radii (tr, br, bl, tl) - // A shape that is not pushing does not shape the bar edge, so inside the strip it - // must not add its own outline to a span another shape is already shaping. - if (inStrip && sh.z < 0.5) { - dArr[i] = 1e10; - continue; - } - // Offset center for asymmetric deformation vec2 center = rect.xy + props.yz; @@ -202,6 +182,10 @@ void main() { } if (hasInverted != 0) { + float dOuter = sdBox(pixel, invertedOuter.xy, invertedOuter.zw) - 1.0; + float dInner = + sdRoundedBox(pixel, invertedInner.xy, invertedInner.zw, invertedRadius); + // Border sinks: track the opposite rect edge, clamped to border thickness float innerTop = invertedInner.y - invertedInner.w; float innerBot = invertedInner.y + invertedInner.w; @@ -214,10 +198,6 @@ void main() { float sinkValue = 0.0; for (int i = 0; i < rectCount; i++) { - // Only a shape that is pushing can move the border. - if (rectData[i * 5 + 3].z < 0.5) - continue; - vec4 rect = rectData[i * 5]; vec4 sinkProps = rectData[i * 5 + 1]; vec2 sinkSh = rectData[i * 5 + 3].xy; @@ -228,7 +208,7 @@ void main() { // Delay sink to absorb smin blend depth (cubic smin max = k/6) float preOff = smoothFactor * (1.0 / 6.0); - // Top border: track rect's BOTTOM edge, clamped to border thickness + // Top border: track rect's BOTTOM edge, only within border thickness float topPen = clamp(innerTop - (ctr.y + sinkSh.y) - preOff, 0.0, innerTop - outerTop); diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index c4f2154..a0b7276 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -3,6 +3,7 @@ qml_module(ZShell-components SOURCES lazylistview.hpp lazylistview.cpp wavyline.hpp wavyline.cpp + wavylinerenderer.hpp wavylinerenderer.cpp buttonrow.hpp buttonrow.cpp carouselview.hpp carouselview.cpp sparklinecontent.hpp sparklinecontent.cpp @@ -11,5 +12,6 @@ qml_module(ZShell-components LIBRARIES Qt::Quick Qt::QuickPrivate + Qt::CanvasPainter ZShell-internal ) diff --git a/Plugins/ZShell/Components/wavyline.cpp b/Plugins/ZShell/Components/wavyline.cpp index 8619f05..e5c2539 100644 --- a/Plugins/ZShell/Components/wavyline.cpp +++ b/Plugins/ZShell/Components/wavyline.cpp @@ -1,12 +1,10 @@ #include "wavyline.hpp" - -#include -#include +#include "wavylinerenderer.hpp" namespace ZShell::controls { WavyLine::WavyLine(QQuickItem* parent) - : QQuickPaintedItem(parent) + : QCanvasPainterItem(parent) , m_lineWidth(4) , m_amplitudeMultiplier(0.5) , m_frequency(6) @@ -18,10 +16,13 @@ WavyLine::WavyLine(QQuickItem* parent) , m_startAngle(0) , m_fullAngle(360) , m_radius(-1) - , m_value(1) - , m_startAngleRad(0) - , m_fullAngleRad(2 * M_PI) { - setAntialiasing(true); + , m_value(1) { + setFillColor(Qt::transparent); + setAlphaBlending(true); +} + +QCanvasPainterItemRenderer* WavyLine::createItemRenderer() const { + return new WavyLineRenderer; } int WavyLine::lineWidth() const { @@ -127,7 +128,6 @@ qreal WavyLine::startAngle() const { void WavyLine::setStartAngle(qreal startAngle) { if (!qFuzzyCompare(m_startAngle + 1.0, startAngle + 1.0)) { m_startAngle = startAngle; - m_startAngleRad = startAngle * M_PI / 180.0; emit startAngleChanged(); update(); } @@ -140,7 +140,6 @@ qreal WavyLine::fullAngle() const { void WavyLine::setFullAngle(qreal fullAngle) { if (!qFuzzyCompare(m_fullAngle + 1.0, fullAngle + 1.0)) { m_fullAngle = fullAngle; - m_fullAngleRad = fullAngle * M_PI / 180.0; emit fullAngleChanged(); update(); } @@ -170,90 +169,4 @@ void WavyLine::setValue(qreal value) { } } -void WavyLine::paint(QPainter* painter) { - painter->setRenderHint(QPainter::Antialiasing); - painter->setPen(QPen(m_color, m_lineWidth, Qt::SolidLine, Qt::RoundCap)); - - if (m_pathType == Arc) { - paintArc(painter); - } else { - paintLinear(painter); - } -} - -void WavyLine::paintLinear(QPainter* painter) { - const auto amplitude = m_lineWidth * m_amplitudeMultiplier; - const auto phase = m_waveProgress * 2 * M_PI; - const auto centerY = height() / 2; - const auto len = m_fullLength > 0 ? m_fullLength : 1; - const auto start = m_lineWidth / 2.0; - const auto fullEnd = width() - m_lineWidth / 2.0; - const auto drawEnd = start + (fullEnd - start) * m_value; - - QPainterPath path; - bool first = true; - - for (int x = m_lineWidth / 2; x <= drawEnd; ++x) { - const auto theta = - m_frequency * 2 * M_PI * (x + m_startX) / len + phase; - const auto waveY = centerY + amplitude * qSin(theta); - if (first) { - path.moveTo(x, waveY); - first = false; - } else { - path.lineTo(x, waveY); - } - } - - painter->drawPath(path); -} - -void WavyLine::paintArc(QPainter* painter) { - if (m_fullAngleRad <= 0) { - return; - } - - const auto amplitude = m_lineWidth * m_amplitudeMultiplier; - const auto cx = width() / 2.0; - const auto cy = height() / 2.0; - const auto radius = - m_radius > 0 - ? m_radius - : (qMin(width(), height()) - m_lineWidth - 2 * amplitude) / 2.0; - - if (radius <= 0) { - return; - } - - const auto phase = m_waveProgress * 2 * M_PI; - const auto arcLen = radius * m_fullAngleRad; - const auto len = m_fullLength > 0 ? m_fullLength : arcLen; - const auto drawAngleRad = m_fullAngleRad * m_value; - - if (drawAngleRad <= 0) { - return; - } - - const auto N = qMax(64, qCeil(radius * drawAngleRad)); - const auto dTheta = drawAngleRad / N; - - QPainterPath path; - - for (int i = 0; i <= N; ++i) { - const auto theta = m_startAngleRad + i * dTheta; - const auto s = i * dTheta * radius; - const auto phi = m_frequency * 2 * M_PI * (s + m_startX) / len + phase; - const auto r = radius + amplitude * qSin(phi); - const auto px = cx + r * qCos(theta); - const auto py = cy + r * qSin(theta); - if (i == 0) { - path.moveTo(px, py); - } else { - path.lineTo(px, py); - } - } - - painter->drawPath(path); -} - } // namespace ZShell::controls diff --git a/Plugins/ZShell/Components/wavyline.hpp b/Plugins/ZShell/Components/wavyline.hpp index 8f25589..5ce3b8e 100644 --- a/Plugins/ZShell/Components/wavyline.hpp +++ b/Plugins/ZShell/Components/wavyline.hpp @@ -1,11 +1,14 @@ #pragma once #include -#include +#include +#include namespace ZShell::controls { -class WavyLine : public QQuickPaintedItem { +class WavyLineRenderer; + +class WavyLine : public QCanvasPainterItem { Q_OBJECT QML_ELEMENT @@ -82,9 +85,7 @@ class WavyLine : public QQuickPaintedItem { [[nodiscard]] qreal value() const; void setValue(qreal value); - void paint(QPainter* painter) override; - - signals: +signals: void lineWidthChanged(); void amplitudeMultiplierChanged(); void frequencyChanged(); @@ -98,9 +99,12 @@ class WavyLine : public QQuickPaintedItem { void radiusChanged(); void valueChanged(); + protected: + [[nodiscard]] QCanvasPainterItemRenderer* createItemRenderer() + const override; + private: - void paintLinear(QPainter* painter); - void paintArc(QPainter* painter); + friend class WavyLineRenderer; int m_lineWidth; qreal m_amplitudeMultiplier; @@ -114,8 +118,6 @@ class WavyLine : public QQuickPaintedItem { qreal m_fullAngle; qreal m_radius; qreal m_value; - qreal m_startAngleRad; - qreal m_fullAngleRad; }; } // namespace ZShell::controls diff --git a/Plugins/ZShell/Components/wavylinerenderer.cpp b/Plugins/ZShell/Components/wavylinerenderer.cpp new file mode 100644 index 0000000..7da2b07 --- /dev/null +++ b/Plugins/ZShell/Components/wavylinerenderer.cpp @@ -0,0 +1,116 @@ +#include "wavylinerenderer.hpp" +#include "wavyline.hpp" +#include + +namespace ZShell::controls { + +void WavyLineRenderer::synchronizeData(QCanvasPainterItem* item) { + auto* wavyLine = static_cast(item); + + m_lineWidth = wavyLine->m_lineWidth; + m_amplitudeMultiplier = wavyLine->m_amplitudeMultiplier; + m_frequency = wavyLine->m_frequency; + m_startX = wavyLine->m_startX; + m_fullLength = wavyLine->m_fullLength; + m_color = wavyLine->m_color; + m_waveProgress = wavyLine->m_waveProgress; + m_pathType = wavyLine->m_pathType; + m_startAngle = wavyLine->m_startAngle; + m_fullAngle = wavyLine->m_fullAngle; + m_radius = wavyLine->m_radius; + m_value = wavyLine->m_value; +} + +void WavyLineRenderer::paint(QCanvasPainter* painter) { + painter->clearRect(0, 0, width(), height()); + + painter->setStrokeStyle(m_color); + painter->setLineWidth(m_lineWidth); + painter->setLineCap(QCanvasPainter::LineCap::Round); + + if (m_pathType == WavyLine::Arc) { + paintArc(painter); + } else { + paintLinear(painter); + } +} + +void WavyLineRenderer::paintLinear(QCanvasPainter* painter) { + const auto amplitude = m_lineWidth * m_amplitudeMultiplier; + const auto phase = m_waveProgress * 2 * M_PI; + const auto centerY = height() / 2; + const auto len = m_fullLength > 0 ? m_fullLength : 1; + const auto start = m_lineWidth / 2.0; + const auto fullEnd = width() - m_lineWidth / 2.0; + const auto drawEnd = start + (fullEnd - start) * m_value; + + painter->beginPath(); + + bool first = true; + for (int x = m_lineWidth / 2; x <= drawEnd; ++x) { + const auto theta = + m_frequency * 2 * M_PI * (x + m_startX) / len + phase; + const auto waveY = centerY + amplitude * qSin(theta); + if (first) { + painter->moveTo(x, waveY); + first = false; + } else { + painter->lineTo(x, waveY); + } + } + + painter->stroke(); +} + +void WavyLineRenderer::paintArc(QCanvasPainter* painter) { + if (m_fullAngle <= 0) { + return; + } + + const auto startAngleRad = m_startAngle * M_PI / 180.0; + const auto fullAngleRad = m_fullAngle * M_PI / 180.0; + + const auto amplitude = m_lineWidth * m_amplitudeMultiplier; + const auto cx = width() / 2.0; + const auto cy = height() / 2.0; + const auto radius = + m_radius > 0 + ? m_radius + : (qMin(width(), height()) - m_lineWidth - 2 * amplitude) / 2.0; + + if (radius <= 0) { + return; + } + + const auto phase = m_waveProgress * 2 * M_PI; + const auto arcLen = radius * fullAngleRad; + const auto len = m_fullLength > 0 ? m_fullLength : arcLen; + const auto drawAngleRad = fullAngleRad * m_value; + + if (drawAngleRad <= 0) { + return; + } + + const auto N = qMax(64, qCeil(radius * drawAngleRad)); + const auto dTheta = drawAngleRad / N; + + painter->beginPath(); + + for (int i = 0; i <= N; ++i) { + const auto theta = startAngleRad + i * dTheta; + const auto s = i * dTheta * radius; + const auto phi = m_frequency * 2 * M_PI * (s + m_startX) / len + phase; + const auto r = radius + amplitude * qSin(phi); + const auto px = cx + r * qCos(theta); + const auto py = cy + r * qSin(theta); + if (i == 0) { + painter->moveTo(px, py); + } else { + painter->lineTo(px, py); + } + } + + painter->stroke(); +} + +} // namespace ZShell::controls diff --git a/Plugins/ZShell/Components/wavylinerenderer.hpp b/Plugins/ZShell/Components/wavylinerenderer.hpp new file mode 100644 index 0000000..191ca5f --- /dev/null +++ b/Plugins/ZShell/Components/wavylinerenderer.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include "wavyline.hpp" + +namespace ZShell::controls { + +class WavyLineRenderer final : public QCanvasPainterItemRenderer { + public: + void synchronizeData(QCanvasPainterItem* item) override; + void paint(QCanvasPainter* painter) override; + + private: + void paintLinear(QCanvasPainter* painter); + void paintArc(QCanvasPainter* painter); + + int m_lineWidth; + qreal m_amplitudeMultiplier; + int m_frequency; + qreal m_startX; + qreal m_fullLength; + QColor m_color; + qreal m_waveProgress; + WavyLine::PathType m_pathType; + qreal m_startAngle; + qreal m_fullAngle; + qreal m_radius; + qreal m_value; +}; + +} // namespace ZShell::controls diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index 62911fd..b2c039a 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -1,7 +1,6 @@ wl_proto(wlp-layer-shell wlr-layer-shell-unstable-v1 "${CMAKE_CURRENT_SOURCE_DIR}/protocols") wl_proto(wlp-viewporter viewporter "${WAYLAND_PROTOCOLS}/stable/viewporter") wl_proto(wlp-fractional-scale fractional-scale-v1 "${WAYLAND_PROTOCOLS}/staging/fractional-scale") - wl_proto(wlp-xdg-shell xdg-shell "${WAYLAND_PROTOCOLS}/stable/xdg-shell") qml_module(ZShell-internal From b9e818faa9e41308caf4b6dca7d0ddbbf55f68be Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 12 Oct 2026 15:58:25 +0200 Subject: [PATCH 81/90] fix: add proper atRestOverride for notification background --- Drawers/Windows.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index b4b543c..30888ee 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -260,6 +260,7 @@ CustomWindow { PanelBg { id: notifsBg + atRestOverride: panel.implicitHeight > 0 panel: panels.notifications radius: Tokens.rounding.large } From 8d54e1ce6f8148b4ed46a5017882b4e2589322d2 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 12 Oct 2026 16:05:52 +0200 Subject: [PATCH 82/90] fix: undo accidental debug code --- Modules/Resources/Cards/MemoryCard.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Resources/Cards/MemoryCard.qml b/Modules/Resources/Cards/MemoryCard.qml index 366cfa9..c2f8041 100644 --- a/Modules/Resources/Cards/MemoryCard.qml +++ b/Modules/Resources/Cards/MemoryCard.qml @@ -48,7 +48,7 @@ CustomRect { implicitSize: usageColumn.implicitHeight + thickness + Tokens.padding.largeIncreased * 2 startAngle: -225 sweepAngle: 270 - value: Memory.percentage / 100 + value: Memory.percentage Behavior on clampedVal { Anim { From 1fa3c84a562c4eba41a3c5357a83baf31176d8e6 Mon Sep 17 00:00:00 2001 From: AramJonghu Date: Tue, 13 Oct 2026 23:58:54 +0200 Subject: [PATCH 83/90] chore(nix): remove all .nix files and flake.lock --- flake.lock | 48 --------------- flake.nix | 71 ---------------------- nix/app2unit.nix | 16 ----- nix/default.nix | 146 --------------------------------------------- nix/zshell-cli.nix | 28 --------- 5 files changed, 309 deletions(-) delete mode 100644 flake.lock delete mode 100644 flake.nix delete mode 100644 nix/app2unit.nix delete mode 100644 nix/default.nix delete mode 100644 nix/zshell-cli.nix diff --git a/flake.lock b/flake.lock deleted file mode 100644 index bb07f24..0000000 --- a/flake.lock +++ /dev/null @@ -1,48 +0,0 @@ -{ - "nodes": { - "nixpkgs": { - "locked": { - "lastModified": 1771369470, - "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "0182a361324364ae3f436a63005877674cf45efb", - "type": "github" - }, - "original": { - "owner": "nixos", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "quickshell": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1770693276, - "narHash": "sha256-ngXnN5YXu+f45+QGYNN/VEBMQmcBCYGRCqwaK8cxY1s=", - "ref": "refs/heads/master", - "rev": "dacfa9de829ac7cb173825f593236bf2c21f637e", - "revCount": 735, - "type": "git", - "url": "https://git.outfoxxed.me/outfoxxed/quickshell" - }, - "original": { - "type": "git", - "url": "https://git.outfoxxed.me/outfoxxed/quickshell" - } - }, - "root": { - "inputs": { - "nixpkgs": "nixpkgs", - "quickshell": "quickshell" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/flake.nix b/flake.nix deleted file mode 100644 index af98ca1..0000000 --- a/flake.nix +++ /dev/null @@ -1,71 +0,0 @@ -{ - description = "Flake for zshell"; - inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; - - quickshell = { - url = "git+https://git.outfoxxed.me/outfoxxed/quickshell"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; - - outputs = { - self, - nixpkgs, - ... - } @ inputs: let - forAllSystems = fn: nixpkgs.lib.genAttrs nixpkgs.lib.platforms.linux (system: fn nixpkgs.legacyPackages.${system}); - in { - formatter = forAllSystems (pkgs: pkgs.nixfmt); - - packages = forAllSystems ( - pkgs: let - pythonEnv = pkgs.python3.withPackages (ps: [ - ps.pillow - ps.materialyoucolor - ]); - in rec { - zshell-cli = pkgs.callPackage ./nix/zshell-cli.nix {}; - - zshell = pkgs.callPackage ./nix { - rev = self.rev or self.dirtyRev; - stdenv = pkgs.clangStdenv; - - quickshell = inputs.quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default.override { - withX11 = false; - withI3 = false; - }; - - app2unit = pkgs.callPackage ./nix/app2unit.nix {}; - - inherit pythonEnv zshell-cli; - }; - - default = zshell; - } - ); - - devShells = forAllSystems (pkgs: { - default = let - system = pkgs.stdenv.hostPlatform.system; - shellPkg = self.packages.${system}.zshell; - cliPkg = self.packages.${system}.zshell-cli; - in - pkgs.mkShell.override {stdenv = shellPkg.stdenv;} { - inputsFrom = [ - shellPkg - shellPkg.plugin - ]; - - packages = with pkgs; [ - shellPkg - cliPkg - - material-symbols - rubik - nerd-fonts.caskaydia-cove - ]; - }; - }); - }; -} diff --git a/nix/app2unit.nix b/nix/app2unit.nix deleted file mode 100644 index a29a66a..0000000 --- a/nix/app2unit.nix +++ /dev/null @@ -1,16 +0,0 @@ -{ - pkgs, - fetchFromGitHub, - ... -}: -pkgs.app2unit.overrideAttrs ( - final: prev: rec { - version = "1.0.3"; - src = fetchFromGitHub { - owner = "Vladimir-csp"; - repo = "app2unit"; - tag = "v${version}"; - hash = "sha256-7eEVjgs+8k+/NLteSBKgn4gPaPLHC+3Uzlmz6XB0930="; - }; - } -) diff --git a/nix/default.nix b/nix/default.nix deleted file mode 100644 index 636e6be..0000000 --- a/nix/default.nix +++ /dev/null @@ -1,146 +0,0 @@ -{ - fftw, - libcava, - rev, - lib, - stdenv, - makeWrapper, - makeFontsConf, - app2unit, - networkmanager, - swappy, - wl-clipboard, - libqalculate, - bash, - hyprland, - material-symbols, - rubik, - nerd-fonts, - qt6, - quickshell, - aubio, - pipewire, - cmake, - ninja, - pkg-config, - pythonEnv, - zshell-cli, - ddcutil, - brightnessctl, -}: -let - version = "1.0.0"; - - runtimeDeps = [ - pythonEnv - app2unit - networkmanager - swappy - wl-clipboard - libqalculate - bash - hyprland - zshell-cli - ddcutil - brightnessctl - ]; - - fontconfig = makeFontsConf { - fontDirectories = [ - material-symbols - rubik - nerd-fonts.caskaydia-cove - ]; - }; - - cmakeBuildType = "RelWithDebInfo"; - - cmakeVersionFlags = [ - (lib.cmakeFeature "VERSION" version) - (lib.cmakeFeature "GIT_REVISION" rev) - (lib.cmakeFeature "DISTRIBUTOR" "nix-flake") - ]; - - plugin = stdenv.mkDerivation { - inherit cmakeBuildType; - name = "zshell-qml-plugin"; - src = lib.fileset.toSource { - root = ./..; - fileset = lib.fileset.union ./../CMakeLists.txt ./../Plugins; - }; - - nativeBuildInputs = [ - cmake - ninja - pkg-config - ]; - buildInputs = [ - pythonEnv - qt6.qtbase - qt6.qtdeclarative - libqalculate - pipewire - aubio - libcava - fftw - ]; - - dontWrapQtApps = true; - cmakeFlags = [ - (lib.cmakeFeature "ENABLE_MODULES" "plugin") - (lib.cmakeFeature "INSTALL_QMLDIR" qt6.qtbase.qtQmlPrefix) - ] - ++ cmakeVersionFlags; - }; -in -stdenv.mkDerivation { - inherit version cmakeBuildType; - pname = "zshell"; - src = ./..; - - nativeBuildInputs = [ - cmake - ninja - makeWrapper - qt6.wrapQtAppsHook - ]; - buildInputs = [ - quickshell - plugin - qt6.qtbase - qt6.qtwayland - ]; - propagatedBuildInputs = runtimeDeps; - - cmakeFlags = [ - (lib.cmakeFeature "ENABLE_MODULES" "shell") - (lib.cmakeFeature "INSTALL_QSCONFDIR" "${placeholder "out"}/share/ZShell") - ] - ++ cmakeVersionFlags; - - prePatch = '' - substituteInPlace shell.qml \ - --replace-fail 'ShellRoot {' 'ShellRoot { settings.watchFiles: false' - ''; - - postInstall = '' - makeWrapper ${quickshell}/bin/qs $out/bin/zshell \ - --prefix PATH : "${lib.makeBinPath runtimeDeps}" \ - --set FONTCONFIG_FILE "${fontconfig}" \ - --add-flags "-p $out/share/ZShell" - - echo "$out" - mkdir -p $out/lib - ''; - - passthru = { - inherit plugin; - }; - - meta = { - description = "A very segsy desktop shell"; - homepage = "https://github.com/Zacharias-Brohn/z-bar-qt"; - license = lib.licenses.gpl3Only; - mainProgram = "zshell"; - }; -} diff --git a/nix/zshell-cli.nix b/nix/zshell-cli.nix deleted file mode 100644 index f3cc3b1..0000000 --- a/nix/zshell-cli.nix +++ /dev/null @@ -1,28 +0,0 @@ -{ - python3, - installShellFiles, -}: -python3.pkgs.buildPythonApplication { - pname = "zshell-cli"; - version = "0.1.0"; - src = ../cli; - pyproject = true; - - build-system = with python3.pkgs; [ - hatch-vcs - hatchling - ]; - - dependencies = with python3.pkgs; [ - materialyoucolor - pillow - jinja2 - typer - ]; - - pythonImportsCheck = ["zshell"]; - - nativeBuildInputs = [installShellFiles]; - - SETUPTOOLS_SCM_PRETEND_VERSION = 1; -} From 4b8d74a7e56c2d19bf9f6111a59b73e72fc0a700 Mon Sep 17 00:00:00 2001 From: AramJonghu Date: Tue, 13 Oct 2026 23:58:54 +0200 Subject: [PATCH 84/90] ci(cleanup): removed NixOS from issue template Avoids confusion --- .gitea/ISSUE_TEMPLATE/bug.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitea/ISSUE_TEMPLATE/bug.yaml b/.gitea/ISSUE_TEMPLATE/bug.yaml index ac0449e..33ad098 100644 --- a/.gitea/ISSUE_TEMPLATE/bug.yaml +++ b/.gitea/ISSUE_TEMPLATE/bug.yaml @@ -38,7 +38,6 @@ body: attributes: label: OS options: - - NixOS - Arch - Debian / Ubuntu - Fedora From 02267ec9a6e76e20879a432e5796043258f0d182 Mon Sep 17 00:00:00 2001 From: AramJonghu Date: Tue, 13 Oct 2026 23:58:54 +0200 Subject: [PATCH 85/90] docs(readme): adjust Nix support documentation --- README.md | 84 ++----------------------------------------------------- 1 file changed, 3 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 1ca52b4..cb7b254 100644 --- a/README.md +++ b/README.md @@ -84,89 +84,11 @@ Defaults from `CMakeLists.txt`: - shell config install dir: `etc/xdg/quickshell/zshell` - greeter install dir: `etc/xdg/quickshell/zshell-greeter` -### Nix Flake +### Nix -_Note that Nix is not actively developed at this point. Things may be broken. Feel free to suggest fixes in a PR_ +Nix support has been removed. Reason being that none of us actively develop for Nix. Having a `flake.nix` in the project will give the wrong idea. -The flake exposes: - -- `packages..zshell` -- `packages..zshell-cli` - -Add it as an input in your system flake: - -```nix -{ - inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; - z-bar-qt = { - url = git+https://git.zach-dev.cc/zach/z-bar-qt.git - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; -} -``` - -Full `flake.nix` example (`nixosConfigurations`): - -```nix -{ - inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; - z-bar-qt = { - url = git+https://git.zach-dev.cc/zach/z-bar-qt.git - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; - - outputs = inputs@{ nixpkgs, ... }: - let - system = "x86_64-linux"; - in { - nixosConfigurations.my-host = nixpkgs.lib.nixosSystem { - inherit system; - specialArgs = { inherit inputs; }; - modules = [ - ({ ... }: { - environment.systemPackages = [ - inputs.z-bar-qt.packages.${system}.zshell - inputs.z-bar-qt.packages.${system}.zshell-cli - ]; - }) - ./configuration.nix - ]; - }; - }; -} -``` - -Then install shell + CLI from the flake packages: - -```nix -{ pkgs, inputs, ... }: -let - system = pkgs.stdenv.hostPlatform.system; -in { - environment.systemPackages = [ - inputs.z-bar-qt.packages.${system}.zshell - inputs.z-bar-qt.packages.${system}.zshell-cli - ]; -} -``` - -If you use Home Manager, the same packages can be added to `home.packages`. - -Run shell via wrapper binary: - -```bash -zshell -``` - -CLI entrypoint: - -```bash -zshell-cli -``` +Contributions for Nix support are welcome, the caveat being that reviews of these contributions can be of lower quality. ## Running From ae25f33fc8d1d65a6ef679ca9ecf4bdc3a8e81d1 Mon Sep 17 00:00:00 2001 From: AramJonghu Date: Wed, 14 Oct 2026 00:19:46 +0200 Subject: [PATCH 86/90] fix(pr-template): adds an "Other" issue type --- .gitea/PULL_REQUEST_TEMPLATE.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitea/PULL_REQUEST_TEMPLATE.yaml b/.gitea/PULL_REQUEST_TEMPLATE.yaml index cd94046..e890116 100644 --- a/.gitea/PULL_REQUEST_TEMPLATE.yaml +++ b/.gitea/PULL_REQUEST_TEMPLATE.yaml @@ -30,6 +30,7 @@ body: - Refactor / style - CI / build / tooling - Documentation + - Other (Describe in description box with header "Issue type") validations: required: true From 1fe8a3a959fbd542261463f484d1ea48a0dddeb4 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 18 Oct 2026 12:58:38 +0200 Subject: [PATCH 87/90] chore: restructure llm plugin to be more coherent & fix various bugs in llm ui --- .../Sidebar/Chat/ChatContent.qml | 16 +- .../Sidebar/Chat/ChatDelegate.qml | 8 +- .../Notifications/Sidebar/Chat/ChatList.qml | 169 +--- .../Notifications/Sidebar/Chat/ChatPanel.qml | 42 +- .../Notifications/Sidebar/Chat/ChatState.qml | 10 +- .../Sidebar/Chat/Content/Actions.qml | 7 +- .../Sidebar/Chat/Content/ApprovalPrompt.qml | 400 ++++++++ .../Sidebar/Chat/Content/ContentBubble.qml | 21 +- .../Sidebar/Chat/Content/EmptyBackground.qml | 38 +- .../Sidebar/Chat/Content/ErrorBubble.qml | 2 +- .../Sidebar/Chat/Content/MarkdownBlocks.qml | 16 +- .../Sidebar/Chat/Content/MessageDelegate.qml | 20 +- .../Sidebar/Chat/Content/ModelsContainer.qml | 351 +++++++ .../Sidebar/Chat/Content/ProcessBlock.qml | 413 +------- .../Sidebar/Chat/SidebarView.qml | 48 +- Modules/Notifications/Sidebar/Content.qml | 2 +- Modules/Notifications/Sidebar/Wrapper.qml | 4 +- .../Pages/Panels/Sidebar/SidebarLlm.qml | 1 + Plugins/ZShell/Llm/CMakeLists.txt | 7 +- Plugins/ZShell/Llm/chat.cpp | 201 ---- Plugins/ZShell/Llm/chat.hpp | 91 -- Plugins/ZShell/Llm/chatstore.cpp | 386 +++++--- Plugins/ZShell/Llm/chatstore.hpp | 69 +- Plugins/ZShell/Llm/clipboard.cpp | 61 ++ Plugins/ZShell/Llm/clipboard.hpp | 12 + Plugins/ZShell/Llm/filetool.cpp | 69 +- Plugins/ZShell/Llm/filetool.hpp | 7 +- Plugins/ZShell/Llm/generation.cpp | 213 +++-- Plugins/ZShell/Llm/generation.hpp | 47 +- Plugins/ZShell/Llm/llmclient.cpp | 904 ++++++------------ Plugins/ZShell/Llm/llmclient.hpp | 126 +-- Plugins/ZShell/Llm/markdownblock.cpp | 23 + Plugins/ZShell/Llm/markdownblock.hpp | 40 +- Plugins/ZShell/Llm/markdownparser.cpp | 41 +- Plugins/ZShell/Llm/markdownparser.hpp | 15 +- Plugins/ZShell/Llm/message.cpp | 57 +- Plugins/ZShell/Llm/message.hpp | 18 +- Plugins/ZShell/Llm/messagemodel.cpp | 12 +- Plugins/ZShell/Llm/prompts.cpp | 68 ++ Plugins/ZShell/Llm/prompts.hpp | 20 + Plugins/ZShell/Llm/segment.cpp | 178 ++-- Plugins/ZShell/Llm/segment.hpp | 136 ++- Plugins/ZShell/Llm/session.cpp | 280 ++---- Plugins/ZShell/Llm/session.hpp | 48 +- Plugins/ZShell/Llm/shelltool.cpp | 69 +- Plugins/ZShell/Llm/shelltool.hpp | 13 +- Plugins/ZShell/Llm/tool.cpp | 55 +- Plugins/ZShell/Llm/tool.hpp | 59 +- Plugins/ZShell/Llm/turn.cpp | 391 ++++++++ Plugins/ZShell/Llm/turn.hpp | 81 ++ Plugins/ZShell/Llm/webfetchtool.cpp | 79 +- Plugins/ZShell/Llm/webfetchtool.hpp | 13 +- Plugins/ZShell/Llm/workspace.cpp | 89 ++ Plugins/ZShell/Llm/workspace.hpp | 35 + 54 files changed, 3022 insertions(+), 2559 deletions(-) create mode 100644 Modules/Notifications/Sidebar/Chat/Content/ApprovalPrompt.qml create mode 100644 Modules/Notifications/Sidebar/Chat/Content/ModelsContainer.qml delete mode 100644 Plugins/ZShell/Llm/chat.cpp delete mode 100644 Plugins/ZShell/Llm/chat.hpp create mode 100644 Plugins/ZShell/Llm/clipboard.cpp create mode 100644 Plugins/ZShell/Llm/clipboard.hpp create mode 100644 Plugins/ZShell/Llm/markdownblock.cpp create mode 100644 Plugins/ZShell/Llm/prompts.cpp create mode 100644 Plugins/ZShell/Llm/prompts.hpp create mode 100644 Plugins/ZShell/Llm/turn.cpp create mode 100644 Plugins/ZShell/Llm/turn.hpp create mode 100644 Plugins/ZShell/Llm/workspace.cpp create mode 100644 Plugins/ZShell/Llm/workspace.hpp diff --git a/Modules/Notifications/Sidebar/Chat/ChatContent.qml b/Modules/Notifications/Sidebar/Chat/ChatContent.qml index 3fdf1db..599dee5 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatContent.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatContent.qml @@ -171,7 +171,7 @@ Item { Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter elide: Text.ElideRight font: Tokens.font.title.medium - text: qsTr(root.chatData.title) + text: qsTr(root.chatData?.title || ChatState.title) } } @@ -540,17 +540,17 @@ Item { anchors.margins: Tokens.padding.medium bg.border.color: Colors.palette.m3outlineVariant bg.color: Colors.tPalette.m3surfaceContainerLowest - fastGlow: Chat.busy - glow.opacity: input.activeFocus && (!Chat.busy || ChatState.isWindow) + fastGlow: LlmClient.busy + glow.opacity: input.activeFocus && (!LlmClient.busy || ChatState.isWindow) font: Tokens.font.body.small implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) focus: true placeholderText: qsTr("Send a message") sendIcon.font: Tokens.font.icon.medium - sendIcon.icon: Chat.busy ? "stop" : "arrow_upward" + sendIcon.icon: LlmClient.busy ? "stop" : "arrow_upward" sendIcon.padding: Tokens.padding.extraSmall - sendIcon.opacity: text || Chat.busy ? 1.0 : 0.5 - sendIcon.enabled: text || Chat.busy + sendIcon.opacity: text || LlmClient.busy ? 1.0 : 0.5 + sendIcon.enabled: text || LlmClient.busy Component.onCompleted: { root.focusInput(); @@ -572,8 +572,8 @@ Item { } } onButtonPressed: { - if (Chat.busy) - Chat.stop(); + if (LlmClient.busy) + LlmClient.stop(); else root.send(text); } diff --git a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml index b7a6721..3480d08 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatDelegate.qml @@ -161,10 +161,6 @@ CustomClippingRect { CustomRect { id: fadeMask - // anchors.top: titleText.top - // anchors.bottom: titleText.bottom - // anchors.left: titleText.left - // anchors.right: parent.right anchors.fill: titleWrapper layer.enabled: true @@ -259,7 +255,7 @@ CustomClippingRect { font: Tokens.font.body.small maximumLineCount: 1 opacity: !root.expanded && !root.slim ? 1 : 0 - text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet") + text: root.modelData.messagesModel.lastMessage?.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet") Behavior on y { Anim {} @@ -284,7 +280,7 @@ CustomClippingRect { color: Colors.palette.m3onSurfaceVariant maximumLineCount: 5 opacity: root.expanded ? 1 : 0 - text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? qsTr("No messages yet") + text: root.modelData.messagesModel.lastMessage?.content ?? qsTr("No messages yet") wrapMode: Text.WrapAtWordBoundaryOrAnywhere Behavior on y { diff --git a/Modules/Notifications/Sidebar/Chat/ChatList.qml b/Modules/Notifications/Sidebar/Chat/ChatList.qml index 3b65771..adf488c 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatList.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatList.qml @@ -5,6 +5,7 @@ import QtQuick import QtQuick.Layouts import ZShell.Llm import ZShell.Config +import qs.Modules.Notifications.Sidebar.Chat.Content import qs.Services import qs.Components @@ -21,8 +22,13 @@ Item { signal loadChatRequest(content: ChatSession, index: int) signal newChatRequest + onSlimChanged: { + if (slim) + modelsContainer.open = false; + } + CustomText { - id: header + id: chatHeader anchors.left: parent.left anchors.margins: Tokens.padding.extraSmall @@ -41,7 +47,7 @@ Item { anchors.bottom: modelsContainer.top anchors.left: parent.left anchors.right: parent.right - anchors.top: header.bottom + anchors.top: chatHeader.bottom anchors.topMargin: Tokens.spacing.medium clip: true spacing: Tokens.spacing.medium @@ -98,156 +104,24 @@ Item { } } - CustomClippingRect { + ModelsContainer { id: modelsContainer - property bool expanded: false - anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: fabRoot.left anchors.margins: Tokens.padding.small anchors.rightMargin: Tokens.spacing.small - implicitHeight: expanded ? 400 : fabRoot.implicitHeight - radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2 + rootParent: root + fabRoot: fabRoot - function prettyModelName(): string { - if (modelsList.count < 1) - return qsTr("No models found"); + opacity: root.slim ? 0 : 1 + visible: opacity > 0 - const pathList = Chat.model.split("\/"); - const name = pathList[pathList.length - 1]; - - if (!name) - return qsTr("No model selected"); - - return name; - } - - Behavior on implicitHeight { - Anim {} - } - - Behavior on radius { - Anim {} - } - - Item { - id: modelName - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - implicitHeight: fabRoot.implicitHeight - - CustomText { - id: label - text: modelsContainer.prettyModelName() - color: Colors.palette.m3onSurfaceVariant - anchors.left: parent.left - anchors.leftMargin: Tokens.padding.large - anchors.verticalCenter: parent.verticalCenter - } - - MaterialIcon { - color: Colors.palette.m3onSurfaceVariant - anchors.right: parent.right - anchors.rightMargin: Tokens.padding.large - anchors.verticalCenter: parent.verticalCenter - text: modelsContainer.expanded ? "unfold_less" : "unfold_more" - animate: true - fontStyle: Tokens.font.icon.large - visible: modelsList.count > 0 - } - - StateLayer { - id: modelLayer - enabled: modelsList.count > 0 - - onClicked: { - ChatState.fabExpanded = false; - modelsContainer.expanded = !modelsContainer.expanded; - } - radius: modelsContainer.radius - } - } - - VerticalFadeListView { - id: modelsList - - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.top: modelName.bottom - anchors.margins: Tokens.padding.medium - anchors.topMargin: Tokens.spacing.medium - model: Chat.availableModels - opacity: modelsContainer.expanded ? 1 : 0 - currentIndex: Chat.availableModels.indexOf(Chat.model) - visible: opacity > 0 - - Behavior on opacity { - Anim {} - } - - CustomScrollBar.vertical: CustomScrollBar { - flickable: modelsList - } - - delegate: CustomRect { - id: model - - required property var modelData - required property int index - readonly property bool selected: model.ListView.view.currentIndex === model.index - - implicitWidth: ListView.view.width - implicitHeight: layout.implicitHeight + Tokens.padding.small * 2 - radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium - color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0) - - Behavior on radius { - Anim { - type: Anim.SlowEffects - } - } - - RowLayout { - id: layout - - anchors.fill: parent - anchors.margins: Tokens.padding.small - anchors.leftMargin: Tokens.padding.large - anchors.rightMargin: Tokens.padding.large - - CustomText { - text: model.modelData - color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface - } - - MaterialIcon { - Layout.alignment: Qt.AlignRight - visible: model.selected - color: Colors.palette.m3onTertiaryContainer - fontStyle: Tokens.font.icon.normal - opacity: model.selected ? 1 : 0 - text: "check" - - Behavior on opacity { - Anim { - type: Anim.SlowEffects - } - } - } - } - - StateLayer { - id: stateLayer - - onClicked: { - Chat.selectModel(model.modelData); - } - } + Behavior on opacity { + Anim { + type: Anim.DefaultEffects } } } @@ -262,19 +136,11 @@ Item { font.pointSize: Math.round(18 * 1.2) icon: root.slim ? "left_panel_open" : "add" isRound: ChatState.fabExpanded - // opacity: root.slim ? 0 : 1 - // visible: opacity > 0 - // - // Behavior on opacity { - // Anim { - // type: Anim.DefaultEffects - // } - // } label.transform: Rotation { origin.y: fabRoot.label.height / 2 origin.x: fabRoot.label.width / 2 - angle: ChatState.fabExpanded ? 135 : 0 + angle: ChatState.fabExpanded && (ChatState.narrowSidebarExpanded && !ChatState.isWide || !ChatState.isWindow) ? 135 : 0 Behavior on angle { Anim {} @@ -287,7 +153,6 @@ Item { return; } - modelsContainer.expanded = false; ChatState.fabExpanded = !ChatState.fabExpanded; } diff --git a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml index 6f8c765..b7a5959 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatPanel.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatPanel.qml @@ -17,15 +17,26 @@ Item { anchors.margins: Tokens.padding.small Component.onCompleted: { + if (ChatState.firstOpen) { + ChatState.openDraft(); + stack.push(chatList); + stack.push(chatContent); + ChatState.firstOpen = false; + return; + } + if (ChatState.inChat) { stack.push(chatList); - stack.push(chatContent, { - "chatData": ChatState.chatSession - }); + stack.push(chatContent); } else stack.push(chatList); } + function openChat(): void { + if (stack.depth < 2) + stack.push(chatContent); + } + StackView { id: stack anchors.fill: parent @@ -35,27 +46,23 @@ Item { id: chatList ChatList { - model: ScriptModel { - values: Chat.chats.values - } + model: LlmClient.chats onDeleteChatRequest: chat => { - Chat.chats.remove(chat); + const wasCurrent = chat === ChatState.chatSession; + LlmClient.chats.remove(chat); + if (wasCurrent) + ChatState.openDraft(); } onLoadChatRequest: (chat, index) => { - stack.push(chatContent, { - "chatData": chat - }); - ChatState.inChat = true; ChatState.chatSession = chat; + openChat(); + ChatState.inChat = true; } onNewChatRequest: { - const data = Chat.chats.insert(); - stack.push(chatContent, { - "chatData": data - }); + ChatState.openDraft(); + openChat(); ChatState.inChat = true; - ChatState.chatSession = data; } } } @@ -64,9 +71,12 @@ Item { id: chatContent ChatContent { + chatData: ChatState.chatSession + onRequestClose: { stack.pop(); ChatState.inChat = false; + ChatState.openDraft(); } } } diff --git a/Modules/Notifications/Sidebar/Chat/ChatState.qml b/Modules/Notifications/Sidebar/Chat/ChatState.qml index 0240a94..5536f1d 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatState.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatState.qml @@ -8,9 +8,10 @@ import qs.Services Singleton { id: root - property ChatSession chatSession - readonly property string title: chatSession ? chatSession.title : "Chat" + property ChatSession chatSession: LlmClient.chats.draft() + readonly property string title: chatSession?.title || "Chat" property int currentIdx: -1 + property bool firstOpen: true property bool inChat: false property bool fabExpanded: false property bool isWindow: false @@ -22,6 +23,11 @@ Singleton { property ShellScreen screen property bool sidebarVisible: ShellState.forActive().sidebar + function openDraft(): void { + root.chatSession = LlmClient.chats.draft(); + root.currentIdx = -1; + } + onSidebarVisibleChanged: if (!sidebarVisible) fabExpanded = false } diff --git a/Modules/Notifications/Sidebar/Chat/Content/Actions.qml b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml index aa018d6..1795ff3 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/Actions.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/Actions.qml @@ -9,12 +9,11 @@ import qs.Services Item { id: root - required property ChatGeneration current required property TextEdit edit required property bool hovered required property bool isUser required property ChatMessage message - required property LlmSegment segment + required property bool running implicitHeight: editButton.implicitHeight implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small @@ -63,7 +62,7 @@ Item { id: editTools anchors.right: parent.right - opacity: !root.current.streaming && root.hovered ? 1 : 0 + opacity: !root.running && root.hovered ? 1 : 0 spacing: Tokens.spacing.small visible: opacity > 0 @@ -99,7 +98,7 @@ Item { Anim {} } - onClicked: Quickshell.clipboardText = root.current.content + onClicked: Quickshell.clipboardText = root.message.content } IconButton { diff --git a/Modules/Notifications/Sidebar/Chat/Content/ApprovalPrompt.qml b/Modules/Notifications/Sidebar/Chat/Content/ApprovalPrompt.qml new file mode 100644 index 0000000..c40a051 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ApprovalPrompt.qml @@ -0,0 +1,400 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import ZShell.Components +import ZShell.Llm +import ZShell.Blobs +import qs.Components +import qs.Services + +Item { + id: root + + required property Item rootParent + required property ChatGeneration current + + implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2 + + CustomRect { + id: approvalBg + + anchors.fill: parent + radius: Tokens.rounding.medium + color: Colors.palette.m3surface + border.width: 1 + border.color: Colors.palette.m3outlineVariant + + ColumnLayout { + id: approvalLayout + anchors.fill: parent + anchors.margins: Tokens.padding.medium + spacing: Tokens.spacing.small + + CustomText { + id: requestHeader + text: qsTr("Tool call request") + font: Tokens.font.headline.medium + color: Colors.palette.m3onSurface + } + + Item { + id: toolsBg + + property bool open: false + property int maxHeight: root.rootParent.height - Tokens.padding.medium * 2 + property int openHeight: Math.min(toolsContent.item?.implicitHeight, maxHeight) + property int openWidth: root.rootParent.width - Tokens.padding.medium * 2 + property real openHeightProg: openHeight / maxHeight + property int collapsedY: 0 + + implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2 + z: open || dialogTransition.running ? 2 : 0 + Layout.fillWidth: true + + function reparentWrapper(): void { + const newParent = open ? root.rootParent : toolsBg; + const c = toolsWrapper.parent.mapToItem(newParent, toolsWrapper.x + toolsWrapper.width / 2, toolsWrapper.y + toolsWrapper.height / 2); + toolsBg.collapsedY = toolsWrapper.y; + toolsWrapper.parent = newParent; + toolsWrapper.x = c.x - toolsWrapper.width / 2; + toolsWrapper.y = c.y - toolsWrapper.height / 2; + } + + onOpenChanged: { + if (open) + root.rootParent.interactive = false; + else + root.rootParent.interactive = true; + } + + BlobGroup { + id: blobGroup + + color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer + + Behavior on color { + CAnim {} + } + } + + MouseArea { + id: backdrop + + anchors.fill: parent + enabled: false + hoverEnabled: enabled + preventStealing: true + parent: toolsBg.open ? root.rootParent : toolsBg + + onClicked: toolsBg.open = false + } + + Item { + id: toolsWrapper + + width: toolsBg.width + height: toolBox.implicitHeight + rotation: parent === root.rootParent ? 180 : 0 + z: 1 + + states: State { + name: "open" + when: toolsBg.open + + PropertyChanges { + backdrop.enabled: true + toolsBackground.bottomLeftRadius: Tokens.rounding.largeIncreased + toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased + toolsBackground.topRightRadius: Tokens.rounding.largeIncreased + toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased + toolsContent.opacity: 1 + toolsWrapper.height: toolsBg.openHeight + toolsWrapper.width: toolsBg.openWidth + toolsWrapper.x: (root.rootParent.width - toolsBg.openWidth) / 2 + toolsWrapper.y: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium + elevation.opacity: 1 + toolBox.opacity: 0 + } + } + transitions: Transition { + id: dialogTransition + + SequentialAnimation { + ScriptAction { + script: toolsBg.reparentWrapper() + } + + Anim { + properties: "x,y" + } + } + + PropertyAction { + property: "enabled" + } + + Anim { + properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius" + type: Anim.DefaultEffects + } + + Anim { + properties: "width,height" + } + } + + Elevation { + id: elevation + + anchors.fill: parent + bottomLeftRadius: toolsBackground.bottomLeftRadius + bottomRightRadius: toolsBackground.bottomRightRadius + level: 4 + opacity: 0 + radius: toolsBackground.radius + + transform: Matrix4x4 { + matrix: toolsBackground.deformMatrix + } + } + + BlobRect { + id: toolsBackground + + anchors.fill: parent + bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small + deformScale: 0 + group: blobGroup + opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) + } + + CustomRect { + id: toolBox + anchors.left: parent.left + anchors.right: parent.right + height: Math.min(implicitHeight, parent.height) + implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2 + radius: Tokens.rounding.small + + StateLayer { + onClicked: { + toolsBg.open = true; + } + } + + ColumnLayout { + id: toolList + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.medium + + Repeater { + id: repeater + + model: root.current.pendingToolCalls?.slice(0, 4) ?? [] + + delegate: RowLayout { + id: toolText + + required property var modelData + required property int index + + spacing: Tokens.spacing.medium + + function toolTarget(seg: var): string { + var a = null; + try { + a = JSON.parse(seg.arguments); + } catch (e) {} + if (a && typeof a === "object") { + const v = a.url ?? a.path; + if (v !== undefined && v !== null) + return String(v); + } + return String(seg.arguments); + } + + MaterialIcon { + color: Colors.palette.m3onPrimaryContainer + text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet" + fill: 1 + } + + CustomText { + color: Colors.palette.m3onPrimaryContainer + text: qsTr("%1").arg(toolText.modelData.name) + } + + MaterialIcon { + color: Colors.palette.m3onPrimaryContainer + text: "arrow_right_alt" + } + + CustomText { + color: Colors.palette.m3onPrimaryContainer + text: qsTr("%1").arg(toolText.toolTarget(toolText.modelData)) + } + } + } + + CustomText { + color: Colors.palette.m3onPrimaryContainer + text: qsTr("And %1 more").arg(root.current.pendingToolCalls.slice(4).length) + visible: repeater.count < root.current.pendingToolCalls.length + } + } + } + + Loader { + id: toolsContent + + active: opacity > 0 + anchors.fill: parent + asynchronous: false + opacity: 0 + + sourceComponent: MouseArea { + onWheel: event => event.accepted = true + preventStealing: true + implicitHeight: innerLayout.contentHeight + innerLayout.anchors.margins * 2 + + VerticalFadeListView { + id: innerLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.small + cacheBuffer: height * 10 + fadeAmount: 0.1 + fadeEnabled: interactive + spacing: 0 + clip: true + interactive: contentHeight > toolsBg.maxHeight + + model: root.current.pendingToolCalls ?? [] + + delegate: Item { + id: toolCall + + required property var modelData + property bool expanded: false + + implicitWidth: CustomListView.view.width + implicitHeight: collapsedRow.implicitHeight + collapsedRow.anchors.margins + (expanded ? expandedTool.implicitHeight + expandedTool.anchors.margins + expandedTool.anchors.topMargin : collapsedRow.anchors.margins) + + function toolTarget(seg: var): string { + var a = null; + try { + a = JSON.parse(seg.arguments); + } catch (e) {} + if (a && typeof a === "object") { + const v = a.url ?? a.path; + if (v !== undefined && v !== null) + return String(v); + } + return String(seg.arguments); + } + + Behavior on implicitHeight { + Anim {} + } + + RowLayout { + id: collapsedRow + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Tokens.padding.medium + + IconButton { + id: toolExpandBtn + + font: Tokens.font.icon.medium + icon: "keyboard_arrow_down" + inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline + rotation: toolCall.expanded ? 180 : 0 + type: IconButton.Text + + Behavior on rotation { + Anim {} + } + + onClicked: toolCall.expanded = !toolCall.expanded + } + + CustomText { + id: preview + + Layout.fillWidth: true + + text: toolCall.modelData.name + } + } + + CustomClippingRect { + id: expandedTool + + anchors.top: collapsedRow.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: Tokens.padding.medium + anchors.topMargin: Tokens.spacing.medium + + implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2 + radius: Tokens.rounding.medium + color: Colors.palette.m3surfaceContainerLow + + CustomText { + id: fullToolCall + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + + text: toolCall.toolTarget(toolCall.modelData) + wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere + } + } + } + } + } + } + } + } + + ButtonRow { + id: confirmationRow + Layout.alignment: Qt.AlignRight + spacing: Tokens.spacing.medium + + IconTextButton { + icon: "check_circle" + text: qsTr("Allow") + horizontalPadding: Tokens.padding.large + verticalPadding: Tokens.padding.small + isRound: true + shapeMorph: true + onClicked: root.current.approveTools() + } + + IconTextButton { + icon: "cancel" + text: qsTr("Deny") + isRound: true + shapeMorph: true + inactiveColor: Colors.palette.m3error + inactiveOnColor: Colors.palette.m3onError + horizontalPadding: Tokens.padding.large + verticalPadding: Tokens.padding.small + onClicked: root.current.denyTools() + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml index 1c4669c..572970a 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ContentBubble.qml @@ -10,12 +10,13 @@ Item { id: root required property bool isUser - required property LlmSegment segment + required property string text + required property var blocks + required property bool running required property bool hovered required property Repeater repeater required property int index required property ChatMessage message - required property ChatGeneration current readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge @@ -34,7 +35,7 @@ Item { radius: Tokens.rounding.medium color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer implicitWidth: layoutFrozen ? frozenWidth : layoutWidth - implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2 + implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : markdown.implicitHeight + markdown.anchors.topMargin * 2 anchors.right: root.isUser ? parent.right : undefined layer.enabled: false layer.smooth: true @@ -74,7 +75,7 @@ Item { TextEditBase { id: msgText - property string cachedText: root.segment.text + property string cachedText: root.text property bool cancelled: false visible: root.isUser @@ -88,7 +89,7 @@ Item { font: Tokens.font.body.small readOnly: true selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4) - text: root.isUser ? root.segment.text : "" + text: root.isUser ? root.text : "" textFormat: CustomText.MarkdownText wrapMode: Text.WordWrap @@ -129,23 +130,22 @@ Item { } } - // Assistant content: parsed markdown blocks. MarkdownBlocks { - id: blocks + id: markdown visible: !root.isUser anchors.topMargin: Tokens.padding.medium anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right - blocks: root.segment.markdown + blocks: root.blocks } } Actions { id: actionsRow - readonly property bool shouldBeActive: (root.segment.type === LlmSegment.Type.Content) && !root.segment.running && root.repeater.count === (root.index + 1) + readonly property bool shouldBeActive: !root.running && root.repeater.count === (root.index + 1) anchors.left: parent.left anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right @@ -156,8 +156,7 @@ Item { edit: msgText hovered: root.hovered isUser: root.isUser - segment: root.segment - current: root.current + running: root.running message: root.message Behavior on opacity { diff --git a/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml b/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml index d3370e7..af0bfef 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/EmptyBackground.qml @@ -5,28 +5,28 @@ import qs.Components import qs.Services ColumnLayout { - id: root + id: root - spacing: Tokens.spacing.small + spacing: Tokens.spacing.small - Item { - Layout.fillHeight: true - } + Item { + Layout.fillHeight: true + } - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - color: Colors.tPalette.m3outlineVariant - font.pointSize: 36 - text: "chat" - } + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3outlineVariant + fontStyle: Tokens.font.icon.extraLarge + text: "chat" + } - CustomText { - Layout.alignment: Qt.AlignHCenter - color: Colors.tPalette.m3onSurfaceVariant - text: qsTr("Send a message to get started") - } + CustomText { + Layout.alignment: Qt.AlignHCenter + color: Colors.tPalette.m3onSurfaceVariant + text: qsTr("Send a message to get started") + } - Item { - Layout.fillHeight: true - } + Item { + Layout.fillHeight: true + } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml b/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml index f3cfca7..9a73c55 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ErrorBubble.qml @@ -108,7 +108,7 @@ Item { wrapMode: CustomText.WordWrap color: Colors.palette.m3onErrorContainer - text: root.current.errorMessage || Chat.serverError + text: root.current.errorMessage || LlmClient.serverError } } diff --git a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml index 877f7e7..1c1117e 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MarkdownBlocks.qml @@ -23,10 +23,10 @@ Column { role: "type" DelegateChoice { - roleValue: LlmMarkdown.Type.Code + roleValue: MarkdownBlock.Type.Code delegate: CodeBlockView { - required property var modelData + required property MarkdownBlock modelData language: modelData.language code: modelData.code @@ -37,10 +37,10 @@ Column { } DelegateChoice { - roleValue: LlmMarkdown.Type.Math + roleValue: MarkdownBlock.Type.Math delegate: MathBlockView { - required property var modelData + required property MarkdownBlock modelData latex: modelData.latex anchors.right: parent.right @@ -50,10 +50,10 @@ Column { } DelegateChoice { - roleValue: LlmMarkdown.Type.Heading + roleValue: MarkdownBlock.Type.Heading delegate: TextEditBase { - required property var modelData + required property MarkdownBlock modelData color: Colors.palette.m3onSurface anchors.right: parent.right @@ -74,10 +74,10 @@ Column { } DelegateChoice { - roleValue: LlmMarkdown.Type.Text + roleValue: MarkdownBlock.Type.Text delegate: BubbleEdit { - required property var modelData + required property MarkdownBlock modelData anchors.right: parent.right anchors.left: parent.left diff --git a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml index 6d37c87..20e2533 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/MessageDelegate.qml @@ -19,7 +19,16 @@ MouseArea { required property ChatListView chatList readonly property bool isUser: modelData.role === ChatMessage.Role.User - readonly property var blocks: blockify(current.segments, current.error) + + readonly property var blocks: root.isUser ? [ + { + kind: "content", + id: 0, + text: root.modelData.content, + markdown: [], + running: false + } + ] : blockify(current.segments, current.error) property ChatGeneration current: modelData.activeGeneration property bool reasoningExpanded: false @@ -54,7 +63,9 @@ MouseArea { blocks.push({ kind: "content", id: i, - segments: [seg] + text: seg.text, + markdown: seg.markdown, + running: seg.running }); } else { const last = blocks.length ? blocks[blocks.length - 1] : null; @@ -330,11 +341,12 @@ MouseArea { required property var modelData isUser: root.isUser - segment: modelData.segments[0] + text: modelData.text + blocks: modelData.markdown + running: modelData.running width: root.width repeater: segmentRep message: root.modelData - current: root.current hovered: root.containsMouse onEdit: text => { diff --git a/Modules/Notifications/Sidebar/Chat/Content/ModelsContainer.qml b/Modules/Notifications/Sidebar/Chat/Content/ModelsContainer.qml new file mode 100644 index 0000000..800e456 --- /dev/null +++ b/Modules/Notifications/Sidebar/Chat/Content/ModelsContainer.qml @@ -0,0 +1,351 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import ZShell.Components +import ZShell.Llm +import ZShell.Blobs +import qs.Modules.Notifications.Sidebar.Chat +import qs.Components +import qs.Services + +Item { + id: root + + required property Item rootParent + required property Item fabRoot + + property bool open: false + + property int maxHeight: root.rootParent.height - Tokens.padding.medium * 2 + property int openHeight: Math.min(modelsContent.item?.implicitHeight, maxHeight) + property int openWidth: root.rootParent.width - Tokens.padding.medium * 2 + property real openHeightProg: openHeight / maxHeight + property int collapsedY: 0 + + property int horizontalContentMargin + property string header: qsTr("Available models") + property string acceptLabel: qsTr("Accept") + + z: open || dialogTransition.running ? 2 : 0 + Layout.fillWidth: true + + implicitHeight: fabRoot.implicitHeight + + function prettyModelName(): string { + if (modelsContent.item?.modelsList.count < 1) + return qsTr("No models found"); + + const pathList = LlmClient.model.split("\/"); + const name = pathList[pathList.length - 1]; + + if (!name) + return qsTr("No model selected"); + + return name; + } + + Behavior on implicitHeight { + Anim {} + } + + function reparentWrapper(): void { + const newParent = open ? root.rootParent : root; + const c = modelsWrapper.parent.mapToItem(newParent, modelsWrapper.x + modelsWrapper.width / 2, modelsWrapper.y + modelsWrapper.height / 2); + root.collapsedY = modelsWrapper.y; + modelsWrapper.parent = newParent; + modelsWrapper.x = c.x - modelsWrapper.width / 2; + modelsWrapper.y = c.y - modelsWrapper.height / 2; + } + + BlobGroup { + id: blobGroup + + color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer + + Behavior on color { + CAnim {} + } + } + + MouseArea { + id: backdrop + + anchors.fill: parent + enabled: false + hoverEnabled: enabled + preventStealing: true + parent: root.open ? root.rootParent : root + + onClicked: root.open = false + } + + Item { + id: modelsWrapper + + width: root.width + height: modelBox.implicitHeight + z: 1 + + states: State { + name: "open" + when: root.open + + PropertyChanges { + backdrop.enabled: true + modelsBackground.radius: Tokens.rounding.largeIncreased + modelsContent.opacity: 1 + modelsWrapper.height: root.openHeight + modelsWrapper.width: root.openWidth + modelsWrapper.x: (root.rootParent.width - root.openWidth) / 2 + modelsWrapper.y: root.rootParent.height - root.openHeight - Tokens.spacing.medium - Tokens.padding.medium - root.fabRoot.implicitHeight + elevation.opacity: 1 + modelBox.opacity: 0 + } + } + transitions: Transition { + id: dialogTransition + + SequentialAnimation { + ScriptAction { + script: root.reparentWrapper() + } + + Anim { + properties: "x,y" + } + } + + PropertyAction { + property: "enabled" + } + + Anim { + properties: "opacity,radius" + type: Anim.DefaultEffects + } + + Anim { + properties: "width,height" + } + } + + Elevation { + id: elevation + + anchors.fill: parent + level: 4 + opacity: 0 + radius: modelsBackground.radius + + transform: Matrix4x4 { + matrix: modelsBackground.deformMatrix + } + } + + BlobRect { + id: modelsBackground + + anchors.fill: parent + radius: modelLayer.pressed ? Tokens.rounding.small : Tokens.rounding.extraLarge + deformScale: 0 + group: blobGroup + opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) + + Behavior on radius { + Anim { + type: Anim.DefaultEffects + } + } + } + + CustomRect { + id: modelBox + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + implicitHeight: root.fabRoot.implicitHeight + radius: modelsBackground.radius + + CustomText { + id: label + text: root.prettyModelName() + color: Colors.palette.m3onSurfaceVariant + anchors.left: parent.left + anchors.leftMargin: Tokens.padding.large + anchors.verticalCenter: parent.verticalCenter + } + + MaterialIcon { + color: Colors.palette.m3onSurfaceVariant + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.large + anchors.verticalCenter: parent.verticalCenter + text: "unfold_more" + fontStyle: Tokens.font.icon.large + } + + StateLayer { + id: modelLayer + + onClicked: { + ChatState.fabExpanded = false; + root.open = true; + } + } + } + + Loader { + id: modelsContent + + active: opacity > 0 + anchors.fill: parent + asynchronous: false + opacity: 0 + + sourceComponent: MouseArea { + readonly property alias modelsList: modelsList + + onWheel: event => event.accepted = true + preventStealing: true + clip: true + implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins * 2 + + ColumnLayout { + id: innerLayout + anchors.bottomMargin: Tokens.padding.largeIncreased + anchors.fill: parent + anchors.margins: Tokens.padding.extraLarge + spacing: 0 + + CustomText { + font: Tokens.font.title.builders.large.weight(Font.Normal).build() + text: root.header + } + + VerticalFadeListView { + id: modelsList + + Layout.fillWidth: true + Layout.leftMargin: root.horizontalContentMargin + Layout.topMargin: Tokens.spacing.medium + Layout.bottomMargin: Tokens.spacing.medium + Layout.rightMargin: root.horizontalContentMargin + Layout.preferredHeight: 400 + Layout.maximumHeight: 400 + model: LlmClient.availableModels + currentIndex: LlmClient.availableModels.indexOf(LlmClient.model) + visible: opacity > 0 + + Behavior on opacity { + Anim {} + } + + CustomScrollBar.vertical: CustomScrollBar { + flickable: modelsList + } + + delegate: CustomRect { + id: model + + required property var modelData + required property int index + readonly property bool selected: ListView.view.currentIndex === model.index + + implicitWidth: ListView.view.width + implicitHeight: name.implicitHeight + Tokens.padding.small * 2 + radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium + color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0) + + Behavior on radius { + Anim { + type: Anim.SlowEffects + } + } + + RowLayout { + id: layout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.small + anchors.leftMargin: Tokens.padding.large + anchors.rightMargin: Tokens.padding.large + + CustomText { + id: name + + text: model.modelData + elide: CustomText.ElideRight + color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface + + Layout.fillWidth: true + } + + MaterialIcon { + id: check + + color: Colors.palette.m3onTertiaryContainer + fontStyle: Tokens.font.icon.medium + opacity: model.selected ? 1 : 0 + visible: opacity > 0 + text: "check" + + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + } + + StateLayer { + id: stateLayer + + onClicked: { + model.ListView.view.currentIndex = model.index; + } + } + } + } + + RowLayout { + Layout.alignment: Qt.AlignRight + spacing: Tokens.spacing.extraSmall + + TextButton { + horizontalPadding: Tokens.padding.largeIncreased + isRound: true + text: qsTr("Cancel") + type: TextButton.Text + verticalPadding: Tokens.padding.small + stateLayer.preventStealing: true + + onClicked: { + root.open = false; + } + } + + TextButton { + horizontalPadding: Tokens.padding.largeIncreased + isRound: true + text: root.acceptLabel + type: TextButton.Text + stateLayer.preventStealing: true + verticalPadding: Tokens.padding.small + + onClicked: { + LlmClient.selectModel(modelsList.currentItem.modelData); + root.open = false; + } + } + } + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml index 4bcfdc7..43ad59a 100644 --- a/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml +++ b/Modules/Notifications/Sidebar/Chat/Content/ProcessBlock.qml @@ -18,9 +18,9 @@ Item { required property ChatGeneration current readonly property bool isActive: index === blocks.length - 1 property bool expanded: false - readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1] + readonly property var lastSegment: root.segments[root.segments.length - 1] readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0) - readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).some(s => current.pendingToolCalls.includes(s)) + readonly property bool pendingApproval: current.toolApprovalPending && segments.some(s => s.type === LlmSegment.Type.ToolCall && s.status === LlmToolCall.Status.Pending) implicitHeight: { const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin; @@ -112,396 +112,17 @@ Item { } } - Item { + ApprovalPrompt { id: approvalPrompt + anchors.top: layout.bottom anchors.topMargin: Tokens.spacing.small anchors.left: parent.left anchors.right: parent.right - implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2 visible: root.pendingApproval - CustomRect { - id: approvalBg - - anchors.fill: parent - radius: Tokens.rounding.medium - color: Colors.palette.m3surface - border.width: 1 - border.color: Colors.palette.m3outlineVariant - - ColumnLayout { - id: approvalLayout - anchors.fill: parent - anchors.margins: Tokens.padding.medium - spacing: Tokens.spacing.small - - CustomText { - id: requestHeader - text: qsTr("Tool call request") - font: Tokens.font.headline.medium - color: Colors.palette.m3onSurface - } - - Item { - id: toolsBg - - property bool open: false - property Item rootParent: root.rootParent - property int maxHeight: rootParent.height - Tokens.padding.medium * 2 - property int openHeight: Math.min(toolsContent.item?.implicitHeight, maxHeight) - property int openWidth: toolsBg.rootParent.width - Tokens.padding.medium * 2 - property real openHeightProg: openHeight / maxHeight - property int collapsedY: 0 - - implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2 - z: open || dialogTransition.running ? 2 : 0 - Layout.fillWidth: true - - function reparentWrapper(): void { - const newParent = open ? toolsBg.rootParent : toolsBg; - const c = toolsWrapper.parent.mapToItem(newParent, toolsWrapper.x + toolsWrapper.width / 2, toolsWrapper.y + toolsWrapper.height / 2); - toolsBg.collapsedY = toolsWrapper.y; - toolsWrapper.parent = newParent; - toolsWrapper.x = c.x - toolsWrapper.width / 2; - toolsWrapper.y = c.y - toolsWrapper.height / 2; - } - - onOpenChanged: { - if (open) - toolsBg.rootParent.interactive = false; - else - toolsBg.rootParent.interactive = true; - } - - BlobGroup { - id: blobGroup - - color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer - - Behavior on color { - CAnim {} - } - } - - MouseArea { - id: backdrop - - anchors.fill: parent - enabled: false - hoverEnabled: enabled - preventStealing: true - parent: toolsBg.open ? toolsBg.rootParent : toolsBg - - onClicked: toolsBg.open = false - } - - Item { - id: toolsWrapper - - width: toolsBg.width - height: toolBox.implicitHeight - rotation: parent === toolsBg.rootParent ? 180 : 0 - z: 1 - - states: State { - name: "open" - when: toolsBg.open - - PropertyChanges { - backdrop.enabled: true - toolsBackground.bottomLeftRadius: Tokens.rounding.largeIncreased - toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased - toolsBackground.topRightRadius: Tokens.rounding.largeIncreased - toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased - toolsContent.opacity: 1 - toolsWrapper.height: toolsBg.openHeight - toolsWrapper.width: toolsBg.openWidth - toolsWrapper.x: (toolsBg.rootParent.width - toolsBg.openWidth) / 2 - toolsWrapper.y: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium - elevation.opacity: 1 - toolBox.opacity: 0 - } - } - transitions: Transition { - id: dialogTransition - - SequentialAnimation { - ScriptAction { - script: toolsBg.reparentWrapper() - } - - Anim { - properties: "x,y" - } - } - - PropertyAction { - property: "enabled" - } - - Anim { - properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius" - type: Anim.DefaultEffects - } - - Anim { - properties: "width,height" - } - } - - Elevation { - id: elevation - - anchors.fill: parent - bottomLeftRadius: toolsBackground.bottomLeftRadius - bottomRightRadius: toolsBackground.bottomRightRadius - level: 4 - opacity: 0 - radius: toolsBackground.radius - - transform: Matrix4x4 { - matrix: toolsBackground.deformMatrix - } - } - - BlobRect { - id: toolsBackground - - anchors.fill: parent - bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small - bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small - topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small - topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small - deformScale: 0 - group: blobGroup - opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) - } - - CustomRect { - id: toolBox - anchors.left: parent.left - anchors.right: parent.right - height: Math.min(implicitHeight, parent.height) - implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2 - radius: Tokens.rounding.small - - StateLayer { - onClicked: { - toolsBg.open = true; - } - } - - ColumnLayout { - id: toolList - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.margins: Tokens.padding.medium - - Repeater { - id: repeater - - model: root.current.pendingToolCalls?.slice(0, 4) ?? [] - - delegate: RowLayout { - id: toolText - - required property LlmSegment modelData - required property int index - - spacing: Tokens.spacing.medium - - function toolTarget(seg: LlmSegment): string { - var a = null; - try { - a = JSON.parse(seg.arguments); - } catch (e) {} - if (a && typeof a === "object") { - const v = a.url ?? a.path; - if (v !== undefined && v !== null) - return String(v); - } - return String(seg.arguments); - } - - MaterialIcon { - color: Colors.palette.m3onPrimaryContainer - text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet" - fill: 1 - } - - CustomText { - color: Colors.palette.m3onPrimaryContainer - text: qsTr("%1").arg(toolText.modelData.name) - } - - MaterialIcon { - color: Colors.palette.m3onPrimaryContainer - text: "arrow_right_alt" - } - - CustomText { - color: Colors.palette.m3onPrimaryContainer - text: qsTr("%1").arg(toolText.toolTarget(toolText.modelData)) - } - } - } - - CustomText { - color: Colors.palette.m3onPrimaryContainer - text: qsTr("And %1 more").arg(root.current.pendingToolCalls.slice(4).length) - visible: repeater.count < root.current.pendingToolCalls.length - } - } - } - - Loader { - id: toolsContent - - active: opacity > 0 - anchors.fill: parent - asynchronous: false - opacity: 0 - - sourceComponent: MouseArea { - onWheel: event => event.accepted = true - preventStealing: true - implicitHeight: innerLayout.contentHeight + innerLayout.anchors.margins * 2 - - VerticalFadeListView { - id: innerLayout - - anchors.fill: parent - anchors.margins: Tokens.padding.small - cacheBuffer: height * 10 - fadeAmount: 0.1 - fadeEnabled: interactive - spacing: 0 - clip: true - interactive: contentHeight > toolsBg.maxHeight - - model: root.current.pendingToolCalls ?? [] - - delegate: Item { - id: toolCall - - required property LlmSegment modelData - property bool expanded: false - - implicitWidth: CustomListView.view.width - implicitHeight: collapsedRow.implicitHeight + collapsedRow.anchors.margins + (expanded ? expandedTool.implicitHeight + expandedTool.anchors.margins + expandedTool.anchors.topMargin : collapsedRow.anchors.margins) - - function toolTarget(seg: LlmSegment): string { - var a = null; - try { - a = JSON.parse(seg.arguments); - } catch (e) {} - if (a && typeof a === "object") { - const v = a.url ?? a.path; - if (v !== undefined && v !== null) - return String(v); - } - return String(seg.arguments); - } - - Behavior on implicitHeight { - Anim {} - } - - RowLayout { - id: collapsedRow - - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.margins: Tokens.padding.medium - - IconButton { - id: toolExpandBtn - - font: Tokens.font.icon.medium - icon: "keyboard_arrow_down" - inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline - rotation: toolCall.expanded ? 180 : 0 - type: IconButton.Text - - Behavior on rotation { - Anim {} - } - - onClicked: toolCall.expanded = !toolCall.expanded - } - - CustomText { - id: preview - - Layout.fillWidth: true - - text: toolCall.modelData.name - } - } - - CustomClippingRect { - id: expandedTool - - anchors.top: collapsedRow.bottom - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.margins: Tokens.padding.medium - anchors.topMargin: Tokens.spacing.medium - - implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2 - radius: Tokens.rounding.medium - color: Colors.palette.m3surfaceContainerLow - - CustomText { - id: fullToolCall - - anchors.fill: parent - anchors.margins: Tokens.padding.medium - - text: toolCall.toolTarget(toolCall.modelData) - wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere - } - } - } - } - } - } - } - } - - ButtonRow { - id: confirmationRow - Layout.alignment: Qt.AlignRight - spacing: Tokens.spacing.medium - - IconTextButton { - icon: "check_circle" - text: qsTr("Allow") - horizontalPadding: Tokens.padding.large - verticalPadding: Tokens.padding.small - isRound: true - shapeMorph: true - onClicked: root.current.approveTools() - } - - IconTextButton { - icon: "cancel" - text: qsTr("Deny") - isRound: true - shapeMorph: true - inactiveColor: Colors.palette.m3error - inactiveOnColor: Colors.palette.m3onError - horizontalPadding: Tokens.padding.large - verticalPadding: Tokens.padding.small - onClicked: root.current.denyTools() - } - } - } - } + rootParent: root.rootParent + current: root.current } CustomClippingRect { @@ -533,7 +154,7 @@ Item { delegate: Item { id: segment - required property LlmSegment modelData + required property var modelData required property int index implicitWidth: ListView.view.width @@ -560,7 +181,7 @@ Item { CustomText { id: content - function toolTarget(seg: LlmSegment): string { + function toolTarget(seg: var): string { var a = null; try { a = JSON.parse(seg.arguments); @@ -573,29 +194,29 @@ Item { return String(seg.arguments); } - function prettyTool(seg: LlmSegment): string { + function prettyTool(seg: var): string { if (seg.name === "webfetch") { switch (seg.status) { - case LlmSegment.Status.Running: + case LlmToolCall.Status.Running: return qsTr("Fetching"); - case LlmSegment.Status.Pending: + case LlmToolCall.Status.Pending: return qsTr("Pending fetch"); - case LlmSegment.Status.Success: + case LlmToolCall.Status.Success: return qsTr("Fetched"); - case LlmSegment.Status.Error: + case LlmToolCall.Status.Error: return qsTr("Failed to fetch"); default: return qsTr("Fetched website"); } } else if (seg.name === "readfile") { switch (seg.status) { - case LlmSegment.Status.Running: + case LlmToolCall.Status.Running: return qsTr("Reading"); - case LlmSegment.Status.Pending: + case LlmToolCall.Status.Pending: return qsTr("Pending read"); - case LlmSegment.Status.Success: + case LlmToolCall.Status.Success: return qsTr("Read"); - case LlmSegment.Status.Error: + case LlmToolCall.Status.Error: return qsTr("Failed to read"); default: return qsTr("Read file"); diff --git a/Modules/Notifications/Sidebar/Chat/SidebarView.qml b/Modules/Notifications/Sidebar/Chat/SidebarView.qml index 748a3ae..38b0e50 100644 --- a/Modules/Notifications/Sidebar/Chat/SidebarView.qml +++ b/Modules/Notifications/Sidebar/Chat/SidebarView.qml @@ -65,9 +65,7 @@ Item { } } - model: ScriptModel { - values: Chat.chats.values - } + model: LlmClient.chats onLoadChatRequest: (chat, index) => { ChatState.currentIdx = index; @@ -75,17 +73,13 @@ Item { } onDeleteChatRequest: chat => { - if (chat === ChatState.chatSession) { - ChatState.chatSession = null; - ChatState.currentIdx = -1; - } - Chat.chats.remove(chat); - } - onNewChatRequest: { - const data = Chat.chats.insert(); - ChatState.currentIdx = 0; - ChatState.chatSession = data; + const wasCurrent = chat === ChatState.chatSession; + LlmClient.chats.remove(chat); + + if (wasCurrent) + ChatState.openDraft(); } + onNewChatRequest: ChatState.openDraft() onRequestExpand: { ChatState.narrowSidebarExpanded = true; @@ -157,30 +151,6 @@ Item { root.chatOpen = true } - ColumnLayout { - id: noChatLayout - anchors.centerIn: parent - opacity: ChatState.chatSession ? 0 : 1 - visible: opacity > 0 - - Behavior on opacity { - Anim {} - } - - MaterialIcon { - Layout.alignment: Qt.AlignCenter - text: "chat_bubble_off" - fontStyle: Tokens.font.icon.extraLarge - color: Colors.tPalette.m3onSurfaceVariant - } - - CustomText { - Layout.alignment: Qt.AlignCenter - text: qsTr("Start or create a chat") - color: Colors.tPalette.m3onSurfaceVariant - } - } - MouseArea { id: dimArea anchors.fill: parent @@ -192,8 +162,10 @@ Item { const x = mouseX; const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2; - if (x > invertedRect.borderLeft - margin) + if (x > invertedRect.borderLeft - margin) { ChatState.narrowSidebarExpanded = false; + ChatState.fabExpanded = false; + } } HoverHandler { diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 41a6728..6167cb7 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -107,7 +107,7 @@ Item { GeminiGlow { innerGlow: 50 z: 5 - opacity: Chat.busy ? 1.0 : 0.0 + opacity: LlmClient.busy ? 1.0 : 0.0 glowSize: 0 visible: opacity > 0 && !ChatState.isWindow diff --git a/Modules/Notifications/Sidebar/Wrapper.qml b/Modules/Notifications/Sidebar/Wrapper.qml index e286918..6934644 100644 --- a/Modules/Notifications/Sidebar/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Wrapper.qml @@ -30,9 +30,9 @@ Item { } Connections { - target: Chat + target: LlmClient - function onErrorOccurred(message: string): void { + function onGenerationFailed(session: ChatSession, message: string): void { if (root.shouldBeActive && root.chatActive) return; diff --git a/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml index d97d5be..9eda863 100644 --- a/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml +++ b/Modules/Settings/Pages/Panels/Sidebar/SidebarLlm.qml @@ -88,6 +88,7 @@ PageBase { id: selectScheme acceptLabel: qsTr("Confirm") + settingAnchor: "panels-sidebar-llm-scheme" enabled: Object.keys(model).length > 0 header: qsTr("Select scheme") first: true diff --git a/Plugins/ZShell/Llm/CMakeLists.txt b/Plugins/ZShell/Llm/CMakeLists.txt index 5501b34..5c218f6 100644 --- a/Plugins/ZShell/Llm/CMakeLists.txt +++ b/Plugins/ZShell/Llm/CMakeLists.txt @@ -232,22 +232,25 @@ qml_module(ZShell-llm URI ZShell.Llm SOURCES attachment.hpp attachment.cpp - chat.hpp chat.cpp chatstore.hpp chatstore.cpp + clipboard.hpp clipboard.cpp codehighlighter.hpp codehighlighter.cpp filetool.hpp filetool.cpp generation.hpp generation.cpp llmclient.hpp llmclient.cpp - markdownblock.hpp + markdownblock.hpp markdownblock.cpp markdownparser.hpp markdownparser.cpp mathtext.hpp mathtext.cpp message.hpp message.cpp messagemodel.hpp messagemodel.cpp + prompts.hpp prompts.cpp segment.hpp segment.cpp session.hpp session.cpp shelltool.hpp shelltool.cpp tool.hpp tool.cpp + turn.hpp turn.cpp webfetchtool.hpp webfetchtool.cpp + workspace.hpp workspace.cpp LIBRARIES Qt::Network Qt::Sql diff --git a/Plugins/ZShell/Llm/chat.cpp b/Plugins/ZShell/Llm/chat.cpp deleted file mode 100644 index 4440386..0000000 --- a/Plugins/ZShell/Llm/chat.cpp +++ /dev/null @@ -1,201 +0,0 @@ -#include "chat.hpp" - -#include "filetool.hpp" -#include "llmclient.hpp" -#include "shelltool.hpp" -#include "webfetchtool.hpp" - -#include "config/rootnodes.hpp" -#include "config/llm.hpp" - -#include - -namespace ZShell::llm { - -Chat::Chat(QObject* parent) - : QObject(parent) - , m_store(new ChatStore(this)) - , m_client(new LlmClient(this)) { - m_store->setLlmClient(m_client); - m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); - m_client->tools()->registerTool(new FileReadTool(m_client->tools())); - m_client->tools()->registerTool(new ShellTool(m_client, m_client->tools())); - - const auto* llm = config::ConfigSingleton::instance()->llm(); - m_client->setEndpoint(llm->endpoint()); - m_client->setModel(llm->model()); - m_client->setTemperature(llm->temperature()); - m_client->setToolsEnabled(llm->tools()); - m_client->setSandboxEnabled(llm->sandbox()); - - connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { - m_client->setEndpoint(llm->endpoint()); - }); - connect(llm, &config::Llm::modelChanged, this, [this, llm]() { - m_client->setModel(llm->model()); - }); - connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() { - m_client->setTemperature(llm->temperature()); - }); - connect(llm, &config::Llm::toolsChanged, this, [this, llm]() { - m_client->setToolsEnabled(llm->tools()); - }); - connect(llm, &config::Llm::sandboxChanged, this, [this, llm]() { - m_client->setSandboxEnabled(llm->sandbox()); - Q_EMIT sandboxEnabledChanged(); - }); - - connect(m_client, &LlmClient::busyChanged, this, [this]() { - if (m_client->busy() && (m_hasError || !m_errorMessage.isEmpty())) { - m_hasError = false; - m_errorMessage.clear(); - Q_EMIT errorChanged(); - } - Q_EMIT busyChanged(); - }); - connect(m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged); - connect(m_client, &LlmClient::modelChanged, this, &Chat::modelChanged); - connect( - m_client, - &LlmClient::availableModelsChanged, - this, - &Chat::availableModelsChanged); - connect( - m_client, - &LlmClient::contextSizeChanged, - this, - &Chat::contextSizeChanged); - connect( - m_client, - &LlmClient::streamingChatIdChanged, - this, - &Chat::streamingChatIdChanged); - connect( - m_client, - &LlmClient::toolsEnabledChanged, - this, - &Chat::toolsEnabledChanged); - connect( - m_client, - &LlmClient::errorOccurred, - this, - [this](const QString& message) { - m_hasError = !message.isEmpty(); - m_errorMessage = message; - Q_EMIT errorChanged(); - Q_EMIT errorOccurred(message); - }); - connect( - m_client, - &LlmClient::serverErrorChanged, - this, - &Chat::serverErrorChanged); - connect( - m_store, - &ChatStore::sessionRemoved, - this, - [this](ChatSession* session) { m_client->sessionRemoved(session); }); - connect( - m_client, - &LlmClient::titleSuggested, - this, - [this](ChatSession* session, const QString& title) { - qInfo() << "Chat: applying generated title" << session->id() - << title << "(was" << session->title() << ")"; - session->setTitle(title); - m_store->saveMeta(session); - }); - connect( - m_client, - &LlmClient::iconSuggested, - this, - [this](ChatSession* session, const QString& icon) { - qInfo() << "Chat: applying generated icon" << session->id() << icon - << "(was" << session->icon() << ")"; - session->setIcon(icon); - m_store->saveMeta(session); - }); -} - -bool Chat::busy() const { - return m_client->busy(); -} - -QString Chat::endpoint() const { - return m_client->endpoint(); -} - -QString Chat::model() const { - return m_client->model(); -} - -QStringList Chat::availableModels() const { - return m_client->availableModels(); -} - -int Chat::contextSize() const { - return m_client->contextSize(); -} - -bool Chat::toolsEnabled() const { - return m_client->toolsEnabled(); -} - -void Chat::setToolsEnabled(bool value) { - m_client->setToolsEnabled(value); - if (auto* config = config::ConfigSingleton::instance()) - config->llm()->set_tools(value); -} - -bool Chat::sandboxEnabled() const { - return m_client->sandboxEnabled(); -} - -void Chat::setSandboxEnabled(bool value) { - m_client->setSandboxEnabled(value); - if (auto* config = config::ConfigSingleton::instance()) - config->llm()->set_sandbox(value); -} - -QString Chat::serverError() const { - return m_client->serverError(); -} - -QString Chat::streamingChatId() const { - return m_client->streamingChatId(); -} - -Chat* Chat::s_instance = nullptr; - -Chat* Chat::create(QQmlEngine*, QJSEngine*) { - if (!s_instance) s_instance = new Chat(); - return s_instance; -} - -void Chat::stop() { - m_client->stop(); -} - -void Chat::dismissError() { - if (!m_hasError && m_errorMessage.isEmpty()) return; - m_hasError = false; - m_errorMessage.clear(); - Q_EMIT errorChanged(); -} - -void Chat::refreshModels() { - m_client->refreshModels(); -} - -void Chat::refreshFromServer() { - m_client->refreshAllStates(); -} - -void Chat::selectModel(const QString& id) { - if (id.isEmpty()) return; - m_client->setModel(id); - if (auto* config = config::ConfigSingleton::instance()) - config->llm()->set_model(id); -} - -} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chat.hpp b/Plugins/ZShell/Llm/chat.hpp deleted file mode 100644 index 89a37c1..0000000 --- a/Plugins/ZShell/Llm/chat.hpp +++ /dev/null @@ -1,91 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "chatstore.hpp" - -class QQmlEngine; -class QJSEngine; - -namespace ZShell::llm { - -class LlmClient; - -class Chat : public QObject { - Q_OBJECT - QML_ELEMENT - QML_SINGLETON - - Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) - Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged) - Q_PROPERTY(QString model READ model NOTIFY modelChanged) - Q_PROPERTY( - QStringList availableModels READ availableModels NOTIFY - availableModelsChanged) - Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged) - Q_PROPERTY( - bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY - toolsEnabledChanged) - Q_PROPERTY( - bool sandboxEnabled READ sandboxEnabled WRITE setSandboxEnabled NOTIFY - sandboxEnabledChanged) - Q_PROPERTY(bool error READ error NOTIFY errorChanged) - Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged) - Q_PROPERTY(QString serverError READ serverError NOTIFY serverErrorChanged) - Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) - Q_PROPERTY( - QString streamingChatId READ streamingChatId NOTIFY - streamingChatIdChanged) - - public: - explicit Chat(QObject* parent = nullptr); - - [[nodiscard]] bool busy() const; - [[nodiscard]] QString endpoint() const; - [[nodiscard]] QString model() const; - [[nodiscard]] QStringList availableModels() const; - [[nodiscard]] int contextSize() const; - [[nodiscard]] bool toolsEnabled() const; - void setToolsEnabled(bool value); - [[nodiscard]] bool sandboxEnabled() const; - void setSandboxEnabled(bool value); - [[nodiscard]] bool error() const { return m_hasError; } - [[nodiscard]] QString errorMessage() const { return m_errorMessage; } - [[nodiscard]] QString serverError() const; - [[nodiscard]] ChatStore* chats() const { return m_store; } - [[nodiscard]] QString streamingChatId() const; - - Q_INVOKABLE void stop(); - Q_INVOKABLE void dismissError(); - Q_INVOKABLE void refreshModels(); - Q_INVOKABLE void refreshFromServer(); - Q_INVOKABLE void selectModel(const QString& id); - - static Chat* create(QQmlEngine*, QJSEngine*); - - Q_SIGNALS: - void busyChanged(); - void endpointChanged(); - void modelChanged(); - void availableModelsChanged(); - void contextSizeChanged(); - void toolsEnabledChanged(); - void sandboxEnabledChanged(); - void errorOccurred(const QString& message); - void errorChanged(); - void serverErrorChanged(); - void streamingChatIdChanged(); - - private: - ChatStore* m_store = nullptr; - LlmClient* m_client = nullptr; - bool m_hasError = false; - QString m_errorMessage; - - static Chat* s_instance; -}; - -} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chatstore.cpp b/Plugins/ZShell/Llm/chatstore.cpp index 86ef46b..2feac92 100644 --- a/Plugins/ZShell/Llm/chatstore.cpp +++ b/Plugins/ZShell/Llm/chatstore.cpp @@ -1,6 +1,8 @@ #include "chatstore.hpp" +#include "attachment.hpp" #include "llmclient.hpp" +#include "markdownblock.hpp" #include "message.hpp" #include "segment.hpp" @@ -11,15 +13,12 @@ #include #include #include -#include #include #include #include #include #include -#include - namespace ZShell::llm { namespace { @@ -76,22 +75,25 @@ struct AttachmentRow { struct MessageRow { bool user = false; qint64 timestamp = 0; + QString content; QVector generations; QVector attachments; }; } // namespace -ChatStore::ChatStore(QObject* parent) - : QObject(parent), m_connectionName(QUuid::createUuid().toString()) { +ChatStore::ChatStore(LlmClient* client, QObject* parent) + : QAbstractListModel(parent) + , m_client(client) + , m_connectionName(QUuid::createUuid().toString()) { openDb(); load(); } ChatStore::~ChatStore() { if (m_connectionName.isEmpty()) return; - QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); - db.close(); + QSqlDatabase handle = QSqlDatabase::database(m_connectionName, false); + handle.close(); QSqlDatabase::removeDatabase(m_connectionName); } @@ -105,20 +107,20 @@ void ChatStore::openDb() { QStringLiteral("/zshell/chats.sqlite"); QDir().mkpath(QFileInfo(m_dbPath).absolutePath()); - QSqlDatabase db = + QSqlDatabase handle = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); - db.setDatabaseName(m_dbPath); - if (!db.open()) { + handle.setDatabaseName(m_dbPath); + if (!handle.open()) { qWarning() << "ChatStore: failed to open database" << m_dbPath << ":" - << db.lastError().text(); + << handle.lastError().text(); return; } { - QSqlQuery pragma(db); + QSqlQuery pragma(handle); pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON")); } { - QSqlQuery query(db); + QSqlQuery query(handle); query.exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS sessions (\n" " id TEXT PRIMARY KEY,\n" @@ -130,18 +132,23 @@ void ChatStore::openDb() { ")")); } { - QSqlQuery query(db); + QSqlQuery query(handle); query.exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS messages (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " session_id TEXT NOT NULL REFERENCES sessions (id) " "ON DELETE CASCADE,\n" " role TEXT NOT NULL,\n" + " content TEXT NOT NULL DEFAULT '',\n" " timestamp INTEGER NOT NULL\n" ")")); + if (!query.exec(QStringLiteral( + "ALTER TABLE messages ADD COLUMN content TEXT " + "NOT NULL DEFAULT ''"))) { + } } { - QSqlQuery query(db); + QSqlQuery query(handle); query.exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS generations (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" @@ -152,7 +159,7 @@ void ChatStore::openDb() { ")")); } { - QSqlQuery query(db); + QSqlQuery query(handle); query.exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS segments (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" @@ -170,7 +177,7 @@ void ChatStore::openDb() { ")")); } { - QSqlQuery query(db); + QSqlQuery query(handle); query.exec(QStringLiteral( "CREATE TABLE IF NOT EXISTS attachments (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" @@ -184,7 +191,7 @@ void ChatStore::openDb() { ")")); } { - QSqlQuery query(db); + QSqlQuery query(handle); query.exec(QStringLiteral( "CREATE INDEX IF NOT EXISTS idx_messages_session " "ON messages (session_id)")); @@ -200,93 +207,156 @@ void ChatStore::openDb() { } } -int ChatStore::count() const { +int ChatStore::rowCount(const QModelIndex& parent) const { + if (parent.isValid()) return 0; return static_cast(m_sessions.size()); } -QVariantList ChatStore::values() const { - QVariantList vals; - vals.reserve(m_sessions.size()); - for (const auto* session : m_sessions) - vals.append(QVariant::fromValue(session)); - return vals; +QVariant ChatStore::data(const QModelIndex& index, int role) const { + if (role != Qt::UserRole || !index.isValid() || index.row() < 0 || + index.row() >= m_sessions.size()) + return {}; + return QVariant::fromValue(m_sessions.at(index.row())); } -ChatSession* ChatStore::at(int index) const { - if (index < 0 || index >= m_sessions.size()) return nullptr; - return m_sessions.at(index); +QHash ChatStore::roleNames() const { + return {{Qt::UserRole, "modelData"}}; } -ChatSession* ChatStore::insert(int index) { +int ChatStore::rowOf(const ChatSession* session) const { + for (int i = 0; i < m_sessions.size(); ++i) + if (m_sessions.at(i) == session) return i; + return -1; +} + +bool ChatStore::firstAfter(const ChatSession* a, const ChatSession* b) { + if (a->pinned() != b->pinned()) return a->pinned(); + return a->updatedAtMs() > b->updatedAtMs(); +} + +int ChatStore::sortedRow(const ChatSession* session) const { + int row = 0; + while (row < m_sessions.size() && !firstAfter(session, m_sessions.at(row))) + ++row; + return row; +} + +void ChatStore::insertRow(ChatSession* session) { + const int row = sortedRow(session); + beginInsertRows(QModelIndex(), row, row); + m_sessions.insert(row, session); + endInsertRows(); +} + +void ChatStore::moveRow(int from, int to) { + if (from < 0 || from >= m_sessions.size() || to < 0 || + to > m_sessions.size() || from == to || to == from + 1) + return; + if (!beginMoveRows(QModelIndex(), from, from, QModelIndex(), to)) return; + m_sessions.move(from, to > from ? to - 1 : to); + endMoveRows(); +} + +void ChatStore::reposition(ChatSession* session) { + if (m_loading) return; + const int row = rowOf(session); + if (row < 0) return; + const int target = sortedRow(session); + if (target == row) return; + moveRow(row, target); +} + +ChatSession* ChatStore::draft() { + if (m_draft) return m_draft; const qint64 now = QDateTime::currentMSecsSinceEpoch(); - const QString id = QUuid::createUuid().toString(); - { - QSqlQuery query(db()); - query.prepare( - "INSERT INTO sessions (id, title, created_at, updated_at) " - "VALUES (:id, '', :created_at, :updated_at)"); - query.bindValue(":id", id); - query.bindValue(":created_at", now); - query.bindValue(":updated_at", now); - if (!query.exec()) - qWarning() << "ChatStore: failed to insert session" << id << ":" - << query.lastError().text(); + m_draft = new ChatSession(this, QUuid::createUuid().toString(), this); + m_draft->hydrate(QString(), QString(), now, now, 0, false); + return m_draft; +} + +void ChatStore::commitDraft(ChatSession* session) { + if (!session || session->isCommitted()) return; + if (m_draft == session) m_draft = nullptr; + + if (!writeSessionRow(session)) return; + session->setCommitted(true); + insertRow(session); +} + +bool ChatStore::writeSessionRow(ChatSession* session) { + QSqlQuery query(db()); + query.prepare( + "INSERT INTO sessions (id, title, icon, created_at, updated_at, " + "pinned) VALUES (:id, :title, :icon, :created_at, :updated_at, " + ":pinned)"); + query.bindValue(":id", session->id()); + query.bindValue(":title", sqlText(session->title())); + query.bindValue(":icon", sqlText(session->icon())); + query.bindValue(":created_at", session->createdAtMs()); + query.bindValue(":updated_at", session->updatedAtMs()); + query.bindValue(":pinned", session->pinned() ? 1 : 0); + if (!query.exec()) { + qWarning() << "ChatStore: failed to insert session" << session->id() + << ":" << query.lastError().text(); + return false; } - auto* session = new ChatSession(id, this); - session->setMeta(QString(), now, now, 0); - const int pos = index >= 0 && index <= m_sessions.size() ? index : 0; - m_sessions.insert(pos, session); - Q_EMIT countChanged(); - Q_EMIT valuesChanged(); - return session; + return true; } -void ChatStore::remove(int index) { - removeSession(at(index)); -} +void ChatStore::removeRow(ChatSession* session) { + if (!session) return; + const int row = rowOf(session); -void ChatStore::remove(ChatSession* chat) { - removeSession(chat); -} - -void ChatStore::removeSession(ChatSession* session) { - if (!session || !m_sessions.contains(session)) return; - const QList before = m_sessions; Q_EMIT sessionRemoved(session); + { QSqlQuery query(db()); query.prepare("DELETE FROM sessions WHERE id = :id"); query.bindValue(":id", session->id()); query.exec(); } - QDir(ChatSession::sandboxDirFor(session->id())).removeRecursively(); - m_sessions.removeOne(session); + + if (row >= 0) { + beginRemoveRows(QModelIndex(), row, row); + m_sessions.removeAt(row); + endRemoveRows(); + } session->deleteLater(); - notify(before); } -void ChatStore::move(int from, int to) { - if (from < 0 || from >= m_sessions.size() || to < 0 || - to >= m_sessions.size() || from == to) +void ChatStore::remove(ChatSession* chat) { + if (!chat) return; + m_pendingPersists.remove(chat); + m_pendingClears.remove(chat); + if (m_draft == chat) m_draft = nullptr; + + if (!chat->isCommitted()) { + Q_EMIT sessionRemoved(chat); + chat->deleteLater(); return; - m_sessions.move(from, to); - Q_EMIT valuesChanged(); + } + + removeRow(chat); } void ChatStore::clear() { const QList sessions = m_sessions; for (ChatSession* session : sessions) - removeSession(session); + remove(session); } -ChatSession* ChatStore::sessionById(const QString& id) { - for (auto* session : m_sessions) - if (session->id() == id) return session; - return nullptr; +void ChatStore::requestClear(ChatSession* session) { + if (!session) return; + m_pendingClears.insert(session); + if (session->isLoaded() && m_client->streamingSession() != session) + applyPendingClear(session); } -void ChatStore::setLlmClient(LlmClient* client) { - m_llmClient = client; +void ChatStore::applyPendingClear(ChatSession* session) { + if (!session || !m_pendingClears.remove(session)) return; + if (!session->isLoaded()) return; + session->clearMessages(); + persist(session); } void ChatStore::persist(ChatSession* session) { @@ -297,21 +367,23 @@ void ChatStore::persist(ChatSession* session) { } session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); if (!saveSession(session)) return; - sortAndNotify(); + reposition(session); } -void ChatStore::saveMeta(ChatSession* session) { +void ChatStore::saveFlags(ChatSession* session) { if (!session || !m_sessions.contains(session)) return; QSqlQuery query(db()); query.prepare( - "UPDATE sessions SET title = :title, icon = :icon " - "WHERE id = :id"); + "UPDATE sessions SET title = :title, icon = :icon, " + "pinned = :pinned WHERE id = :id"); query.bindValue(":title", sqlText(session->title())); query.bindValue(":icon", sqlText(session->icon())); + query.bindValue(":pinned", session->pinned() ? 1 : 0); query.bindValue(":id", session->id()); if (!query.exec()) - qWarning() << "ChatStore: failed to save meta for" << session->id() + qWarning() << "ChatStore: failed to save flags for" << session->id() << ":" << query.lastError().text(); + reposition(session); } bool ChatStore::saveSession(ChatSession* session) { @@ -326,10 +398,12 @@ bool ChatStore::saveSession(ChatSession* session) { { QSqlQuery query(handle); query.prepare( - "UPDATE sessions SET title = :title, updated_at = :updated_at " - "WHERE id = :id"); + "UPDATE sessions SET title = :title, icon = :icon, " + "updated_at = :updated_at, pinned = :pinned WHERE id = :id"); query.bindValue(":title", sqlText(session->title())); + query.bindValue(":icon", sqlText(session->icon())); query.bindValue(":updated_at", session->updatedAtMs()); + query.bindValue(":pinned", session->pinned() ? 1 : 0); query.bindValue(":id", session->id()); ok = query.exec(); } @@ -342,8 +416,8 @@ bool ChatStore::saveSession(ChatSession* session) { if (ok) { QSqlQuery messageInsert(handle); ok = messageInsert.prepare( - "INSERT INTO messages (session_id, role, timestamp) " - "VALUES (:id, :role, :timestamp)"); + "INSERT INTO messages (session_id, role, content, timestamp) " + "VALUES (:id, :role, :content, :timestamp)"); QSqlQuery generationInsert(handle); ok = ok && generationInsert.prepare( @@ -371,6 +445,7 @@ bool ChatStore::saveSession(ChatSession* session) { message->role() == ChatMessage::Role::User ? QStringLiteral("user") : QStringLiteral("assistant")); + messageInsert.bindValue(":content", sqlText(message->content())); messageInsert.bindValue(":timestamp", message->timestamp()); if (!messageInsert.exec()) { ok = false; @@ -401,16 +476,24 @@ bool ChatStore::saveSession(ChatSession* session) { segmentInsert.bindValue(":gid", generationId); segmentInsert.bindValue( ":type", segmentTypeName(segment->type())); - segmentInsert.bindValue(":text", sqlText(segment->text())); - segmentInsert.bindValue(":name", sqlText(segment->name())); + const auto* text = + qobject_cast(segment); segmentInsert.bindValue( - ":tool_call_id", sqlText(segment->toolCallId())); + ":text", sqlText(text ? text->text() : QString())); + const auto* call = + qobject_cast(segment); segmentInsert.bindValue( - ":arguments", sqlText(segment->arguments())); + ":name", sqlText(call ? call->name() : QString())); segmentInsert.bindValue( - ":result", sqlText(segment->result())); + ":tool_call_id", + sqlText(call ? call->toolCallId() : QString())); segmentInsert.bindValue( - ":status", static_cast(segment->status())); + ":arguments", + sqlText(call ? call->arguments() : QString())); + segmentInsert.bindValue( + ":result", sqlText(call ? call->result() : QString())); + segmentInsert.bindValue( + ":status", call ? static_cast(call->status()) : 0); segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs()); segmentInsert.bindValue(":timestamp", segment->timestamp()); if (!segmentInsert.exec()) { @@ -448,12 +531,13 @@ bool ChatStore::saveSession(ChatSession* session) { qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back"; handle.rollback(); - ok = false; qWarning() << "ChatStore: failed to save session" << session->id() << ":" << handle.lastError().text(); + return false; } - return ok; + return true; } + void ChatStore::loadMessagesInto(ChatSession* session) { if (!session) return; const QString sessionId = session->id(); @@ -467,15 +551,15 @@ void ChatStore::loadMessagesInto(ChatSession* session) { QList rows; const QString connName = QUuid::createUuid().toString(); { - QSqlDatabase db = + QSqlDatabase handle = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName); - db.setDatabaseName(path); - if (db.open()) { - QSqlQuery busy(db); + handle.setDatabaseName(path); + if (handle.open()) { + QSqlQuery busy(handle); busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000")); - QSqlQuery query(db); + QSqlQuery query(handle); query.prepare( - "SELECT id, role, timestamp FROM messages " + "SELECT id, role, content, timestamp FROM messages " "WHERE session_id = :id ORDER BY rowid DESC"); query.bindValue(":id", sessionId); if (!query.exec()) { @@ -487,8 +571,9 @@ void ChatStore::loadMessagesInto(ChatSession* session) { MessageRow message; message.user = query.value(1).toString() == QLatin1String("user"); - message.timestamp = query.value(2).toLongLong(); - QSqlQuery generationQuery(db); + message.content = query.value(2).toString(); + message.timestamp = query.value(3).toLongLong(); + QSqlQuery generationQuery(handle); generationQuery.prepare( "SELECT id, timestamp, is_active FROM " "generations WHERE message_id = :mid " @@ -501,7 +586,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) { generationQuery.value(1).toLongLong(); generation.active = generationQuery.value(2).toInt() != 0; - QSqlQuery segmentQuery(db); + QSqlQuery segmentQuery(handle); segmentQuery.prepare( "SELECT type, text, name, tool_call_id, " "arguments, result, status, elapsed_ms, " @@ -549,7 +634,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) { << messageId << ":" << generationQuery.lastError().text(); } - QSqlQuery attachmentQuery(db); + QSqlQuery attachmentQuery(handle); attachmentQuery.prepare( "SELECT name, path, kind, mime, size FROM " "attachments WHERE message_id = :mid " @@ -580,10 +665,10 @@ void ChatStore::loadMessagesInto(ChatSession* session) { rows.append(message); } } - db.close(); + handle.close(); } else { qWarning() << "ChatStore: failed to open database for load:" - << db.lastError().text(); + << handle.lastError().text(); } } QSqlDatabase::removeDatabase(connName); @@ -595,7 +680,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) { ChatSession* s = session; if (!st || !s) return; - auto* model = s->model(); + auto* model = s->messagesModel(); if (!model) return; QList messages; for (const MessageRow& row : rows) { @@ -603,6 +688,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) { row.user ? ChatMessage::Role::User : ChatMessage::Role::Assistant, row.timestamp); + if (!row.attachments.isEmpty()) { QList attachments; for (const AttachmentRow& attachmentRow : @@ -619,27 +705,54 @@ void ChatStore::loadMessagesInto(ChatSession* session) { } message->setAttachments(attachments); } + + if (row.user) { + QString content = row.content; + if (content.isEmpty() && !row.generations.isEmpty()) { + for (const SegmentRow& segment : + row.generations.first().segments) + if (segmentTypeFromName(segment.type) == + LlmSegment::Type::Content) { + content = segment.text; + break; + } + } + if (!content.isEmpty()) message->setContent(content); + messages.append(message); + continue; + } + int activeIndex = 0; for (int i = 0; i < row.generations.size(); ++i) { const GenerationRow& generationRow = row.generations.at(i); auto* generation = message->addGeneration(generationRow.timestamp); + if (!generation) continue; for (const SegmentRow& segmentRow : generationRow.segments) { - auto* segment = new LlmSegment( - segmentTypeFromName(segmentRow.type), - segmentRow.timestamp, - generation); - segment->setText(segmentRow.text); - segment->setName(segmentRow.name); - segment->setToolCallId(segmentRow.toolCallId); - segment->appendArguments(segmentRow.arguments); - segment->setResult(segmentRow.result); - segment->setStatus( - static_cast( - segmentRow.status)); - segment->restore(segmentRow.elapsedMs); + const LlmSegment::Type type = + segmentTypeFromName(segmentRow.type); + LlmSegment* segment = nullptr; + if (type == LlmSegment::Type::ToolCall) { + auto* call = new LlmToolCall( + segmentRow.timestamp, generation); + call->setName(segmentRow.name); + call->setToolCallId(segmentRow.toolCallId); + call->appendArguments(segmentRow.arguments); + call->setResult(segmentRow.result); + call->setStatus( + static_cast( + segmentRow.status)); + call->restore(segmentRow.elapsedMs); + segment = call; + } else { + auto* text = new LlmTextSegment( + type, segmentRow.timestamp, generation); + text->setText(segmentRow.text); + text->restore(segmentRow.elapsedMs); + segment = text; + } generation->addSegment(segment); } if (generationRow.active) activeIndex = i; @@ -658,54 +771,35 @@ void ChatStore::loadMessagesInto(ChatSession* session) { s->markLoaded(); - if (s->takeClearPending()) { - s->clear(); - } else if (st->m_pendingPersists.remove(s)) { - st->persist(s); - } + st->applyPendingClear(s); + if (st->m_pendingPersists.remove(s)) st->persist(s); }, Qt::QueuedConnection); }); } void ChatStore::load() { + m_loading = true; QSqlQuery query(db()); query.exec( "SELECT s.id, s.title, s.icon, s.created_at, s.updated_at, s.pinned, " "(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS cnt " "FROM sessions s ORDER BY s.pinned DESC, s.updated_at DESC"); while (query.next()) { - auto* session = new ChatSession(query.value(0).toString(), this); - session->setMeta( + auto* session = new ChatSession(this, query.value(0).toString(), this); + session->hydrate( query.value(1).toString(), + query.value(2).toString(), query.value(3).toLongLong(), query.value(4).toLongLong(), - query.value(6).toInt()); - session->setIcon(query.value(2).toString()); - session->setPinned(query.value(5).toInt() != 0); + query.value(6).toInt(), + query.value(5).toInt() != 0); m_sessions.append(session); } - sortAndNotify(); -} + m_loading = false; -void ChatStore::sortAndNotify() { - const QList before = m_sessions; - std::stable_sort( - m_sessions.begin(), - m_sessions.end(), - [](const ChatSession* a, const ChatSession* b) { - if (a->pinned() != b->pinned()) return a->pinned() > b->pinned(); - return a->updatedAtMs() > b->updatedAtMs(); - }); - notify(before); -} - -void ChatStore::notify(const QList& before) { - if (before.size() != m_sessions.size()) Q_EMIT countChanged(); - bool same = before.size() == m_sessions.size(); - for (int i = 0; same && i < m_sessions.size(); ++i) - if (before.at(i) != m_sessions.at(i)) same = false; - if (!same) Q_EMIT valuesChanged(); + beginResetModel(); + endResetModel(); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/chatstore.hpp b/Plugins/ZShell/Llm/chatstore.hpp index 75b335b..25b901b 100644 --- a/Plugins/ZShell/Llm/chatstore.hpp +++ b/Plugins/ZShell/Llm/chatstore.hpp @@ -2,65 +2,76 @@ #include "session.hpp" -#include +#include +#include +#include #include #include #include -#include +#include namespace ZShell::llm { class LlmClient; -class ChatStore : public QObject { +class ChatStore : public QAbstractListModel { Q_OBJECT - QML_ANONYMOUS - - Q_PROPERTY(int count READ count NOTIFY countChanged) - Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged) + QML_ELEMENT + QML_UNCREATABLE("Chat sessions are owned by the LlmClient singleton") public: - explicit ChatStore(QObject* parent = nullptr); + explicit ChatStore(LlmClient* client, QObject* parent = nullptr); ~ChatStore() override; - [[nodiscard]] int count() const; - [[nodiscard]] QVariantList values() const; - [[nodiscard]] ChatSession* at(int index) const; + [[nodiscard]] int rowCount( + const QModelIndex& parent = QModelIndex()) const override; + [[nodiscard]] QVariant data( + const QModelIndex& index, int role = Qt::DisplayRole) const override; + [[nodiscard]] QHash roleNames() const override; - Q_INVOKABLE ZShell::llm::ChatSession* insert(int index = -1); - Q_INVOKABLE void remove(int index); + [[nodiscard]] LlmClient* client() const { return m_client; } + + [[nodiscard]] int rowOf(const ChatSession* session) const; + + Q_INVOKABLE ZShell::llm::ChatSession* draft(); Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat); - Q_INVOKABLE void move(int from, int to); Q_INVOKABLE void clear(); - [[nodiscard]] ChatSession* sessionById(const QString& id); - [[nodiscard]] LlmClient* llmClient() const { return m_llmClient; } - void setLlmClient(LlmClient* client); + void commitDraft(ZShell::llm::ChatSession* session); - void persist(ChatSession* session); - void saveMeta(ChatSession* session); - void loadMessagesInto(ChatSession* session); + void persist(ZShell::llm::ChatSession* session); + void saveFlags(ZShell::llm::ChatSession* session); + void loadMessagesInto(ZShell::llm::ChatSession* session); + + void requestClear(ZShell::llm::ChatSession* session); + void applyPendingClear(ZShell::llm::ChatSession* session); + + void reposition(ZShell::llm::ChatSession* session); Q_SIGNALS: - void countChanged(); - void valuesChanged(); void sessionRemoved(ZShell::llm::ChatSession* session); private: void openDb(); void load(); - bool saveSession(ChatSession* session); - void sortAndNotify(); - void removeSession(ChatSession* session); - void notify(const QList& before); + [[nodiscard]] int sortedRow(const ChatSession* session) const; + [[nodiscard]] static bool firstAfter( + const ChatSession* a, const ChatSession* b); + void insertRow(ChatSession* session); + void moveRow(int from, int to); + void removeRow(ChatSession* session); + [[nodiscard]] bool saveSession(ChatSession* session); + [[nodiscard]] bool writeSessionRow(ChatSession* session); + [[nodiscard]] QSqlDatabase db() const; + LlmClient* m_client; QList m_sessions; - LlmClient* m_llmClient = nullptr; + ChatSession* m_draft = nullptr; QString m_connectionName; QString m_dbPath; QSet m_pendingPersists; - - [[nodiscard]] QSqlDatabase db() const; + QSet m_pendingClears; + bool m_loading = false; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/clipboard.cpp b/Plugins/ZShell/Llm/clipboard.cpp new file mode 100644 index 0000000..917cd55 --- /dev/null +++ b/Plugins/ZShell/Llm/clipboard.cpp @@ -0,0 +1,61 @@ +#include "clipboard.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::llm::clipboard { + +bool hasAttachment() { + QClipboard* clipboard = QGuiApplication::clipboard(); + if (!clipboard) return false; + const QMimeData* data = clipboard->mimeData(); + if (!data) return false; + if (data->hasImage()) return true; + for (const QUrl& url : data->urls()) + if (url.isLocalFile()) return true; + return false; +} + +QStringList attachmentPaths(QString& error) { + error.clear(); + QClipboard* clipboard = QGuiApplication::clipboard(); + const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr; + if (!data) { + qWarning() << "clipboard: no clipboard data"; + return {}; + } + + QStringList paths; + if (data->hasImage()) { + const QImage image = clipboard->image(); + if (image.isNull()) { + error = QStringLiteral("Clipboard image could not be decoded"); + return {}; + } + const QString tempPath = + QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png") + .arg(QDateTime::currentMSecsSinceEpoch()); + if (!image.save(tempPath)) { + error = QStringLiteral("Could not save the pasted image"); + return {}; + } + paths.append(tempPath); + } else if (data->hasUrls()) { + for (const QUrl& url : data->urls()) + if (url.isLocalFile()) paths.append(url.toLocalFile()); + } + + if (paths.isEmpty() && (data->hasImage() || data->hasUrls())) + error = QStringLiteral( + "Nothing usable on the clipboard (expected an image " + "or copied files)"); + return paths; +} + +} // namespace ZShell::llm::clipboard diff --git a/Plugins/ZShell/Llm/clipboard.hpp b/Plugins/ZShell/Llm/clipboard.hpp new file mode 100644 index 0000000..5a2d168 --- /dev/null +++ b/Plugins/ZShell/Llm/clipboard.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +namespace ZShell::llm::clipboard { + +[[nodiscard]] bool hasAttachment(); + +[[nodiscard]] QStringList attachmentPaths(QString& error); + +} // namespace ZShell::llm::clipboard diff --git a/Plugins/ZShell/Llm/filetool.cpp b/Plugins/ZShell/Llm/filetool.cpp index d4c7b6b..c58ad82 100644 --- a/Plugins/ZShell/Llm/filetool.cpp +++ b/Plugins/ZShell/Llm/filetool.cpp @@ -6,23 +6,8 @@ namespace ZShell::llm { -namespace { - -QJsonObject makeOutput(const QString& text) { - QJsonObject obj; - obj[QStringLiteral("output")] = text; - return obj; -} - -QJsonObject makeError(const QString& message) { - QJsonObject obj; - obj[QStringLiteral("error")] = message; - return obj; -} - -} // namespace - -FileReadTool::FileReadTool(QObject* parent) : LlmTool(parent) {} +FileReadTool::FileReadTool(ToolRegistry* registry, QObject* parent) + : LlmTool(registry, parent) {} QString FileReadTool::name() const { return QStringLiteral("readfile"); @@ -34,8 +19,8 @@ QString FileReadTool::description() const { "its content. At most a limited number of characters is returned " "per call; use offset and limit to page through larger files. " "Binary files are not supported. Full output of webfetch calls " - "is stored under /tmp/zshell-llm/webfetch/ and can be read this " - "way. This tool is read-only."); + "is saved in the sandbox directory and can be read this way. " + "This tool is read-only."); } QJsonObject FileReadTool::parameters() const { @@ -72,19 +57,16 @@ QJsonObject FileReadTool::parameters() const { } void FileReadTool::execute( - const QString& toolCallId, - const QJsonObject& args, - int outputBudgetChars, - std::function done) { - Q_UNUSED(toolCallId); + const ToolContext& context, const QJsonObject& args, ToolDone done) { const QString path = args[QStringLiteral("path")].toString().trimmed(); if (path.isEmpty()) { - done(makeError(QStringLiteral("Missing required argument 'path'"))); + done({false, QStringLiteral("Missing required argument 'path'")}); return; } const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0)); - int budget = inlineBudgetChars(); - if (outputBudgetChars > 0) budget = qMin(budget, outputBudgetChars); + const int budget = context.outputBudgetChars > 0 + ? context.outputBudgetChars + : registry()->outputBudgetChars(1); const int limit = qBound(1, args[QStringLiteral("limit")].toInt(budget), budget); @@ -92,33 +74,33 @@ void FileReadTool::execute( if (!file.open(QIODevice::ReadOnly)) { const QFileInfo info(path); if (info.exists() && info.isDir()) { - done(makeError(QStringLiteral( - "Path is a directory, not a " - "file: %1") - .arg(path))); + done( + {false, + QStringLiteral("Path is a directory, not a file: %1") + .arg(path)}); } else { - done(makeError(QStringLiteral("Cannot open file: %1").arg(path))); + done({false, QStringLiteral("Cannot open file: %1").arg(path)}); } return; } const qint64 fileSize = file.size(); if (fileSize == 0) { file.close(); - done(makeOutput(QString())); + done({true, QString()}); return; } if (offset >= fileSize) { file.close(); - done(makeError(QStringLiteral( - "Offset %1 is beyond the end of the " - "file (%2 bytes)") - .arg(offset) - .arg(fileSize))); + done( + {false, + QStringLiteral("Offset %1 is beyond the end of the file (%2 bytes)") + .arg(offset) + .arg(fileSize)}); return; } if (!file.seek(offset)) { file.close(); - done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path))); + done({false, QStringLiteral("Cannot seek in file: %1").arg(path)}); return; } const qint64 wanted = qint64(limit) * 4 + 16; @@ -126,13 +108,14 @@ void FileReadTool::execute( file.close(); if (chunk.contains('\0')) { - done(makeError( - QStringLiteral("Binary files are not supported: %1").arg(path))); + done( + {false, + QStringLiteral("Binary files are not supported: %1").arg(path)}); return; } const QString content = QString::fromUtf8(chunk); if (content.size() <= limit) { - done(makeOutput(content)); + done({true, content}); return; } const QString head = content.left(limit); @@ -147,7 +130,7 @@ void FileReadTool::execute( .arg(nextOffset) .arg(fileSize) .arg(nextOffset); - done(makeOutput(output)); + done({true, output}); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/filetool.hpp b/Plugins/ZShell/Llm/filetool.hpp index cd2c06e..eac43ed 100644 --- a/Plugins/ZShell/Llm/filetool.hpp +++ b/Plugins/ZShell/Llm/filetool.hpp @@ -8,16 +8,15 @@ class FileReadTool : public LlmTool { Q_OBJECT public: - explicit FileReadTool(QObject* parent = nullptr); + explicit FileReadTool(ToolRegistry* registry, QObject* parent = nullptr); QString name() const override; QString description() const override; QJsonObject parameters() const override; void execute( - const QString& toolCallId, + const ToolContext& context, const QJsonObject& args, - int outputBudgetChars, - std::function done) override; + ToolDone done) override; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/generation.cpp b/Plugins/ZShell/Llm/generation.cpp index 407db6b..e6a8b8e 100644 --- a/Plugins/ZShell/Llm/generation.cpp +++ b/Plugins/ZShell/Llm/generation.cpp @@ -1,37 +1,50 @@ #include "generation.hpp" #include "llmclient.hpp" -#include "messagemodel.hpp" +#include "message.hpp" #include "session.hpp" #include namespace ZShell::llm { -ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent) - : QObject(parent), m_timestamp(timestamp) { +ChatGeneration::ChatGeneration( + ChatMessage* message, qint64 timestamp, QObject* parent) + : QObject(parent), m_message(message), m_timestamp(timestamp) { m_timer.setParent(this); m_timer.setInterval(500); m_timer.setTimerType(Qt::CoarseTimer); connect(&m_timer, &QTimer::timeout, this, [this]() { - bool anyRunning = false; + bool runningText = false; + bool runningTool = false; for (auto* segment : m_segments) { if (!segment->running()) continue; - anyRunning = true; - segment->elapsedMsChanged(); + Q_EMIT segment->elapsedMsChanged(); + if (segment->type() == LlmSegment::Type::ToolCall) + runningTool = true; + else + runningText = true; } - if (anyRunning) Q_EMIT elapsedMsChanged(); - if (!anyRunning && !m_streaming) m_timer.stop(); + if (runningText) { + Q_EMIT reasoningElapsedMsChanged(); + Q_EMIT contentElapsedMsChanged(); + } + if (runningTool) Q_EMIT toolsElapsedMsChanged(); + if (!runningText && !runningTool && !m_streaming) m_timer.stop(); }); } +ChatMessage* ChatGeneration::message() const { + return m_message; +} + QString ChatGeneration::content() const { QStringList parts; for (const auto* segment : m_segments) { - if (segment->type() != LlmSegment::Type::Content || - segment->text().isEmpty()) - continue; - parts.append(segment->text()); + if (segment->type() != LlmSegment::Type::Content) continue; + const auto* text = qobject_cast(segment); + if (!text || text->text().isEmpty()) continue; + parts.append(text->text()); } return parts.join(QStringLiteral("\n\n")); } @@ -39,10 +52,10 @@ QString ChatGeneration::content() const { QString ChatGeneration::reasoning() const { QStringList parts; for (const auto* segment : m_segments) { - if (segment->type() != LlmSegment::Type::Reasoning || - segment->text().isEmpty()) - continue; - parts.append(segment->text()); + if (segment->type() != LlmSegment::Type::Reasoning) continue; + const auto* text = qobject_cast(segment); + if (!text || text->text().isEmpty()) continue; + parts.append(text->text()); } return parts.join(QStringLiteral("\n\n")); } @@ -77,25 +90,27 @@ qint64 ChatGeneration::toolsElapsedMs() const { return total; } +QList ChatGeneration::toolCalls() const { + QList calls; + for (auto* segment : m_segments) + if (auto* call = qobject_cast(segment)) + calls.append(call); + return calls; +} + int ChatGeneration::toolCallCount() const { - int count = 0; - for (const auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::ToolCall) ++count; - return count; + return static_cast(toolCalls().size()); } bool ChatGeneration::hasRunningTool() const { - for (const auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::ToolCall && segment->running()) - return true; + for (const auto* call : toolCalls()) + if (call->running()) return true; return false; } bool ChatGeneration::hasPendingTool() const { - for (const auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::ToolCall && - segment->status() == LlmSegment::Status::Pending) - return true; + for (const auto* call : toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) return true; return false; } @@ -103,30 +118,37 @@ void ChatGeneration::setApprovalPending(bool value) { if (m_approvalPending == value) return; m_approvalPending = value; Q_EMIT toolApprovalPendingChanged(); + Q_EMIT pendingToolCallsChanged(); } QVariantList ChatGeneration::pendingToolCalls() const { QVariantList out; if (!m_approvalPending) return out; - for (const auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::ToolCall && - segment->status() == LlmSegment::Status::Pending) - out.append(QVariant::fromValue(segment)); + for (const auto* call : toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) + out.append(QVariant::fromValue(call)); return out; } void ChatGeneration::approveTools() { - if (auto* llmClient = client()) llmClient->approveTools(); + if (auto* llmClient = client()) llmClient->approveTools(this); } void ChatGeneration::denyTools() { - if (auto* llmClient = client()) llmClient->denyTools(); + if (auto* llmClient = client()) llmClient->denyTools(this); +} + +void ChatGeneration::dismissError() { + if (!m_hasError && m_errorMessage.isEmpty()) return; + m_hasError = false; + m_errorMessage.clear(); + Q_EMIT errorChanged(); + Q_EMIT errorMessageChanged(); } LlmClient* ChatGeneration::client() const { - if (auto* message = qobject_cast(parent())) - if (auto* model = qobject_cast(message->parent())) - if (auto* session = model->session()) return session->client(); + if (m_message && m_message->session()) + return m_message->session()->client(); return nullptr; } @@ -138,21 +160,19 @@ void ChatGeneration::updateReasoningActive() { } void ChatGeneration::setContent(const QString& value) { - LlmSegment* first = nullptr; + LlmTextSegment* first = nullptr; for (auto* segment : m_segments) { if (segment->type() != LlmSegment::Type::Content) continue; + auto* text = qobject_cast(segment); + if (!text) continue; if (!first) - first = segment; + first = text; else - segment->setText(QString()); + text->setText(QString()); } if (!first) { if (value.isEmpty()) return; - first = new LlmSegment( - LlmSegment::Type::Content, - QDateTime::currentMSecsSinceEpoch(), - this); - addSegment(first); + first = openTextSegment(LlmSegment::Type::Content); } first->setText(value); } @@ -164,7 +184,7 @@ void ChatGeneration::appendContent(const QString& piece) { segment->running()) segment->close(); } - openContentSegment()->appendText(piece); + openTextSegment(LlmSegment::Type::Content)->appendText(piece); } void ChatGeneration::appendReasoning(const QString& piece) { @@ -173,7 +193,7 @@ void ChatGeneration::appendReasoning(const QString& piece) { if (segment->type() == LlmSegment::Type::Content && segment->running()) segment->close(); } - openReasoningSegment()->appendText(piece); + openTextSegment(LlmSegment::Type::Reasoning)->appendText(piece); } void ChatGeneration::setError(const QString& value) { @@ -182,13 +202,7 @@ void ChatGeneration::setError(const QString& value) { m_hasError = hasError; m_errorMessage = value; Q_EMIT errorChanged(); -} - -void ChatGeneration::dismissError() { - if (!m_hasError && m_errorMessage.isEmpty()) return; - m_hasError = false; - m_errorMessage.clear(); - Q_EMIT errorChanged(); + Q_EMIT errorMessageChanged(); } void ChatGeneration::setStreaming(bool value) { @@ -201,70 +215,67 @@ void ChatGeneration::setStreaming(bool value) { closeOpenSegments(); m_timer.stop(); } - Q_EMIT elapsedMsChanged(); + Q_EMIT reasoningElapsedMsChanged(); + Q_EMIT contentElapsedMsChanged(); + Q_EMIT toolsElapsedMsChanged(); updateReasoningActive(); } -LlmSegment* ChatGeneration::openContentSegment() { - for (auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::Content && segment->running()) - return segment; - auto* segment = new LlmSegment( - LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), this); - segment->begin(); - addSegment(segment); - return segment; +LlmTextSegment* ChatGeneration::openTextSegment(LlmSegment::Type type) { + for (auto* segment : m_segments) { + if (segment->type() != type) continue; + auto* text = qobject_cast(segment); + if (text && text->running()) return text; + } + auto* text = + new LlmTextSegment(type, QDateTime::currentMSecsSinceEpoch(), this); + text->begin(); + addSegment(text); + return text; } -LlmSegment* ChatGeneration::openReasoningSegment() { - for (auto* segment : m_segments) - if (segment->type() == LlmSegment::Type::Reasoning && - segment->running()) - return segment; - auto* segment = new LlmSegment( - LlmSegment::Type::Reasoning, QDateTime::currentMSecsSinceEpoch(), this); - segment->begin(); - addSegment(segment); - return segment; -} - -LlmSegment* ChatGeneration::beginToolCall( +LlmToolCall* ChatGeneration::beginToolCall( const QString& name, const QString& toolCallId) { closeOpenSegments(); - auto* segment = new LlmSegment( - LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this); - segment->setName(name); - segment->setToolCallId(toolCallId); - segment->setStatus(LlmSegment::Status::Pending); - segment->begin(); - addSegment(segment); - Q_EMIT toolStateChanged(); - return segment; + auto* call = new LlmToolCall(QDateTime::currentMSecsSinceEpoch(), this); + call->setName(name); + call->setToolCallId(toolCallId); + call->setStatus(LlmToolCall::Status::Pending); + call->begin(); + addSegment(call); + Q_EMIT toolCallCountChanged(); + return call; } void ChatGeneration::addSegment(LlmSegment* segment) { if (!segment || m_segments.contains(segment)) return; segment->setParent(this); - connect(segment, &LlmSegment::textChanged, this, [this, segment]() { - if (segment->type() == LlmSegment::Type::Reasoning) - Q_EMIT reasoningChanged(); - else if (segment->type() == LlmSegment::Type::Content) - Q_EMIT contentChanged(); - updateReasoningActive(); - }); - connect(segment, &LlmSegment::statusChanged, this, [this]() { - Q_EMIT toolStateChanged(); - }); - connect(segment, &LlmSegment::resultChanged, this, [this]() { - Q_EMIT toolStateChanged(); - }); - connect(segment, &LlmSegment::runningChanged, this, [this]() { - Q_EMIT elapsedMsChanged(); - Q_EMIT toolStateChanged(); + connect(segment, &LlmSegment::runningChanged, this, [this, segment]() { + Q_EMIT toolCallCountChanged(); + if (segment->type() == LlmSegment::Type::ToolCall) + Q_EMIT hasRunningToolChanged(); updateReasoningActive(); }); + if (auto* text = qobject_cast(segment)) + connect(text, &LlmTextSegment::textChanged, this, [this, text]() { + if (text->type() == LlmSegment::Type::Reasoning) + Q_EMIT reasoningChanged(); + else + Q_EMIT contentChanged(); + updateReasoningActive(); + }); + if (auto* call = qobject_cast(segment)) { + connect(call, &LlmToolCall::statusChanged, this, [this, call]() { + Q_EMIT hasRunningToolChanged(); + Q_EMIT pendingToolCallsChanged(); + if (call->status() == LlmToolCall::Status::Pending) + Q_EMIT toolApprovalPendingChanged(); + updateReasoningActive(); + }); + } m_segments.append(segment); Q_EMIT segmentsChanged(); + Q_EMIT toolCallCountChanged(); } void ChatGeneration::closeOpenSegments() { diff --git a/Plugins/ZShell/Llm/generation.hpp b/Plugins/ZShell/Llm/generation.hpp index ac1a85c..b7b732b 100644 --- a/Plugins/ZShell/Llm/generation.hpp +++ b/Plugins/ZShell/Llm/generation.hpp @@ -5,12 +5,13 @@ #include #include #include -#include #include #include namespace ZShell::llm { +class ChatMessage; +class ChatSession; class LlmClient; class ChatGeneration : public QObject { @@ -18,35 +19,39 @@ class ChatGeneration : public QObject { QML_ELEMENT QML_UNCREATABLE("Chat generations are managed by ChatMessage") - Q_PROPERTY( - QString content READ content WRITE setContent NOTIFY contentChanged) + Q_PROPERTY(QString content READ content NOTIFY contentChanged) Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) Q_PROPERTY(bool error READ error NOTIFY errorChanged) - Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged) + Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorMessageChanged) Q_PROPERTY( qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY - elapsedMsChanged) + reasoningElapsedMsChanged) Q_PROPERTY( - qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) - Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged) + qint64 contentElapsedMs READ contentElapsedMs NOTIFY + contentElapsedMsChanged) + Q_PROPERTY( + qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY toolsElapsedMsChanged) Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) Q_PROPERTY( bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) - Q_PROPERTY(bool hasRunningTool READ hasRunningTool NOTIFY toolStateChanged) + Q_PROPERTY( + bool hasRunningTool READ hasRunningTool NOTIFY hasRunningToolChanged) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) Q_PROPERTY( QList segments READ segments NOTIFY segmentsChanged) - Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged) + Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY toolCallCountChanged) Q_PROPERTY( bool toolApprovalPending READ toolApprovalPending NOTIFY toolApprovalPendingChanged) Q_PROPERTY( QVariantList pendingToolCalls READ pendingToolCalls NOTIFY - toolApprovalPendingChanged) + pendingToolCallsChanged) public: - explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); + ChatGeneration(ChatMessage* message, qint64 timestamp, QObject* parent); + + [[nodiscard]] ChatMessage* message() const; [[nodiscard]] QString content() const; [[nodiscard]] QString reasoning() const; @@ -59,12 +64,12 @@ class ChatGeneration : public QObject { [[nodiscard]] bool streaming() const { return m_streaming; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; } [[nodiscard]] QList segments() const { return m_segments; } + [[nodiscard]] QList toolCalls() const; [[nodiscard]] int toolCallCount() const; [[nodiscard]] bool hasRunningTool() const; [[nodiscard]] bool hasPendingTool() const; [[nodiscard]] bool toolApprovalPending() const { return m_approvalPending; } - void setApprovalPending(bool value); [[nodiscard]] QVariantList pendingToolCalls() const; Q_INVOKABLE void approveTools(); @@ -76,10 +81,10 @@ class ChatGeneration : public QObject { void appendReasoning(const QString& piece); void setStreaming(bool value); void setError(const QString& value); + void setApprovalPending(bool value); - [[nodiscard]] LlmSegment* openContentSegment(); - [[nodiscard]] LlmSegment* openReasoningSegment(); - [[nodiscard]] LlmSegment* beginToolCall( + [[nodiscard]] LlmTextSegment* openTextSegment(LlmSegment::Type type); + [[nodiscard]] LlmToolCall* beginToolCall( const QString& name, const QString& toolCallId); void addSegment(LlmSegment* segment); void closeOpenSegments(); @@ -88,17 +93,23 @@ class ChatGeneration : public QObject { void contentChanged(); void reasoningChanged(); void errorChanged(); + void errorMessageChanged(); void reasoningActiveChanged(); - void elapsedMsChanged(); + void reasoningElapsedMsChanged(); + void contentElapsedMsChanged(); + void toolsElapsedMsChanged(); void streamingChanged(); - void toolStateChanged(); + void hasRunningToolChanged(); void segmentsChanged(); + void toolCallCountChanged(); void toolApprovalPendingChanged(); + void pendingToolCallsChanged(); private: void updateReasoningActive(); - LlmClient* client() const; + [[nodiscard]] LlmClient* client() const; + ChatMessage* m_message; QTimer m_timer; QList m_segments; bool m_reasoningActive = false; diff --git a/Plugins/ZShell/Llm/llmclient.cpp b/Plugins/ZShell/Llm/llmclient.cpp index 1dc831a..74aeff4 100644 --- a/Plugins/ZShell/Llm/llmclient.cpp +++ b/Plugins/ZShell/Llm/llmclient.cpp @@ -1,118 +1,36 @@ #include "llmclient.hpp" #include "attachment.hpp" +#include "filetool.hpp" #include "generation.hpp" #include "message.hpp" #include "messagemodel.hpp" -#include "segment.hpp" +#include "prompts.hpp" #include "session.hpp" +#include "shelltool.hpp" +#include "webfetchtool.hpp" +#include "workspace.hpp" + +#include "config/llm.hpp" +#include "config/rootnodes.hpp" #include -#include #include #include #include #include #include +#include #include #include +#include #include -#include - namespace ZShell::llm { namespace { -constexpr int kMinContextTokens = 512; - -qsizetype jsonValueChars(const QJsonValue& value) { - switch (value.type()) { - case QJsonValue::String: - return value.toString().size(); - case QJsonValue::Array: { - qsizetype total = 0; - for (const QJsonValue& item : value.toArray()) - total += jsonValueChars(item); - return total; - } - case QJsonValue::Object: { - qsizetype total = 0; - const QJsonObject obj = value.toObject(); - for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) - total += jsonValueChars(it.value()); - return total; - } - default: - return 2; - } -} - -int estimateMessageTokens(const QJsonObject& message) { - return int(jsonValueChars(message) / LlmTool::CharsPerToken) + 4; -} - -struct ContextUnit { - QList messages; - int tokens = 0; -}; - -void shrinkUnitToBudget(QList& unit, int budgetTokens) { - static const QStringList kTextKeys = { - QStringLiteral("content"), QStringLiteral("reasoning_content")}; - for (int pass = 0; pass < 8; ++pass) { - int tokens = 0; - for (const QJsonObject& message : unit) - tokens += estimateMessageTokens(message); - if (tokens <= budgetTokens) return; - qsizetype bestMessage = -1; - QString bestKey; - qsizetype bestLength = 0; - for (qsizetype i = 0; i < unit.size(); ++i) { - const QJsonObject message = unit.at(i); - for (const QString& key : kTextKeys) { - const QJsonValue value = message.value(key); - if (value.isString() && value.toString().size() > bestLength) { - bestMessage = i; - bestKey = key; - bestLength = value.toString().size(); - } - } - } - if (bestMessage < 0) return; - QString text = unit.at(bestMessage).value(bestKey).toString(); - text.truncate(qMax(32, text.size() * 3 / 4)); - const QString marker = QStringLiteral("\n[…]"); - if (!text.endsWith(marker)) text += marker; - unit[bestMessage][bestKey] = text; - } -} - -int estimateArrayTokens(const QJsonArray& messages) { - int total = 0; - for (const QJsonValue& value : messages) - total += estimateMessageTokens(value.toObject()); - return total; -} - -constexpr int kToolResultStubChars = 512; - -constexpr double kPromptEstimateDiscount = 10.0 / 12.0; - -constexpr int kMinToolResultTokens = 128; - -int completionReserve(int contextSize) { - return qBound(2048, contextSize / 8, 8192); -} - -int promptBudgetTokens(int contextSize) { - return int( - (contextSize - completionReserve(contextSize)) * - kPromptEstimateDiscount); -} - constexpr int kRefreshIntervalMs = 60'000; - constexpr int kProbeTimeoutMs = 2000; int contextSizeFromErrorMessage(const QString& message) { @@ -164,25 +82,92 @@ void LlmClient::setBusy(bool value) { Q_EMIT busyChanged(); } -void LlmClient::setStreamingChatId(const QString& id) { - if (m_streamingChatId == id) return; - m_streamingChatId = id; - Q_EMIT streamingChatIdChanged(); +void LlmClient::setModel(const QString& value) { + if (m_model == value) return; + m_model = value; + Q_EMIT modelChanged(); + if (m_model.isEmpty()) refreshModels(); +} + +void LlmClient::setContextSize(int size) { + if (size <= 0 || m_tools->contextSize() == size) return; + m_tools->setContextSize(size); + Q_EMIT contextSizeChanged(); +} + +void LlmClient::setToolsEnabled(bool value) { + if (m_tools->enabled() == value) return; + m_tools->setEnabled(value); + Q_EMIT toolsEnabledChanged(); + if (value) refreshAllStates(); +} + +void LlmClient::setSandboxEnabled(bool value) { + if (m_tools->sandboxEnabled() == value) return; + m_tools->setSandboxEnabled(value); + Q_EMIT sandboxEnabledChanged(); } LlmClient::LlmClient(QObject* parent) : QObject(parent) { m_tools = new ToolRegistry(this); - connect( - m_tools, - &ToolRegistry::enabledChanged, - this, - &LlmClient::toolsEnabledChanged); + m_store = new ChatStore(this, this); + + m_tools->registerTool(new WebFetchTool(m_tools, m_tools)); + m_tools->registerTool(new FileReadTool(m_tools, m_tools)); + m_tools->registerTool(new ShellTool(m_tools, m_tools)); + + auto* llm = config::ConfigSingleton::instance()->llm(); + if (llm) { + m_endpoint = llm->endpoint(); + m_model = llm->model(); + m_temperature = llm->temperature(); + m_configEnabled = llm->enabled(); + m_tools->setEnabled(llm->tools()); + m_tools->setSandboxEnabled(llm->sandbox()); + + connect(llm, &config::Llm::endpointChanged, this, [this, llm]() { + m_endpoint = llm->endpoint(); + refreshAllStates(); + }); + connect(llm, &config::Llm::modelChanged, this, [this, llm]() { + setModel(llm->model()); + }); + connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() { + m_temperature = llm->temperature(); + }); + connect(llm, &config::Llm::toolsChanged, this, [this, llm]() { + setToolsEnabled(llm->tools()); + }); + connect(llm, &config::Llm::sandboxChanged, this, [this, llm]() { + setSandboxEnabled(llm->sandbox()); + }); + connect(llm, &config::Llm::enabledChanged, this, [this, llm]() { + m_configEnabled = llm->enabled(); + if (m_configEnabled) { + m_refreshTimer.setInterval(kRefreshIntervalMs); + m_refreshTimer.start(); + refreshAllStates(); + } else { + m_refreshTimer.stop(); + } + }); + } + + connect( + m_store, &ChatStore::sessionRemoved, this, [this](ChatSession* session) { + if (m_turn && m_turn->session() == session) { + stop(); + endStream(); + } + }); - m_refreshTimer.setParent(this); m_refreshTimer.setInterval(kRefreshIntervalMs); connect( &m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshAllStates); - m_refreshTimer.start(); + if (m_configEnabled) { + m_refreshTimer.start(); + refreshAllStates(); + } } LlmClient::~LlmClient() { @@ -194,86 +179,47 @@ LlmClient::~LlmClient() { m_tools->cancelAll(); } -void LlmClient::setEndpoint(const QString& value) { - if (m_endpoint == value) return; - m_endpoint = value; - Q_EMIT endpointChanged(); - m_refreshTimer.start(); - refreshAllStates(); +void LlmClient::selectModel(const QString& id) { + if (id.isEmpty()) return; + if (auto* config = config::ConfigSingleton::instance()) + config->llm()->set_model(id); + setModel(id); } -void LlmClient::setModel(const QString& value) { - if (m_model == value) return; - m_model = value; - Q_EMIT modelChanged(); - if (m_model.isEmpty()) refreshModels(); -} - -void LlmClient::setTemperature(double value) { - m_temperature = value; -} - -void LlmClient::setSandboxEnabled(bool value) { - m_sandboxEnabled = value; -} - -QString LlmClient::activeWorkspace() const { - return m_active ? m_active->workspacePath() : QString(); +ToolContext LlmClient::toolContext() const { + ToolContext context; + if (m_turn) { + context.workspace = m_turn->session()->workspace()->path(); + context.outputBudgetChars = m_turn->outputBudgetChars(); + } + return context; } void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { - if (m_busy || !session || !target) return; - m_contextRetryUsed = false; - m_active = session; - m_streaming = target; + if (m_busy || !session || !target || !m_configEnabled) return; + + m_turn = std::make_unique(session, target, *m_tools); + if (m_turn->targetRow() < 0) { + fail(QStringLiteral( + "Internal error: generation target is not in the session")); + return; + } + target->setStreaming(true); setBusy(true); - setStreamingChatId(session->id()); - - const QUrl url = - QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); - if (!url.isValid() || url.host().isEmpty()) { - fail(QStringLiteral("Invalid LLM endpoint: %1").arg(m_endpoint)); - return; - } - - const auto* model = session->messagesModel(); - const int targetRow = - model->rowOf(qobject_cast(target->parent())); - if (targetRow < 0) { - fail(QStringLiteral( - "Internal error: generation target is not in the " - "session")); - return; - } - - m_transcript = QJsonArray(); - m_callBuilders.clear(); - m_callResults.clear(); - m_finishReason.clear(); - m_round = 0; - m_contentMark = 0; - m_reasoningMark = 0; - setApprovalPending(false); probeContextSize([this, session, target]() { - if (m_active != session || m_streaming != target) return; + if (!m_turn || m_turn->session() != session || + m_turn->generation() != target) + return; refreshModels(); sendRound(); }); } void LlmClient::sendRound() { - if (!m_active || !m_streaming) return; + if (!m_turn) return; probeContextSize(); - m_finishReason.clear(); - m_callBuilders.clear(); - m_callResults.clear(); - m_roundDone = false; - m_streamDone = false; - m_toolPhase = false; - m_pendingCalls = 0; - m_buffer.clear(); const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); @@ -282,51 +228,8 @@ void LlmClient::sendRound() { return; } - ChatSession* active = m_active; - ChatGeneration* streaming = m_streaming; - const auto* model = active->messagesModel(); - const int targetRow = - model->rowOf(qobject_cast(streaming->parent())); - if (targetRow < 0) { - fail(QStringLiteral( - "Internal error: generation target is not in the " - "session")); - return; - } - - int transcriptTokens = 0; - for (const QJsonValue& value : m_transcript) - transcriptTokens += estimateMessageTokens(value.toObject()); - QJsonArray messages = - buildContextMessages(m_active, targetRow, transcriptTokens); - for (const QJsonValue& value : m_transcript) - messages.append(value); - - if (m_contextSize > 0) { - const int promptLimit = promptBudgetTokens(m_contextSize); - while (estimateArrayTokens(messages) > promptLimit) { - bool stubbed = false; - for (qsizetype i = 0; i < messages.size(); ++i) { - QJsonObject message = messages.at(i).toObject(); - if (message.value("role").toString() != QLatin1String("tool")) - continue; - const QString content = message.value("content").toString(); - if (content.size() <= kToolResultStubChars) continue; - QString stub = content.left(kToolResultStubChars); - stub += QStringLiteral( - "\n[truncated to fit the context window; was " - "%1 characters]") - .arg(content.size()); - message["content"] = stub; - messages[i] = message; - stubbed = true; - break; - } - if (!stubbed) break; - } - } - - appendSandboxPreamble(messages); + Turn* turn = m_turn.get(); + QJsonArray messages = turn->contextMessages(); QJsonObject body; QJsonObject streamOptions; @@ -336,8 +239,10 @@ void LlmClient::sendRound() { body[QStringLiteral("stream")] = true; body[QStringLiteral("temperature")] = m_temperature; if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model; - const QJsonArray toolSpecs = m_tools->specifications(); - if (!toolSpecs.isEmpty()) body[QStringLiteral("tools")] = toolSpecs; + if (m_tools->enabled()) { + const QJsonArray toolSpecs = m_tools->specifications(toolContext()); + if (!toolSpecs.isEmpty()) body[QStringLiteral("tools")] = toolSpecs; + } QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); @@ -351,12 +256,12 @@ void LlmClient::sendRound() { } m_reply = reply; - connect(reply, &QNetworkReply::readyRead, this, [this, reply]() { - if (m_reply != reply) return; + connect(reply, &QNetworkReply::readyRead, this, [this, reply, turn]() { + if (m_reply != reply || m_turn.get() != turn) return; m_buffer.append(reply->readAll()); drainBuffer(); }); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { + connect(reply, &QNetworkReply::finished, this, [this, reply, turn]() { if (m_reply != reply) { reply->deleteLater(); return; @@ -369,11 +274,12 @@ void LlmClient::sendRound() { m_buffer.append(responseBody); reply->deleteLater(); + if (m_turn.get() != turn) return; + drainBuffer(); - if (!m_streaming) return; if (error == QNetworkReply::NoError) { - if (m_streamDone) { + if (turn->streamDone) { roundFinished(); return; } @@ -394,11 +300,12 @@ void LlmClient::sendRound() { } const QString message = serverErrorMessage(responseBody, errorString); const int reported = contextSizeFromErrorMessage(message); - if (reported > 0 && reported != m_contextSize && !m_contextRetryUsed) { + if (reported > 0 && reported != m_tools->contextSize() && + !turn->contextRetryUsed) { qWarning() << "LlmClient: prompt overflow, server reports" << reported << "tokens; retrying round"; setContextSize(reported); - m_contextRetryUsed = true; + turn->contextRetryUsed = true; sendRound(); return; } @@ -406,185 +313,14 @@ void LlmClient::sendRound() { }); } -QJsonArray LlmClient::buildContextMessages( - ChatSession* session, int stopBeforeRow, int extraReserveTokens) const { - const auto* model = session->messagesModel(); - QList units; - for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { - const auto* message = model->at(row); - const auto* generation = message->activeGeneration(); - if (!generation) continue; - const bool hasAnyContent = !generation->content().isEmpty() || - !generation->reasoning().isEmpty() || - generation->toolCallCount() > 0; - if (message->role() != ChatMessage::Role::User && !hasAnyContent) - continue; - - ContextUnit unit; - if (message->role() == ChatMessage::Role::User) { - QJsonObject user; - user[QStringLiteral("role")] = QStringLiteral("user"); - const QString text = generation->content(); - const QList attachments = message->attachments(); - if (attachments.isEmpty()) { - user[QStringLiteral("content")] = text; - } else { - QJsonArray parts; - if (!text.isEmpty()) { - QJsonObject textPart; - textPart[QStringLiteral("type")] = QStringLiteral("text"); - textPart[QStringLiteral("text")] = text; - parts.append(textPart); - } - for (const auto* attachment : attachments) { - if (attachment->kind() != Attachment::Kind::Image) continue; - QFile file(attachment->path()); - if (!file.open(QIODevice::ReadOnly)) continue; - const QByteArray base64 = file.readAll().toBase64(); - file.close(); - if (base64.isEmpty()) continue; - QJsonObject imageUrl; - imageUrl[QStringLiteral("url")] = - QStringLiteral("data:%1;base64,%2") - .arg(attachment->mimeType()) - .arg(QString::fromLatin1(base64)); - QJsonObject imagePart; - imagePart[QStringLiteral("type")] = - QLatin1String("image_url"); - imagePart[QStringLiteral("image_url")] = imageUrl; - parts.append(imagePart); - } - if (parts.isEmpty()) - user[QStringLiteral("content")] = text; - else - user[QStringLiteral("content")] = parts; - } - unit.messages.append(user); - } else { - QList toolSegments; - for (const auto* segment : generation->segments()) - if (segment->type() == LlmSegment::Type::ToolCall) - toolSegments.append(segment); - - QJsonObject assistant; - assistant[QStringLiteral("role")] = QStringLiteral("assistant"); - if (toolSegments.isEmpty()) - assistant[QStringLiteral("content")] = generation->content(); - else if (!generation->content().isEmpty()) - assistant[QStringLiteral("content")] = generation->content(); - if (!generation->reasoning().isEmpty()) - assistant[QStringLiteral("reasoning_content")] = - generation->reasoning(); - if (!toolSegments.isEmpty()) { - QJsonArray calls; - for (const auto* segment : toolSegments) { - QJsonObject function; - function[QStringLiteral("name")] = segment->name(); - function[QStringLiteral("arguments")] = - segment->arguments(); - QJsonObject call; - call[QStringLiteral("id")] = segment->toolCallId(); - call[QStringLiteral("type")] = QStringLiteral("function"); - call[QStringLiteral("function")] = function; - calls.append(call); - } - assistant[QStringLiteral("tool_calls")] = calls; - } - unit.messages.append(assistant); - for (const auto* segment : toolSegments) { - QJsonObject toolMessage; - toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); - toolMessage[QStringLiteral("tool_call_id")] = - segment->toolCallId(); - toolMessage[QStringLiteral("content")] = segment->result(); - unit.messages.append(toolMessage); - } - } - for (const QJsonObject& messageJson : unit.messages) - unit.tokens += estimateMessageTokens(messageJson); - units.append(unit); - } - - if (m_contextSize > 0) { - int budget = m_contextSize - completionReserve(m_contextSize) - - extraReserveTokens; - budget = qMax(budget, kMinContextTokens); - - int total = 0; - for (const ContextUnit& unit : units) - total += unit.tokens; - while (units.size() > 1 && total > budget) { - total -= units.first().tokens; - units.removeFirst(); - } - while ( - units.size() > 1 && - units.first().messages.first()[QStringLiteral("role")].toString() == - QLatin1String("assistant")) { - total -= units.first().tokens; - units.removeFirst(); - } - if (units.size() == 1 && units.first().tokens > budget) - shrinkUnitToBudget(units.first().messages, budget); - } - - QJsonArray messages; - for (const ContextUnit& unit : units) - for (const QJsonObject& messageJson : unit.messages) - messages.append(messageJson); - return messages; -} - -void LlmClient::appendSandboxPreamble(QJsonArray& messages) { - if (!m_sandboxEnabled) return; - ChatSession* active = m_active; - if (!active) return; - const QString workspace = active->workspacePath(); - if (workspace.isEmpty()) return; - const auto* model = active->messagesModel(); - QStringList files; - QSet seenPaths; - for (int row = 0; row < model->rowCount(); ++row) { - const auto* message = model->at(row); - if (message->role() != ChatMessage::Role::User) continue; - for (const auto* attachment : message->attachments()) { - if (attachment->kind() != Attachment::Kind::File) continue; - const QString relativePath = - attachment->path().mid(workspace.size() + 1); - if (seenPaths.contains(relativePath)) continue; - seenPaths.insert(relativePath); - files.append(QStringLiteral("- %1 (available as %2)") - .arg(attachment->name(), relativePath)); - } - } - if (files.isEmpty()) return; - QJsonObject preamble; - preamble[QStringLiteral("role")] = QStringLiteral("system"); - preamble[QStringLiteral("content")] = - QStringLiteral( - "The user attached the following files to this " - "conversation. They are available in your sandbox " - "directory at %1:\n%2\nruncommand executes shell " - "commands inside a bubblewrap sandbox: the rest of the " - "filesystem is read-only, there is no network access, " - "and only the sandbox directory is writable. Use it to " - "inspect, transform, or extract attached files (for " - "example to extract archives or search across many " - "files). Files you create stay inside the sandbox " - "directory. Use the webfetch tool for anything that " - "needs the network.") - .arg(workspace, files.join(QStringLiteral("\n"))); - messages.prepend(preamble); -} - void LlmClient::applyToolCallDelta(const QJsonObject& call) { - ChatGeneration* streaming = m_streaming; - if (!streaming) return; + Turn* turn = m_turn.get(); + if (!turn) return; const int index = call[QStringLiteral("index")].toInt(-1); if (index < 0) return; - while (m_callBuilders.size() <= index) - m_callBuilders.append(ToolCallBuilder{}); - ToolCallBuilder& builder = m_callBuilders[index]; + while (turn->calls().size() <= index) + turn->calls().append(Turn::Call{}); + Turn::Call& builder = turn->calls()[index]; builder.seen = true; const QString id = call[QStringLiteral("id")].toString(); if (!id.isEmpty()) builder.id = id; @@ -595,8 +331,9 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) { if (!arguments.isEmpty()) builder.arguments += arguments; if (!builder.segment) { - streaming->closeOpenSegments(); - builder.segment = streaming->beginToolCall(builder.name, builder.id); + turn->generation()->closeOpenSegments(); + builder.segment = + turn->generation()->beginToolCall(builder.name, builder.id); } builder.segment->setName(builder.name); builder.segment->setToolCallId(builder.id); @@ -604,90 +341,49 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) { } void LlmClient::roundFinished() { - if (m_roundDone || !m_streaming) return; - ChatGeneration* streaming = m_streaming; - m_roundDone = true; + Turn* turn = m_turn.get(); + if (!turn || turn->roundDone) return; + turn->roundDone = true; - bool hasCalls = false; - for (const auto& builder : m_callBuilders) - if (builder.seen) hasCalls = true; - if (!hasCalls) { + if (!turn->roundHasCalls()) { finishTurn(); return; } - if (m_finishReason != QLatin1String("tool_calls")) { - qWarning() << "LlmClient: stream ended" << m_finishReason + if (turn->finishReason != QLatin1String("tool_calls")) { + qWarning() << "LlmClient: stream ended" << turn->finishReason << "with incomplete tool calls, ending turn"; - for (const auto& builder : m_callBuilders) { + for (const auto& builder : turn->calls()) { if (!builder.seen || !builder.segment) continue; - builder.segment->finishTool( + builder.segment->finish( QStringLiteral( - "Stream truncated before the tool call " - "completed"), + "Stream truncated before the tool call completed"), false); } finishTurn(); return; } - if (m_round >= kMaxToolRounds) { + if (turn->round >= kMaxToolRounds) { qWarning() << "LlmClient: tool round limit reached, ending turn"; finishTurn(); return; } - streaming->closeOpenSegments(); - - const QString content = streaming->content(); - const QString reasoning = streaming->reasoning(); - QJsonObject assistant; - assistant[QStringLiteral("role")] = QStringLiteral("assistant"); - if (content.size() > m_contentMark) { - assistant[QStringLiteral("content")] = content.mid(m_contentMark); - m_contentMark = content.size(); - } - if (reasoning.size() > m_reasoningMark) { - assistant[QStringLiteral("reasoning_content")] = - reasoning.mid(m_reasoningMark); - m_reasoningMark = reasoning.size(); - } - QJsonArray calls; - for (const auto& builder : m_callBuilders) { - if (!builder.seen) continue; - QJsonObject function; - function[QStringLiteral("name")] = builder.name; - function[QStringLiteral("arguments")] = builder.arguments; - QJsonObject call; - call[QStringLiteral("id")] = builder.id; - call[QStringLiteral("type")] = QStringLiteral("function"); - call[QStringLiteral("function")] = function; - calls.append(call); - } - assistant[QStringLiteral("tool_calls")] = calls; - m_transcript.append(assistant); - m_round++; + turn->generation()->closeOpenSegments(); + turn->appendToTranscript(turn->roundAssistantMessage()); + ++turn->round; setApprovalPending(true); } void LlmClient::executeAllCalls() { - m_toolPhase = true; - m_pendingCalls = 0; - m_callResults = QList(m_callBuilders.size()); + Turn* turn = m_turn.get(); + if (!turn) return; + turn->toolPhase = true; + turn->pendingCalls = 0; + turn->results() = QList(turn->calls().size()); - int batchSize = 0; - for (const auto& call : m_callBuilders) - if (call.seen) ++batchSize; - int perCallChars = 0; - if (batchSize > 0 && m_contextSize > 0) { - const int available = promptBudgetTokens(m_contextSize) - - estimateArrayTokens(m_transcript); - const int perCallTokens = - qMax(available / batchSize, kMinToolResultTokens); - perCallChars = perCallTokens * LlmTool::CharsPerToken; - } - - for (int i = 0; i < m_callBuilders.size(); ++i) { - const auto& call = m_callBuilders.at(i); + for (int i = 0; i < turn->calls().size(); ++i) { + const Turn::Call& call = turn->calls().at(i); if (!call.seen) continue; LlmTool* tool = m_tools->tool(call.name); @@ -701,115 +397,103 @@ void LlmClient::executeAllCalls() { QJsonDocument::fromJson(call.arguments.toUtf8()); if (!doc.isObject()) { errorText = QStringLiteral( - "Error: tool arguments are not valid " - "JSON: %1") + "Error: tool arguments are not valid JSON: %1") .arg(call.arguments); } else { args = doc.object(); } } if (!errorText.isEmpty()) { - m_callResults[i] = {errorText, false}; - if (LlmSegment* segment = call.segment) - segment->finishTool(errorText, false); + turn->results()[i] = {false, errorText}; + if (call.segment) call.segment->finish(errorText, false); continue; } - ++m_pendingCalls; - tool->execute( - call.id, - args, - perCallChars, - [this, i, call](const QJsonObject& result) { - if (!m_streaming) return; - const bool success = result.contains(QStringLiteral("output")); - const QString content = - success ? result[QStringLiteral("output")].toString() - : QStringLiteral("Error: ") + - result[QStringLiteral("error")].toString(); - m_callResults[i] = {content, success}; - if (LlmSegment* segment = call.segment) - segment->finishTool(content, success); - if (--m_pendingCalls == 0) flushCallResults(); - }); + ToolContext context = toolContext(); + context.toolCallId = call.id; + + ++turn->pendingCalls; + tool->execute(context, args, [this, turn, i](const ToolResult& result) { + if (m_turn.get() != turn) return; + turn->results()[i] = result; + if (turn->calls().at(i).segment) + turn->calls().at(i).segment->finish(result.text, result.ok); + if (--turn->pendingCalls == 0) flushCallResults(); + }); } - if (m_pendingCalls == 0) flushCallResults(); + if (turn->pendingCalls == 0) flushCallResults(); } void LlmClient::flushCallResults() { - if (!m_toolPhase) return; - m_toolPhase = false; - if (!m_streaming) return; - for (int i = 0; i < m_callBuilders.size(); ++i) { - const auto& call = m_callBuilders.at(i); - if (!call.seen) continue; - QJsonObject toolMessage; - toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); - toolMessage[QStringLiteral("tool_call_id")] = call.id; - toolMessage[QStringLiteral("content")] = m_callResults.at(i).content; - m_transcript.append(toolMessage); - } + Turn* turn = m_turn.get(); + if (!turn || !turn->toolPhase) return; + turn->toolPhase = false; + + for (const QJsonValue& value : turn->toolMessages()) + turn->appendToTranscript(value.toObject()); sendRound(); } void LlmClient::setApprovalPending(bool value) { - if (m_approvalPending == value) return; - m_approvalPending = value; - if (ChatGeneration* streaming = m_streaming) - streaming->setApprovalPending(value); + Turn* turn = m_turn.get(); + if (!turn) return; + if (turn->approvalPending == value) return; + turn->approvalPending = value; + turn->generation()->setApprovalPending(value); } -void LlmClient::approveTools() { - if (!m_approvalPending) return; - ChatGeneration* streaming = m_streaming; - if (!streaming) return; +void LlmClient::approveTools(ChatGeneration* generation) { + Turn* turn = m_turn.get(); + if (!turn || !turn->approvalPending || turn->generation() != generation) + return; setApprovalPending(false); - for (auto* segment : streaming->segments()) { - if (segment->type() != LlmSegment::Type::ToolCall || - segment->status() != LlmSegment::Status::Pending) - continue; - segment->setStatus(LlmSegment::Status::Running); - segment->begin(); + for (auto* call : generation->toolCalls()) { + if (call->status() != LlmToolCall::Status::Pending) continue; + call->setStatus(LlmToolCall::Status::Running); + call->begin(); } executeAllCalls(); } -void LlmClient::denyTools() { - if (!m_approvalPending || !m_streaming) return; +void LlmClient::denyTools(ChatGeneration* generation) { + Turn* turn = m_turn.get(); + if (!turn || !turn->approvalPending || turn->generation() != generation) + return; setApprovalPending(false); - finishPendingCalls(QStringLiteral("Denied by user")); + for (auto* call : generation->toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) + call->finish("Denied by user", false); finishTurn(); } void LlmClient::finishPendingCalls(const QString& resultText) { - ChatGeneration* streaming = m_streaming; - if (!streaming) return; - for (auto* segment : streaming->segments()) - if (segment->type() == LlmSegment::Type::ToolCall && - segment->status() == LlmSegment::Status::Pending) - segment->finishTool(resultText, false); + Turn* turn = m_turn.get(); + if (!turn) return; + for (auto* call : turn->generation()->toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) + call->finish(resultText, false); } void LlmClient::stop() { if (!m_busy) return; - if (m_approvalPending) { + Turn* turn = m_turn.get(); + if (!turn) return; + + if (turn->approvalPending) { setApprovalPending(false); finishPendingCalls(QStringLiteral("Cancelled")); finishTurn(); return; } - if (m_toolPhase) { + if (turn->toolPhase) { m_tools->cancelAll(); - if (ChatGeneration* streaming = m_streaming) { - for (auto* segment : streaming->segments()) { - if (segment->type() == LlmSegment::Type::ToolCall && - segment->running()) - segment->finishTool(QStringLiteral("Cancelled"), false); - } - } + for (auto* call : turn->generation()->toolCalls()) + if (call->running()) + call->finish(QStringLiteral("Cancelled"), false); finishTurn(); return; } + if (m_reply) { QNetworkReply* reply = m_reply; m_reply = nullptr; @@ -819,66 +503,55 @@ void LlmClient::stop() { } void LlmClient::endStream() { - if (!m_streaming) return; - ChatGeneration* generation = m_streaming; - ChatSession* session = m_active; - m_streaming = nullptr; - m_active = nullptr; + Turn* turn = m_turn.get(); + if (!turn) return; + ChatSession* session = turn->session(); + ChatGeneration* generation = turn->generation(); + if (m_reply) { QNetworkReply* reply = m_reply; m_reply = nullptr; reply->abort(); } + generation->setStreaming(false); - m_approvalPending = false; - generation->setApprovalPending(false); - for (auto* segment : generation->segments()) - if (segment->type() == LlmSegment::Type::ToolCall && - segment->status() == LlmSegment::Status::Pending) - segment->finishTool(QStringLiteral("Cancelled"), false); + setApprovalPending(false); + for (auto* call : generation->toolCalls()) + if (call->status() == LlmToolCall::Status::Pending) + call->finish(QStringLiteral("Cancelled"), false); + if (generation->content().isEmpty() && generation->reasoning().isEmpty() && generation->toolCallCount() == 0 && !generation->error()) { - if (auto* message = qobject_cast(generation->parent())) { - if (message->generationCount() <= 1) { - if (session) session->removeMessage(message); - } else { + ChatMessage* message = generation->message(); + if (message) { + if (message->generationCount() <= 1) + session->removeMessage(message); + else message->removeGeneration(generation); - } } } + + m_turn.reset(); setBusy(false); - setStreamingChatId(QString()); } void LlmClient::finishTurn() { - if (!m_streaming) return; - ChatSession* session = m_active; + ChatSession* session = m_turn ? m_turn->session() : nullptr; endStream(); - if (session && m_pendingClear == session) { - session->clearMessages(); - m_pendingClear.clear(); - } - if (session) session->persist(); - refreshAllStates(); -} - -void LlmClient::clearOnFinish(ChatSession* session) { - m_pendingClear = session; -} - -void LlmClient::sessionRemoved(ChatSession* session) { - if (m_pendingClear == session) m_pendingClear.clear(); - if (m_active == session) { - stop(); - endStream(); + if (session) { + m_store->applyPendingClear(session); + session->persist(); } + if (m_configEnabled) refreshAllStates(); } void LlmClient::fail(const QString& message) { qWarning() << "LlmClient:" << message; - if (ChatGeneration* streaming = m_streaming) streaming->setError(message); + ChatSession* session = m_turn ? m_turn->session() : nullptr; + if (Turn* turn = m_turn.get()) turn->generation()->setError(message); finishTurn(); - Q_EMIT errorOccurred(message); + if (session && !message.isEmpty()) + Q_EMIT generationFailed(session, message); } void LlmClient::drainBuffer() { @@ -892,11 +565,12 @@ void LlmClient::drainBuffer() { } void LlmClient::handleLine(const QByteArray& line) { - if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) return; + Turn* turn = m_turn.get(); + if (!turn || line.isEmpty() || !line.startsWith("data:")) return; const QByteArray data = line.mid(5).trimmed(); if (data == "[DONE]") { - m_streamDone = true; + turn->streamDone = true; roundFinished(); return; } @@ -920,27 +594,27 @@ void LlmClient::handleLine(const QByteArray& line) { } for (const QJsonValue& choiceValue : obj["choices"].toArray()) { - if (!m_streaming) continue; + if (!m_turn) break; const QJsonObject choice = choiceValue.toObject(); const QJsonObject delta = choice["delta"].toObject(); - const QString finishReason = - choice[QStringLiteral("finish_reason")].toString(); - if (!finishReason.isEmpty()) m_finishReason = finishReason; + const QString finishReason = choice["finish_reason"].toString(); + if (!finishReason.isEmpty()) turn->finishReason = finishReason; - m_streaming->appendContent(delta["content"].toString()); + turn->generation()->appendContent(delta["content"].toString()); QString reasoning = delta["reasoning_content"].toString(); if (reasoning.isEmpty()) reasoning = delta["reasoning"].toString(); - m_streaming->appendReasoning(reasoning); + turn->generation()->appendReasoning(reasoning); for (const QJsonValue& callValue : delta["tool_calls"].toArray()) { - if (!m_streaming) break; + if (!m_turn) break; applyToolCallDelta(callValue.toObject()); } } } void LlmClient::refreshAllStates() { + if (!m_configEnabled) return; if (m_endpoint.trimmed().isEmpty()) { setServerError(QStringLiteral("LLM endpoint is not configured")); return; @@ -949,6 +623,8 @@ void LlmClient::refreshAllStates() { } void LlmClient::refreshModels() { + if (!m_configEnabled) return; + const QUrl url = QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); if (!url.isValid() || url.host().isEmpty()) { @@ -994,24 +670,20 @@ void LlmClient::refreshModels() { m_availableModels = models; Q_EMIT availableModelsChanged(); - if (m_model.isEmpty()) { - m_model = models.first(); - Q_EMIT modelChanged(); - } else if (!models.contains(m_model)) { - m_model = models.first(); - Q_EMIT modelChanged(); + if (m_model.isEmpty() || !models.contains(m_model)) { + if (auto* config = config::ConfigSingleton::instance()) + config->llm()->set_model(models.first()); + setModel(models.first()); } }); } -void LlmClient::setContextSize(int size) { - if (size <= 0 || m_contextSize == size) return; - m_contextSize = size; - m_tools->setContextSize(size); - Q_EMIT contextSizeChanged(); -} - void LlmClient::probeContextSize(std::function done) { + if (!m_configEnabled) { + if (done) done(); + return; + } + QString base = m_endpoint.trimmed(); while (base.endsWith('/')) base.chop(1); @@ -1064,7 +736,7 @@ void LlmClient::probeContextSize(std::function done) { } if (size > 0) setContextSize(size); - else if (m_contextSize <= 0) + else if (m_tools->contextSize() <= 0) setContextSize(4096); if (done) done(); }; @@ -1075,13 +747,12 @@ void LlmClient::probeContextSize(std::function done) { } void LlmClient::updateTokenUsage(const QJsonObject& data) { - ChatSession* active = m_active; - if (!active || m_contextSize <= 0) return; + if (!m_turn || m_tools->contextSize() <= 0) return; const QJsonObject usage = data["usage"].toObject(); if (usage.isEmpty()) return; const double used = usage.value("prompt_tokens").toDouble() + usage.value("completion_tokens").toDouble(); - if (used > 0) active->setLastTokenCount(static_cast(used)); + if (used > 0) m_turn->session()->setUsageTokens(static_cast(used)); } void LlmClient::shortRequest( @@ -1129,15 +800,6 @@ void LlmClient::shortRequest( const QByteArray data = reply->readAll(); reply->deleteLater(); - qInfo() << "LlmClient:" << tag << "request finished" - << "error=" << reply->error() << reply->errorString() - << "http=" - << reply - ->attribute(QNetworkRequest::HttpStatusCodeAttribute) - .toInt() - << "response=" - << QString::fromUtf8(data.left(400)).simplified(); - if (reply->error() != QNetworkReply::NoError) return; const QJsonDocument doc = QJsonDocument::fromJson(data); @@ -1149,7 +811,6 @@ void LlmClient::shortRequest( .toObject()[QStringLiteral("content")] .toString() .trimmed(); - qInfo() << "LlmClient:" << tag << "raw result" << result; onResult(result); }); } @@ -1157,55 +818,28 @@ void LlmClient::shortRequest( void LlmClient::requestTitle(ChatSession* session, const QString& userText) { shortRequest( QStringLiteral("title"), - QStringLiteral( - "Write a short, concise title for a chat conversation starting " - "with the user's message below. At most six words, no quotation " - "marks, no trailing punctuation. Reply with the title only."), + prompts::titleSystemPrompt(), userText, [this, session = QPointer(session)](QString title) { if (!session) { qWarning() << "LlmClient: title request: session gone"; return; } - const auto isQuote = [](QChar c) { - return c == QLatin1Char('"') || c == QLatin1Char('\'') || - c == QChar(u'\u201C') || c == QChar(u'\u201D') || - c == QChar(u'\u2018') || c == QChar(u'\u2019'); - }; - while (title.size() >= 2 && isQuote(title.at(0)) && - isQuote(title.at(title.size() - 1))) - title = title.mid(1, title.size() - 2).simplified(); - while (!title.isEmpty() && (title.endsWith(QLatin1Char('.')) || - title.endsWith(QLatin1Char('!')) || - title.endsWith(QLatin1Char('?')))) - title.chop(1); - if (title.size() < 2) { + title = prompts::cleanTitle(title); + if (title.length() < 2) { qWarning() << "LlmClient: title rejected (too short)" << title; return; } - if (title.size() > 48) title = title.left(47) + QStringLiteral("…"); qInfo() << "LlmClient: suggesting title" << title; - Q_EMIT titleSuggested(session, title); + session->setTitle(title); }); } void LlmClient::requestIcon(ChatSession* session, const QString& userText) { - const QStringList icons = { - "chat", "lightbulb", "code", "description", - "article", "school", "work", "build", - "science", "palette", "music_note", "sports_esports", - "takeout_dining", "flight", "photo_camera", "psychology_alt", - "favorite", "savings", "gamepad", "auto_awesome", - }; - const QString prompt = - QStringLiteral( - "Pick the single icon name from this list that best matches the " - "topic of the user's message below: %1. Reply with only the icon " - "name, exactly as written in the list, and nothing else.") - .arg(icons.join(QStringLiteral(", "))); + const QStringList icons = prompts::iconNames(); shortRequest( QStringLiteral("icon"), - prompt, + prompts::iconSystemPrompt(), userText, [this, session = QPointer(session), icons](QString name) { if (!session) { @@ -1226,7 +860,7 @@ void LlmClient::requestIcon(ChatSession* session, const QString& userText) { name = QStringLiteral("chat"); } qInfo() << "LlmClient: suggesting icon" << name; - Q_EMIT iconSuggested(session, name); + session->setIcon(name); }); } diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp index 3a1b955..a9430ed 100644 --- a/Plugins/ZShell/Llm/llmclient.hpp +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -1,18 +1,19 @@ #pragma once -#include "segment.hpp" +#include "chatstore.hpp" #include "tool.hpp" +#include "turn.hpp" #include #include #include #include -#include #include #include #include #include +#include class QJsonObject; class QNetworkReply; @@ -21,80 +22,75 @@ namespace ZShell::llm { class ChatGeneration; class ChatSession; -class LlmSegment; -class LlmTool; class LlmClient : public QObject { Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString model READ model NOTIFY modelChanged) + Q_PROPERTY( + QStringList availableModels READ availableModels NOTIFY + availableModelsChanged) + Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged) + Q_PROPERTY( + bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY + toolsEnabledChanged) + Q_PROPERTY( + bool sandboxEnabled READ sandboxEnabled WRITE setSandboxEnabled NOTIFY + sandboxEnabledChanged) + Q_PROPERTY(QString serverError READ serverError NOTIFY serverErrorChanged) + Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) public: explicit LlmClient(QObject* parent = nullptr); ~LlmClient() override; - [[nodiscard]] QString endpoint() const { return m_endpoint; } + [[nodiscard]] ChatStore* chats() const { return m_store; } + + [[nodiscard]] bool busy() const { return m_busy; } [[nodiscard]] QString model() const { return m_model; } - [[nodiscard]] double temperature() const { return m_temperature; } [[nodiscard]] QStringList availableModels() const { return m_availableModels; } - [[nodiscard]] int contextSize() const { return m_contextSize; } - [[nodiscard]] ToolRegistry* tools() const { return m_tools; } - [[nodiscard]] QString serverError() const { return m_serverError; } - void setEndpoint(const QString& value); - void setModel(const QString& value); - void setTemperature(double value); - void setContextSize(int size); - void probeContextSize(std::function done = {}); - void refreshAllStates(); - - [[nodiscard]] bool busy() const { return m_busy; } + [[nodiscard]] int contextSize() const { return m_tools->contextSize(); } [[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); } - void setToolsEnabled(bool value) { m_tools->setEnabled(value); } - [[nodiscard]] bool sandboxEnabled() const { return m_sandboxEnabled; } + [[nodiscard]] bool sandboxEnabled() const { + return m_tools->sandboxEnabled(); + } + [[nodiscard]] QString serverError() const { return m_serverError; } + [[nodiscard]] ChatSession* streamingSession() const { + return m_turn ? m_turn->session() : nullptr; + } + + void setToolsEnabled(bool value); void setSandboxEnabled(bool value); - [[nodiscard]] QString activeWorkspace() const; - [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } - [[nodiscard]] ChatSession* streamingSession() const { return m_active; } + + Q_INVOKABLE void selectModel(const QString& id); + Q_INVOKABLE void stop(); + Q_INVOKABLE void refreshModels(); + Q_INVOKABLE void refreshFromServer(); void startGeneration(ChatSession* session, ChatGeneration* target); - void stop(); - void endStream(); - void clearOnFinish(ChatSession* session); - void sessionRemoved(ChatSession* session); - - void approveTools(); - void denyTools(); - - void refreshModels(); + void approveTools(ZShell::llm::ChatGeneration* generation); + void denyTools(ZShell::llm::ChatGeneration* generation); void requestTitle(ChatSession* session, const QString& userText); void requestIcon(ChatSession* session, const QString& userText); Q_SIGNALS: void busyChanged(); - void endpointChanged(); void modelChanged(); void availableModelsChanged(); void contextSizeChanged(); void toolsEnabledChanged(); - void streamingChatIdChanged(); - void errorOccurred(const QString& message); + void sandboxEnabledChanged(); void serverErrorChanged(); - void titleSuggested(ZShell::llm::ChatSession* session, const QString& title); - void iconSuggested(ZShell::llm::ChatSession* session, const QString& icon); + void generationFailed( + ZShell::llm::ChatSession* session, const QString& message); private: - struct ToolCallBuilder { - QString id; - QString name; - QString arguments; - QPointer segment; - bool seen = false; - }; - void sendRound(); - QJsonArray buildContextMessages( - ChatSession* session, int stopBeforeRow, int extraReserveTokens) const; - void appendSandboxPreamble(QJsonArray& messages); void applyToolCallDelta(const QJsonObject& call); void roundFinished(); void executeAllCalls(); @@ -102,13 +98,17 @@ class LlmClient : public QObject { void setApprovalPending(bool value); void finishPendingCalls(const QString& resultText); void finishTurn(); + void endStream(); void fail(const QString& message); void handleLine(const QByteArray& line); void drainBuffer(); void updateTokenUsage(const QJsonObject& data); void setBusy(bool value); - void setStreamingChatId(const QString& id); void setServerError(const QString& value); + void setModel(const QString& value); + void setContextSize(int size); + void probeContextSize(std::function done = {}); + void refreshAllStates(); void shortRequest( const QString& tag, const QString& systemPrompt, @@ -119,42 +119,22 @@ class LlmClient : public QObject { static QString jsonErrorMessage(const QByteArray& body); static QString serverErrorMessage( const QByteArray& body, const QString& fallback); + [[nodiscard]] ToolContext toolContext() const; QNetworkAccessManager m_manager; + ChatStore* m_store = nullptr; ToolRegistry* m_tools = nullptr; + std::unique_ptr m_turn; QNetworkReply* m_reply = nullptr; QByteArray m_buffer; QTimer m_refreshTimer; - QPointer m_active; - QPointer m_streaming; - QPointer m_pendingClear; - bool m_busy = false; - QString m_streamingChatId; QString m_endpoint; QString m_model; QStringList m_availableModels; QString m_serverError; double m_temperature = 0.7; - int m_contextSize = 0; - - QJsonArray m_transcript; - QList m_callBuilders; - struct ToolCallResult { - QString content; - bool success = false; - }; - QList m_callResults; - QString m_finishReason; - int m_round = 0; - qsizetype m_contentMark = 0; - qsizetype m_reasoningMark = 0; - bool m_sandboxEnabled = true; - bool m_roundDone = false; - bool m_streamDone = false; - bool m_toolPhase = false; - bool m_approvalPending = false; - bool m_contextRetryUsed = false; - int m_pendingCalls = 0; + bool m_busy = false; + bool m_configEnabled = true; static constexpr int kMaxToolRounds = 12; }; diff --git a/Plugins/ZShell/Llm/markdownblock.cpp b/Plugins/ZShell/Llm/markdownblock.cpp new file mode 100644 index 0000000..9b2d7e1 --- /dev/null +++ b/Plugins/ZShell/Llm/markdownblock.cpp @@ -0,0 +1,23 @@ +#include "markdownblock.hpp" + +namespace ZShell::llm { + +MarkdownBlock::MarkdownBlock( + Type type, + const QString& id, + const QString& text, + const QString& latex, + const QString& code, + const QString& language, + int level, + QObject* parent) + : QObject(parent) + , m_type(type) + , m_id(id) + , m_text(text) + , m_latex(latex) + , m_code(code) + , m_language(language) + , m_level(level) {} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownblock.hpp b/Plugins/ZShell/Llm/markdownblock.hpp index 576a289..fee7576 100644 --- a/Plugins/ZShell/Llm/markdownblock.hpp +++ b/Plugins/ZShell/Llm/markdownblock.hpp @@ -1,20 +1,54 @@ #pragma once #include +#include #include namespace ZShell::llm { -class LlmMarkdown : public QObject { +class MarkdownBlock : public QObject { Q_OBJECT QML_ELEMENT - QML_UNCREATABLE("Blocks are produced by LlmSegment") + QML_UNCREATABLE("Blocks are produced by LlmTextSegment") + + Q_PROPERTY(Type type READ type CONSTANT) + Q_PROPERTY(QString id READ id CONSTANT) + Q_PROPERTY(QString text READ text CONSTANT) + Q_PROPERTY(QString latex READ latex CONSTANT) + Q_PROPERTY(QString code READ code CONSTANT) + Q_PROPERTY(QString language READ language CONSTANT) + Q_PROPERTY(int level READ level CONSTANT) public: enum class Type : int { Text = 0, Heading, Code, Math }; Q_ENUM(Type) - explicit LlmMarkdown(QObject* parent = nullptr) : QObject(parent) {} + MarkdownBlock( + Type type, + const QString& id, + const QString& text, + const QString& latex, + const QString& code, + const QString& language, + int level, + QObject* parent = nullptr); + + [[nodiscard]] Type type() const { return m_type; } + [[nodiscard]] QString id() const { return m_id; } + [[nodiscard]] QString text() const { return m_text; } + [[nodiscard]] QString latex() const { return m_latex; } + [[nodiscard]] QString code() const { return m_code; } + [[nodiscard]] QString language() const { return m_language; } + [[nodiscard]] int level() const { return m_level; } + + private: + Type m_type; + QString m_id; + QString m_text; + QString m_latex; + QString m_code; + QString m_language; + int m_level = 0; }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/markdownparser.cpp b/Plugins/ZShell/Llm/markdownparser.cpp index 756334d..5d4a5ad 100644 --- a/Plugins/ZShell/Llm/markdownparser.cpp +++ b/Plugins/ZShell/Llm/markdownparser.cpp @@ -1,18 +1,15 @@ #include "markdownparser.hpp" -#include "markdownblock.hpp" - #include #include #include #include -#include namespace ZShell::llm { -QVariantList MarkdownParser::parse(const QString& source) { - QVariantList blocks; +QVector MarkdownParser::parse(const QString& source) { + QVector blocks; if (source.trimmed().isEmpty()) return blocks; const QStringList lines = source.split('\n'); @@ -35,42 +32,36 @@ QVariantList MarkdownParser::parse(const QString& source) { QStringLiteral("\\$\\$(.+?)\\$\\$"), QRegularExpression::DotMatchesEverythingOption); - auto makeBlock = [&](LlmMarkdown::Type type) { - QVariantMap block; - block.insert("type", static_cast(type)); - block.insert( - "id", - QString::number(static_cast(blocks.size())) + - QLatin1Char(':') + QString::number(static_cast(type))); - return block; - }; - auto appendText = [&](const QString& text) { if (text.trimmed().isEmpty()) return; - QVariantMap block = makeBlock(LlmMarkdown::Type::Text); - block.insert("text", text); + MarkdownBlockData block; + block.type = MarkdownBlock::Type::Text; + block.text = text; blocks.append(block); }; auto appendMath = [&](const QString& latex) { if (latex.trimmed().isEmpty()) return; - QVariantMap block = makeBlock(LlmMarkdown::Type::Math); - block.insert("latex", latex); + MarkdownBlockData block; + block.type = MarkdownBlock::Type::Math; + block.latex = latex; blocks.append(block); }; auto appendCode = [&](const QString& language, const QString& code) { - QVariantMap block = makeBlock(LlmMarkdown::Type::Code); - block.insert("language", language); - block.insert("code", code); + MarkdownBlockData block; + block.type = MarkdownBlock::Type::Code; + block.language = language; + block.code = code; blocks.append(block); }; auto appendHeading = [&](int level, const QString& text) { if (text.trimmed().isEmpty()) return; - QVariantMap block = makeBlock(LlmMarkdown::Type::Heading); - block.insert("level", level); - block.insert("text", text); + MarkdownBlockData block; + block.type = MarkdownBlock::Type::Heading; + block.level = level; + block.text = text; blocks.append(block); }; diff --git a/Plugins/ZShell/Llm/markdownparser.hpp b/Plugins/ZShell/Llm/markdownparser.hpp index 433e06b..5ab6007 100644 --- a/Plugins/ZShell/Llm/markdownparser.hpp +++ b/Plugins/ZShell/Llm/markdownparser.hpp @@ -1,13 +1,24 @@ #pragma once +#include "markdownblock.hpp" + #include -#include +#include namespace ZShell::llm { +struct MarkdownBlockData { + MarkdownBlock::Type type = MarkdownBlock::Type::Text; + QString text; + QString latex; + QString code; + QString language; + int level = 0; +}; + class MarkdownParser { public: - [[nodiscard]] static QVariantList parse(const QString& source); + [[nodiscard]] static QVector parse(const QString& source); }; } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/message.cpp b/Plugins/ZShell/Llm/message.cpp index 0cf1fba..a453b01 100644 --- a/Plugins/ZShell/Llm/message.cpp +++ b/Plugins/ZShell/Llm/message.cpp @@ -1,25 +1,36 @@ #include "message.hpp" -#include "messagemodel.hpp" #include "session.hpp" namespace ZShell::llm { -namespace { +ChatMessage::ChatMessage( + ChatSession* session, Role role, qint64 timestamp, QObject* parent) + : QObject(parent) + , m_session(session) + , m_role(role) + , m_timestamp(timestamp) {} -ChatSession* sessionOf(const ChatMessage* message) { - if (auto* model = qobject_cast(message->parent())) - return model->session(); - return nullptr; +QString ChatMessage::content() const { + if (m_role == Role::User) return m_content; + const ChatGeneration* generation = activeGeneration(); + return generation ? generation->content() : QString(); } -} // namespace - -ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent) - : QObject(parent), m_role(role), m_timestamp(timestamp) {} +void ChatMessage::setContent(const QString& value) { + if (m_role != Role::User || m_content == value) return; + m_content = value; + Q_EMIT contentChanged(); +} ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) { - auto* generation = new ChatGeneration(timestamp, this); + if (m_role != Role::Assistant) return nullptr; + + auto* generation = new ChatGeneration(this, timestamp, this); + connect( + generation, &ChatGeneration::contentChanged, this, [this, generation]() { + if (generation == activeGeneration()) Q_EMIT contentChanged(); + }); m_generations.append(generation); if (m_active < 0) m_active = static_cast(m_generations.size() - 1); Q_EMIT generationsChanged(); @@ -27,7 +38,7 @@ ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) { } ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) { - auto* generation = addGeneration(timestamp); + ChatGeneration* generation = addGeneration(timestamp); setActiveInternal(static_cast(m_generations.size() - 1)); return generation; } @@ -44,13 +55,17 @@ void ChatMessage::removeGeneration(ChatGeneration* generation) { m_active = static_cast(m_generations.size() - 1); } Q_EMIT generationsChanged(); - if (wasActive) Q_EMIT activeGenerationChanged(); + if (wasActive) { + Q_EMIT activeGenerationChanged(); + Q_EMIT contentChanged(); + } } void ChatMessage::setActiveInternal(int index) { if (index < 0 || index >= m_generations.size() || index == m_active) return; m_active = index; Q_EMIT activeGenerationChanged(); + Q_EMIT contentChanged(); } void ChatMessage::setActiveGeneration(int index) { @@ -72,17 +87,25 @@ void ChatMessage::setAttachments(QList attachments) { } void ChatMessage::edit(const QString& newContent) { - if (auto* generation = activeGeneration()) + if (m_role == Role::User) { + setContent(newContent); + } else if (ChatGeneration* generation = activeGeneration()) { generation->setContent(newContent); - if (auto* session = sessionOf(this)) session->persist(); + } else { + return; + } + + if (m_session) m_session->persist(); } void ChatMessage::retry() { - if (auto* session = sessionOf(this)) session->retry(this); + if (m_role != Role::Assistant || !m_session) return; + m_session->retry(this); } void ChatMessage::generate() { - if (auto* session = sessionOf(this)) session->continueFrom(this); + if (m_role != Role::Assistant || !m_session) return; + m_session->continueFrom(this); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/message.hpp b/Plugins/ZShell/Llm/message.hpp index df9ff5d..022f7cf 100644 --- a/Plugins/ZShell/Llm/message.hpp +++ b/Plugins/ZShell/Llm/message.hpp @@ -10,13 +10,16 @@ namespace ZShell::llm { +class ChatSession; + class ChatMessage : public QObject { Q_OBJECT QML_ELEMENT - QML_UNCREATABLE("Chat messages are created by the Chat singleton") + QML_UNCREATABLE("Chat messages are created by ChatMessageModel") Q_PROPERTY(Role role READ role CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) + Q_PROPERTY(QString content READ content NOTIFY contentChanged) Q_PROPERTY( int generationCount READ generationCount NOTIFY generationsChanged) Q_PROPERTY( @@ -36,10 +39,16 @@ class ChatMessage : public QObject { enum class Role : int { User = 0, Assistant }; Q_ENUM(Role) - explicit ChatMessage(Role role, qint64 timestamp, QObject* parent = nullptr); + ChatMessage( + ChatSession* session, + Role role, + qint64 timestamp, + QObject* parent = nullptr); + [[nodiscard]] ChatSession* session() const { return m_session; } [[nodiscard]] Role role() const { return m_role; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; } + [[nodiscard]] QString content() const; [[nodiscard]] int generationCount() const { return static_cast(m_generations.size()); } @@ -68,7 +77,10 @@ class ChatMessage : public QObject { ChatGeneration* appendGeneration(qint64 timestamp); void removeGeneration(ChatGeneration* generation); + void setContent(const QString& value); + Q_SIGNALS: + void contentChanged(); void generationsChanged(); void activeGenerationChanged(); void attachmentsChanged(); @@ -76,8 +88,10 @@ class ChatMessage : public QObject { private: void setActiveInternal(int index); + ChatSession* m_session; Role m_role; qint64 m_timestamp; + QString m_content; QList m_generations; QList m_attachments; int m_active = -1; diff --git a/Plugins/ZShell/Llm/messagemodel.cpp b/Plugins/ZShell/Llm/messagemodel.cpp index 9dd50c8..5267fd6 100644 --- a/Plugins/ZShell/Llm/messagemodel.cpp +++ b/Plugins/ZShell/Llm/messagemodel.cpp @@ -40,14 +40,18 @@ int ChatMessageModel::rowOf(const ChatMessage* message) const { ChatMessage* ChatMessageModel::createMessage( ChatMessage::Role role, qint64 timestamp) { - return new ChatMessage(role, timestamp, this); + return new ChatMessage(m_session, role, timestamp, this); } ChatMessage* ChatMessageModel::appendNewest( ChatMessage::Role role, const QString& content, qint64 timestamp) { - auto* message = new ChatMessage(role, timestamp, this); - auto* generation = message->addGeneration(timestamp); - generation->setContent(content); + auto* message = createMessage(role, timestamp); + if (role == ChatMessage::Role::User) { + message->setContent(content); + } else { + auto* generation = message->addGeneration(timestamp); + generation->setContent(content); + } beginInsertRows(QModelIndex(), 0, 0); m_messages.prepend(message); diff --git a/Plugins/ZShell/Llm/prompts.cpp b/Plugins/ZShell/Llm/prompts.cpp new file mode 100644 index 0000000..4cc5ec5 --- /dev/null +++ b/Plugins/ZShell/Llm/prompts.cpp @@ -0,0 +1,68 @@ +#include "prompts.hpp" + +namespace ZShell::llm::prompts { + +QString sandboxPreamble(const QString& workspace, const QStringList& files) { + return QStringLiteral( + "The user attached the following files to this " + "conversation. They are available in your sandbox " + "directory at %1:\n%2\nruncommand executes shell " + "commands inside a bubblewrap sandbox: the rest of the " + "filesystem is read-only, there is no network access, " + "and only the sandbox directory is writable. Use it to " + "inspect, transform, or extract attached files (for " + "example to extract archives or search across many " + "files). Files you create stay inside the sandbox " + "directory. Use the webfetch tool for anything that " + "needs the network.") + .arg(workspace, files.join(QStringLiteral("\n"))); +} + +QString titleSystemPrompt() { + return QStringLiteral( + "Write a short, concise title for a chat conversation starting " + "with the user's message below. At most six words, no quotation " + "marks, no trailing punctuation. Reply with the title only."); +} + +QString iconSystemPrompt() { + return QStringLiteral( + "Pick the single icon name from this list that best matches the " + "topic of the user's message below: %1. Reply with only the " + "icon " + "name, exactly as written in the list, and nothing else.") + .arg(iconNames().join(QStringLiteral(", "))); +} + +QStringList iconNames() { + return { + "chat", "lightbulb", "code", "description", + "article", "school", "work", "build", + "science", "palette", "music_note", "sports_esports", + "takeout_dining", "flight", "photo_camera", "psychology_alt", + "favorite", "savings", "gamepad", "auto_awesome", + }; +} + +QString cleanTitle(const QString& raw) { + static const auto isQuote = [](QChar c) { + return c == QLatin1Char('"') || c == QLatin1Char('\'') || + c == QChar(u'\u201C') || c == QChar(u'\u201D') || + c == QChar(u'\u2018') || c == QChar(u'\u2019'); + }; + + QString title = raw; + while (title.size() >= 2 && isQuote(title.at(0)) && + isQuote(title.at(title.size() - 1))) + title = title.mid(1, title.size() - 2).simplified(); + while (!title.isEmpty() && (title.endsWith(QLatin1Char('.')) || + title.endsWith(QLatin1Char('!')) || + title.endsWith(QLatin1Char('?')))) + title.chop(1); + + if (title.size() > MaxTitleLength) + title = title.left(MaxTitleLength - 1) + QStringLiteral("…"); + return title; +} + +} // namespace ZShell::llm::prompts diff --git a/Plugins/ZShell/Llm/prompts.hpp b/Plugins/ZShell/Llm/prompts.hpp new file mode 100644 index 0000000..4ae8ed0 --- /dev/null +++ b/Plugins/ZShell/Llm/prompts.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +namespace ZShell::llm::prompts { + +[[nodiscard]] QString sandboxPreamble( + const QString& workspace, const QStringList& files); + +[[nodiscard]] QString titleSystemPrompt(); +[[nodiscard]] QString iconSystemPrompt(); + +[[nodiscard]] QStringList iconNames(); + +constexpr int MaxTitleLength = 48; + +[[nodiscard]] QString cleanTitle(const QString& raw); + +} // namespace ZShell::llm::prompts diff --git a/Plugins/ZShell/Llm/segment.cpp b/Plugins/ZShell/Llm/segment.cpp index 9134f7d..365d722 100644 --- a/Plugins/ZShell/Llm/segment.cpp +++ b/Plugins/ZShell/Llm/segment.cpp @@ -11,16 +11,12 @@ namespace ZShell::llm { namespace { constexpr int kMarkdownRefreshMs = 150; -} +} // namespace LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent) - : QObject(parent), m_type(type), m_timestamp(timestamp) { - m_markdownTimer.setInterval(kMarkdownRefreshMs); - connect(&m_markdownTimer, &QTimer::timeout, this, [this]() { - if (!m_markdownDirty) return; - if (parseMarkdown()) m_markdownDirty = false; - }); -} + : QObject(parent), m_type(type), m_timestamp(timestamp) {} + +LlmSegment::~LlmSegment() = default; qint64 LlmSegment::elapsedMs() const { if (m_startedAt <= 0) return 0; @@ -45,60 +41,7 @@ void LlmSegment::close() { Q_EMIT runningChanged(); Q_EMIT elapsedMsChanged(); } - if (m_type == Type::Content && m_markdownDirty) { - m_markdownTimer.stop(); - if (parseMarkdown()) m_markdownDirty = false; - } -} - -void LlmSegment::appendText(const QString& piece) { - if (piece.isEmpty()) return; - m_text += piece; - Q_EMIT textChanged(); - scheduleMarkdown(); -} - -void LlmSegment::setText(const QString& value) { - if (m_text == value) return; - m_text = value; - Q_EMIT textChanged(); - scheduleMarkdown(); -} - -void LlmSegment::setName(const QString& value) { - if (m_name == value) return; - m_name = value; - Q_EMIT nameChanged(); -} - -void LlmSegment::setToolCallId(const QString& value) { - if (m_toolCallId == value) return; - m_toolCallId = value; - Q_EMIT toolCallIdChanged(); -} - -void LlmSegment::appendArguments(const QString& piece) { - if (piece.isEmpty()) return; - m_arguments += piece; - Q_EMIT argumentsChanged(); -} - -void LlmSegment::setResult(const QString& value) { - if (m_result == value) return; - m_result = value; - Q_EMIT resultChanged(); -} - -void LlmSegment::setStatus(Status value) { - if (m_status == value) return; - m_status = value; - Q_EMIT statusChanged(); -} - -void LlmSegment::finishTool(const QString& resultText, bool success) { - setResult(resultText); - setStatus(success ? Status::Success : Status::Error); - close(); + closed(); } void LlmSegment::restore(qint64 elapsedMs) { @@ -106,32 +49,119 @@ void LlmSegment::restore(qint64 elapsedMs) { m_endedAt = m_timestamp + qMax(0, elapsedMs); } -void LlmSegment::scheduleMarkdown() { - if (m_type != Type::Content) return; +LlmTextSegment::LlmTextSegment(Type type, qint64 timestamp, QObject* parent) + : LlmSegment(type, timestamp, parent) { + m_markdownTimer.setInterval(kMarkdownRefreshMs); + connect(&m_markdownTimer, &QTimer::timeout, this, [this]() { + if (!m_markdownDirty) return; + if (parseMarkdown()) m_markdownDirty = false; + }); +} + +void LlmTextSegment::setText(const QString& value) { + if (m_text == value) return; + m_text = value; + Q_EMIT textChanged(); + scheduleMarkdown(); +} + +void LlmTextSegment::appendText(const QString& piece) { + if (piece.isEmpty()) return; + m_text += piece; + Q_EMIT textChanged(); + scheduleMarkdown(); +} + +void LlmTextSegment::closed() { + if (type() != Type::Content) return; + if (m_markdownDirty) { + m_markdownTimer.stop(); + if (parseMarkdown()) m_markdownDirty = false; + } +} + +void LlmTextSegment::scheduleMarkdown() { + if (type() != Type::Content) return; m_markdownDirty = true; if (!m_markdownTimer.isActive()) m_markdownTimer.start(); } -bool LlmSegment::parseMarkdown() { +bool LlmTextSegment::parseMarkdown() { if (m_parseInFlight) return false; m_parseInFlight = true; const QString text = m_text; QThreadPool::globalInstance()->start([this, text]() { - const QVariantList blocks = MarkdownParser::parse(text); - QPointer guard(this); + const QVector parsed = MarkdownParser::parse(text); + QPointer guard(this); QMetaObject::invokeMethod( QCoreApplication::instance(), - [guard, blocks]() { - LlmSegment* seg = guard; - if (!seg) return; - seg->m_parseInFlight = false; - seg->m_markdown = blocks; - Q_EMIT seg->markdownChanged(); - if (seg->m_markdownDirty) seg->parseMarkdown(); + [guard, parsed]() { + LlmTextSegment* segment = guard; + if (!segment) return; + segment->m_parseInFlight = false; + + QList blocks; + blocks.reserve(static_cast(parsed.size())); + for (int i = 0; i < static_cast(parsed.size()); ++i) { + const MarkdownBlockData& data = parsed.at(i); + const QString id = QStringLiteral("%1:%2").arg(i).arg( + static_cast(data.type)); + blocks.append(new MarkdownBlock( + data.type, + id, + data.text, + data.latex, + data.code, + data.language, + data.level, + segment)); + } + segment->m_blocks = blocks; + Q_EMIT segment->markdownChanged(); + if (segment->m_markdownDirty) segment->parseMarkdown(); }, Qt::QueuedConnection); }); return true; } +LlmToolCall::LlmToolCall(qint64 timestamp, QObject* parent) + : LlmSegment(Type::ToolCall, timestamp, parent) {} + +void LlmToolCall::setName(const QString& value) { + if (m_name == value) return; + m_name = value; + Q_EMIT nameChanged(); +} + +void LlmToolCall::setToolCallId(const QString& value) { + if (m_toolCallId == value) return; + m_toolCallId = value; + Q_EMIT toolCallIdChanged(); +} + +void LlmToolCall::appendArguments(const QString& piece) { + if (piece.isEmpty()) return; + m_arguments += piece; + Q_EMIT argumentsChanged(); +} + +void LlmToolCall::setResult(const QString& value) { + if (m_result == value) return; + m_result = value; + Q_EMIT resultChanged(); +} + +void LlmToolCall::setStatus(Status value) { + if (m_status == value) return; + m_status = value; + Q_EMIT statusChanged(); +} + +void LlmToolCall::finish(const QString& resultText, bool success) { + setResult(resultText); + setStatus(success ? Status::Success : Status::Error); + close(); +} + } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/segment.hpp b/Plugins/ZShell/Llm/segment.hpp index ecef553..148a57c 100644 --- a/Plugins/ZShell/Llm/segment.hpp +++ b/Plugins/ZShell/Llm/segment.hpp @@ -1,9 +1,11 @@ #pragma once +#include "markdownblock.hpp" + +#include #include #include #include -#include #include namespace ZShell::llm { @@ -15,79 +17,121 @@ class LlmSegment : public QObject { Q_PROPERTY(Type type READ type CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) - Q_PROPERTY(QString text READ text NOTIFY textChanged) - Q_PROPERTY(QString name READ name NOTIFY nameChanged) - Q_PROPERTY(QString toolCallId READ toolCallId NOTIFY toolCallIdChanged) - Q_PROPERTY(QString arguments READ arguments NOTIFY argumentsChanged) - Q_PROPERTY(QString result READ result NOTIFY resultChanged) - Q_PROPERTY(Status status READ status NOTIFY statusChanged) Q_PROPERTY(bool running READ running NOTIFY runningChanged) Q_PROPERTY(qint64 elapsedMs READ elapsedMs NOTIFY elapsedMsChanged) - Q_PROPERTY(QVariantList markdown READ markdown NOTIFY markdownChanged) public: - enum class Type : int { Reasoning = 0, ToolCall, Content }; + enum class Type : int { Reasoning = 0, Content, ToolCall }; Q_ENUM(Type) - enum class Status : int { None = 0, Running, Success, Error, Pending }; - Q_ENUM(Status) - - explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); + LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); + ~LlmSegment() override; [[nodiscard]] Type type() const { return m_type; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; } - [[nodiscard]] QString text() const { return m_text; } - [[nodiscard]] QString name() const { return m_name; } - [[nodiscard]] QString toolCallId() const { return m_toolCallId; } - [[nodiscard]] QString arguments() const { return m_arguments; } - [[nodiscard]] QString result() const { return m_result; } - [[nodiscard]] Status status() const { return m_status; } [[nodiscard]] bool running() const { return m_running; } [[nodiscard]] qint64 elapsedMs() const; - [[nodiscard]] QVariantList markdown() const { return m_markdown; } void begin(); void close(); - void appendText(const QString& piece); - void setText(const QString& value); - void setName(const QString& value); - void setToolCallId(const QString& value); - void appendArguments(const QString& piece); - void setResult(const QString& value); - void setStatus(Status value); - void finishTool(const QString& resultText, bool success); + void restore(qint64 elapsedMs); + Q_SIGNALS: + void runningChanged(); + void elapsedMsChanged(); + + protected: + virtual void closed() {} + + qint64 m_startedAt = 0; + qint64 m_endedAt = 0; + + private: + Type m_type; + qint64 m_timestamp; + bool m_running = false; +}; + +class LlmTextSegment : public LlmSegment { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Text segments are managed by ChatGeneration") + + Q_PROPERTY(QString text READ text NOTIFY textChanged) + Q_PROPERTY( + QList markdown READ markdown NOTIFY + markdownChanged) + + public: + LlmTextSegment(Type type, qint64 timestamp, QObject* parent = nullptr); + + [[nodiscard]] QString text() const { return m_text; } + void setText(const QString& value); + void appendText(const QString& piece); + + [[nodiscard]] QList markdown() const { return m_blocks; } + Q_SIGNALS: void textChanged(); void markdownChanged(); - void nameChanged(); - void toolCallIdChanged(); - void argumentsChanged(); - void resultChanged(); - void statusChanged(); - void runningChanged(); - void elapsedMsChanged(); private: void scheduleMarkdown(); bool parseMarkdown(); + void closed() override; - Type m_type; - qint64 m_timestamp; QString m_text; - QString m_name; - QString m_toolCallId; - QString m_arguments; - QString m_result; - Status m_status = Status::None; - bool m_running = false; - qint64 m_startedAt = 0; - qint64 m_endedAt = 0; - QVariantList m_markdown; + QList m_blocks; QTimer m_markdownTimer; bool m_markdownDirty = false; bool m_parseInFlight = false; }; +class LlmToolCall : public LlmSegment { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("Tool calls are managed by ChatGeneration") + + Q_PROPERTY(QString name READ name NOTIFY nameChanged) + Q_PROPERTY(QString toolCallId READ toolCallId NOTIFY toolCallIdChanged) + Q_PROPERTY(QString arguments READ arguments NOTIFY argumentsChanged) + Q_PROPERTY(QString result READ result NOTIFY resultChanged) + Q_PROPERTY(Status status READ status NOTIFY statusChanged) + + public: + enum class Status : int { None = 0, Running, Success, Error, Pending }; + Q_ENUM(Status) + + LlmToolCall(qint64 timestamp, QObject* parent = nullptr); + + [[nodiscard]] QString name() const { return m_name; } + [[nodiscard]] QString toolCallId() const { return m_toolCallId; } + [[nodiscard]] QString arguments() const { return m_arguments; } + [[nodiscard]] QString result() const { return m_result; } + [[nodiscard]] Status status() const { return m_status; } + + void setName(const QString& value); + void setToolCallId(const QString& value); + void appendArguments(const QString& piece); + void setResult(const QString& value); + void setStatus(Status value); + + void finish(const QString& resultText, bool success); + + Q_SIGNALS: + void nameChanged(); + void toolCallIdChanged(); + void argumentsChanged(); + void resultChanged(); + void statusChanged(); + + private: + QString m_name; + QString m_toolCallId; + QString m_arguments; + QString m_result; + Status m_status = Status::None; +}; + } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/session.cpp b/Plugins/ZShell/Llm/session.cpp index ee7e4df..2a22a55 100644 --- a/Plugins/ZShell/Llm/session.cpp +++ b/Plugins/ZShell/Llm/session.cpp @@ -2,42 +2,29 @@ #include "attachment.hpp" #include "chatstore.hpp" +#include "clipboard.hpp" #include "llmclient.hpp" +#include "prompts.hpp" +#include "workspace.hpp" -#include #include #include #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include namespace ZShell::llm { namespace { -constexpr qint64 kMaxImageBytes = 20 * 1024 * 1024; -constexpr qint64 kMaxFileBytes = 100 * 1024 * 1024; - -QString titleFrom(const QString& content) { - const QString flat = content.simplified(); - if (flat.isEmpty()) return QString(); - if (flat.size() <= 48) return flat; - return flat.left(47) + QStringLiteral("…"); -} +constexpr int kMaxStoredMessages = 200; } // namespace -ChatSession::ChatSession(const QString& id, QObject* parent) - : QObject(parent), m_id(id), m_workspace(sandboxDirFor(id)) { +ChatSession::ChatSession(ChatStore* store, const QString& id, QObject* parent) + : QObject(parent), m_store(store), m_id(id) { m_model = new ChatMessageModel(this, this); + m_workspace = new Workspace(this, this); + connect( m_model, &QAbstractListModel::rowsInserted, @@ -70,18 +57,27 @@ void ChatSession::setIcon(const QString& value) { if (m_icon == value) return; m_icon = value; Q_EMIT iconChanged(); + if (m_store) m_store->saveFlags(this); } void ChatSession::setUpdatedAt(qint64 value) { if (m_updatedAt == value) return; m_updatedAt = value; Q_EMIT updatedAtChanged(); + if (m_store) m_store->reposition(this); } void ChatSession::setPinned(bool value) { if (m_pinned == value) return; m_pinned = value; Q_EMIT pinnedChanged(); + if (m_store) m_store->saveFlags(this); +} + +void ChatSession::setUsageTokens(int value) { + if (m_usageTokens == value) return; + m_usageTokens = value; + Q_EMIT usageTokensChanged(); } void ChatSession::setCount(int value) { @@ -90,29 +86,25 @@ void ChatSession::setCount(int value) { Q_EMIT messageCountChanged(); } -void ChatSession::setMeta( - const QString& title, qint64 createdAt, qint64 updatedAt, int messageCount) { +void ChatSession::hydrate( + const QString& title, + const QString& icon, + qint64 createdAt, + qint64 updatedAt, + int messageCount, + bool pinned) { m_title = title; + m_icon = icon; m_createdAt = createdAt; m_updatedAt = updatedAt; m_messageCount = messageCount; -} - -void ChatSession::setLastTokenCount(int value) { - m_lastTokenCount = value; -} - -LlmClient* ChatSession::client() const { - if (auto* store = qobject_cast(parent())) - return store->llmClient(); - return nullptr; + m_pinned = pinned; } void ChatSession::ensureLoaded() { if (m_loadRequested) return; m_loadRequested = true; - if (auto* store = qobject_cast(parent())) - store->loadMessagesInto(this); + if (m_store) m_store->loadMessagesInto(this); } ChatMessageModel* ChatSession::messagesModel() { @@ -126,18 +118,12 @@ void ChatSession::markLoaded() { Q_EMIT loaded(); } -bool ChatSession::takeClearPending() { - const bool pending = m_clearPending; - m_clearPending = false; - return pending; -} - void ChatSession::adoptMessages(QList messages) { m_model->loadMessages(std::move(messages)); } void ChatSession::persist() { - if (auto* store = qobject_cast(parent())) store->persist(this); + if (m_store) m_store->persist(this); } ChatMessage* ChatSession::appendNewest( @@ -154,32 +140,27 @@ void ChatSession::clearMessages() { } void ChatSession::startGeneration(ChatMessage* target) { - auto* generation = target->activeGeneration(); - auto* clientObject = client(); - if (generation && clientObject) { - if (isLoaded()) { - clientObject->startGeneration(this, generation); - } else { - connect( - this, - &ChatSession::loaded, - clientObject, - [this, generation, clientObject]() { - clientObject->startGeneration(this, generation); - }); - } + ChatGeneration* generation = target->activeGeneration(); + LlmClient* clientObject = client(); + if (!generation || !clientObject) return; + + if (isLoaded()) { + clientObject->startGeneration(this, generation); + return; } + + connect( + this, + &ChatSession::loaded, + clientObject, + [this, generation, clientObject]() { + clientObject->startGeneration(this, generation); + }); persist(); } -QString ChatSession::sandboxDirFor(const QString& sessionId) { - const QString state = - QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation); - return state + QStringLiteral("/zshell/sandboxes/") + sessionId; -} - -void ChatSession::ensureWorkspace() { - QDir().mkpath(m_workspace); +LlmClient* ChatSession::client() const { + return m_store ? m_store->client() : nullptr; } void ChatSession::setAttachmentError(const QString& value) { @@ -189,125 +170,34 @@ void ChatSession::setAttachmentError(const QString& value) { } QVariantList ChatSession::addAttachments(const QStringList& paths) { - ensureWorkspace(); + if (!m_workspace) return {}; setAttachmentError(QString()); + QVariantList result; - for (const QString& rawPath : paths) { - const QFileInfo info(rawPath); - if (!info.exists() || !info.isFile()) { - qWarning() << "ChatSession: addAttachments skipping" << rawPath - << ": file not found or not a file"; - setAttachmentError( - QStringLiteral("File not found: %1").arg(info.fileName())); - continue; - } - const QString mimeType = QMimeDatabase().mimeTypeForFile(info).name(); - const bool isImage = - mimeType.startsWith(QLatin1String("image/"), Qt::CaseInsensitive); - const qint64 cap = isImage ? kMaxImageBytes : kMaxFileBytes; - if (info.size() > cap) { - qWarning() << "ChatSession: addAttachments skipping" << rawPath - << ": too large (" << info.size() << "bytes, cap" << cap - << ")"; - setAttachmentError( - QStringLiteral("File too large: %1").arg(info.fileName())); - continue; - } - QString destination = m_workspace + QLatin1Char('/') + info.fileName(); - int suffix = 1; - while (QFile::exists(destination)) { - const QString extension = info.suffix().isEmpty() - ? QString() - : QLatin1Char('.') + info.suffix(); - destination = m_workspace + QLatin1Char('/') + - info.completeBaseName() + - QStringLiteral("_%1%2").arg(suffix).arg(extension); - ++suffix; - } - if (!QFile::copy(info.absoluteFilePath(), destination)) { - qWarning() << "ChatSession: addAttachments skipping" << rawPath - << ": copy to" << destination << "failed"; - setAttachmentError( - QStringLiteral("Could not copy file: %1").arg(info.fileName())); - continue; - } - auto* attachment = new Attachment( - info.fileName(), - destination, - isImage ? Attachment::Kind::Image : Attachment::Kind::File, - mimeType, - info.size(), - this); + QString error; + for (Attachment* attachment : m_workspace->importFiles(paths, error)) result.append(QVariant::fromValue(attachment)); - } + if (!error.isEmpty()) setAttachmentError(error); return result; } bool ChatSession::clipboardHasAttachment() { - QClipboard* clipboard = QGuiApplication::clipboard(); - if (!clipboard) return false; - const QMimeData* data = clipboard->mimeData(); - if (!data) return false; - if (data->hasImage()) return true; - for (const QUrl& url : data->urls()) - if (url.isLocalFile()) return true; - return false; + return clipboard::hasAttachment(); } QVariantList ChatSession::pasteAttachments() { - QClipboard* clipboard = QGuiApplication::clipboard(); - const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr; - if (!data) { - qWarning() << "ChatSession: pasteAttachments: no clipboard data"; - return {}; - } - - qInfo() << "ChatSession: pasteAttachments: formats =" << data->formats() - << "hasImage =" << data->hasImage() - << "hasUrls =" << data->hasUrls() << "hasText =" << data->hasText(); - - QStringList paths; - QString tempImagePath; - if (data->hasImage()) { - const QImage image = clipboard->image(); - qInfo() << "ChatSession: pasteAttachments: image" - << (image.isNull() ? "null" - : QString::number(image.width()) + "x" + - QString::number(image.height())); - if (image.isNull()) { - setAttachmentError( - QStringLiteral("Clipboard image could not be decoded")); - return {}; - } - tempImagePath = - QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png") - .arg(QDateTime::currentMSecsSinceEpoch()); - if (!image.save(tempImagePath)) { - qWarning() << "ChatSession: pasteAttachments: saving image to" - << tempImagePath << "failed"; - setAttachmentError( - QStringLiteral("Could not save the pasted image")); - return {}; - } - paths.append(tempImagePath); - } else if (data->hasUrls()) { - for (const QUrl& url : data->urls()) { - qInfo() << "ChatSession: pasteAttachments: url =" << url.toString() - << "local =" << url.isLocalFile(); - if (url.isLocalFile()) paths.append(url.toLocalFile()); - } - } + QString error; + const QStringList paths = clipboard::attachmentPaths(error); if (paths.isEmpty()) { - if (data->hasImage() || data->hasUrls()) - setAttachmentError(QStringLiteral( - "Nothing usable on the clipboard (expected an image " - "or copied files)")); + if (!error.isEmpty()) setAttachmentError(error); return {}; } + const QVariantList attachments = addAttachments(paths); - if (!tempImagePath.isEmpty()) QFile::remove(tempImagePath); - qInfo() << "ChatSession: pasteAttachments: result =" << attachments.size() - << "attachment(s)"; + + for (const QString& path : paths) + if (path.startsWith(QDir::tempPath())) QFile::remove(path); + return attachments; } @@ -324,55 +214,52 @@ void ChatSession::sendMessage( attachmentList.append(attachment); const QString trimmed = text.trimmed(); if (trimmed.isEmpty() && attachmentList.isEmpty()) return; - if (auto* clientObject = client()) { - if (clientObject->busy()) return; - } + if (client() && client()->busy()) return; + + if (!m_committed && m_store) m_store->commitDraft(this); const qint64 now = QDateTime::currentMSecsSinceEpoch(); - auto* userMessage = appendNewest(ChatMessage::Role::User, trimmed, now); + ChatMessage* userMessage = + appendNewest(ChatMessage::Role::User, trimmed, now); if (!attachmentList.isEmpty()) userMessage->setAttachments(attachmentList); - while (m_model->rowCount() > 200) + while (m_model->rowCount() > kMaxStoredMessages) m_model->removeMessage(m_model->at(m_model->rowCount() - 1)); + if (m_title.isEmpty()) { QString titleSource = trimmed; if (titleSource.isEmpty() && !attachmentList.isEmpty()) titleSource = attachmentList.first()->name(); - m_title = titleFrom(titleSource); + m_title = prompts::cleanTitle(titleSource); Q_EMIT titleChanged(); - qInfo() << "ChatSession:" << m_id << "new conversation," - << "fallback title" << m_title - << "- requesting generated title and icon"; - if (auto* clientObject = client()) { + persist(); + + if (LlmClient* clientObject = client()) { clientObject->requestTitle(this, titleSource); clientObject->requestIcon(this, titleSource); } } - auto* assistant = + + ChatMessage* assistant = appendNewest(ChatMessage::Role::Assistant, QString(), now); startGeneration(assistant); } void ChatSession::retry(ChatMessage* target) { if (!target || target->role() != ChatMessage::Role::Assistant) return; - if (auto* clientObject = client()) { - if (clientObject->busy()) return; - } + if (client() && client()->busy()) return; const int row = m_model->rowOf(target); if (row < 0) return; m_model->removeRange(0, row - 1); if (m_model->rowCount() < 2) return; - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - target->appendGeneration(now); + target->appendGeneration(QDateTime::currentMSecsSinceEpoch()); startGeneration(target); } void ChatSession::continueFrom(ChatMessage* message) { if (!message) return; - if (auto* clientObject = client()) { - if (clientObject->busy()) return; - } + if (client() && client()->busy()) return; const int row = m_model->rowOf(message); if (row < 0) return; @@ -384,26 +271,15 @@ void ChatSession::continueFrom(ChatMessage* message) { message->appendGeneration(now); startGeneration(message); } else { - auto* assistant = + ChatMessage* assistant = appendNewest(ChatMessage::Role::Assistant, QString(), now); startGeneration(assistant); } } void ChatSession::clear() { - if (auto* clientObject = client()) { - if (clientObject->busy() && clientObject->streamingSession() == this) { - clientObject->clearOnFinish(this); - clientObject->stop(); - return; - } - } - if (!isLoaded()) { - m_clearPending = true; - return; - } - m_model->clear(); - persist(); + if (!m_store) return; + m_store->requestClear(this); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/session.hpp b/Plugins/ZShell/Llm/session.hpp index f96e70f..cab1f3d 100644 --- a/Plugins/ZShell/Llm/session.hpp +++ b/Plugins/ZShell/Llm/session.hpp @@ -1,7 +1,7 @@ #pragma once -#include "messagemodel.hpp" #include "message.hpp" +#include "messagemodel.hpp" #include #include @@ -12,30 +12,32 @@ namespace ZShell::llm { +class ChatStore; class LlmClient; +class Workspace; class ChatSession : public QObject { Q_OBJECT QML_ELEMENT - QML_UNCREATABLE("Chat sessions are managed by Chat.chats") + QML_UNCREATABLE("Chat sessions are owned by the LlmClient singleton") Q_PROPERTY(QString id READ id CONSTANT) Q_PROPERTY(QString title READ title WRITE setTitle NOTIFY titleChanged) - Q_PROPERTY(QString icon READ icon NOTIFY iconChanged) + Q_PROPERTY(QString icon READ icon WRITE setIcon NOTIFY iconChanged) Q_PROPERTY(QDateTime createdAt READ createdAt CONSTANT) Q_PROPERTY(QDateTime updatedAt READ updatedAt NOTIFY updatedAtChanged) - Q_PROPERTY(bool pinned READ pinned NOTIFY pinnedChanged) + Q_PROPERTY(bool pinned READ pinned WRITE setPinned NOTIFY pinnedChanged) Q_PROPERTY(bool loaded READ isLoaded NOTIFY loaded) Q_PROPERTY(int messageCount READ messageCount NOTIFY messageCountChanged) + Q_PROPERTY(int usageTokens READ usageTokens NOTIFY usageTokensChanged) Q_PROPERTY( ZShell::llm::ChatMessageModel* messagesModel READ messagesModel CONSTANT) - Q_PROPERTY(QString workspace READ workspacePath CONSTANT) Q_PROPERTY( QString attachmentError READ attachmentError NOTIFY attachmentErrorChanged) public: - explicit ChatSession(const QString& id, QObject* parent = nullptr); + ChatSession(ChatStore* store, const QString& id, QObject* parent = nullptr); [[nodiscard]] QString id() const { return m_id; } [[nodiscard]] QString title() const { return m_title; } @@ -50,34 +52,33 @@ class ChatSession : public QObject { [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } [[nodiscard]] bool pinned() const { return m_pinned; } [[nodiscard]] int messageCount() const { return m_messageCount; } + [[nodiscard]] int usageTokens() const { return m_usageTokens; } [[nodiscard]] ChatMessageModel* messagesModel(); void ensureLoaded(); [[nodiscard]] bool isLoaded() const { return m_loaded; } - [[nodiscard]] ChatMessageModel* model() const { return m_model; } void markLoaded(); - [[nodiscard]] bool takeClearPending(); - - [[nodiscard]] LlmClient* client() const; - [[nodiscard]] int lastTokenCount() const { return m_lastTokenCount; } - void setLastTokenCount(int value); - - [[nodiscard]] QString workspacePath() const { return m_workspace; } [[nodiscard]] QString attachmentError() const { return m_attachmentError; } - void setAttachmentError(const QString& value); - static QString sandboxDirFor(const QString& sessionId); - void ensureWorkspace(); + [[nodiscard]] ChatStore* store() const { return m_store; } + [[nodiscard]] LlmClient* client() const; + [[nodiscard]] Workspace* workspace() const { return m_workspace; } void setTitle(const QString& value); void setIcon(const QString& value); void setUpdatedAt(qint64 value); void setPinned(bool value); + void setUsageTokens(int value); - void setMeta( + void hydrate( const QString& title, + const QString& icon, qint64 createdAt, qint64 updatedAt, - int messageCount); + int messageCount, + bool pinned); + + [[nodiscard]] bool isCommitted() const { return m_committed; } + void setCommitted(bool value) { m_committed = value; } void adoptMessages(QList messages); void persist(); @@ -101,6 +102,7 @@ class ChatSession : public QObject { void pinnedChanged(); void messageCountChanged(); void loaded(); + void usageTokensChanged(); void attachmentErrorChanged(); private: @@ -109,20 +111,22 @@ class ChatSession : public QObject { ChatMessage::Role role, const QString& content, qint64 timestamp); void startGeneration(ChatMessage* target); void setCount(int value); + void setAttachmentError(const QString& value); + ChatStore* m_store; QString m_id; + bool m_committed = false; QString m_title; QString m_icon; qint64 m_createdAt = 0; qint64 m_updatedAt = 0; bool m_pinned = false; int m_messageCount = 0; + int m_usageTokens = 0; ChatMessageModel* m_model = nullptr; + Workspace* m_workspace = nullptr; bool m_loadRequested = false; bool m_loaded = false; - bool m_clearPending = false; - int m_lastTokenCount = 0; - QString m_workspace; QString m_attachmentError; }; diff --git a/Plugins/ZShell/Llm/shelltool.cpp b/Plugins/ZShell/Llm/shelltool.cpp index c18ceaa..e9ce098 100644 --- a/Plugins/ZShell/Llm/shelltool.cpp +++ b/Plugins/ZShell/Llm/shelltool.cpp @@ -1,8 +1,5 @@ #include "shelltool.hpp" -#include "llmclient.hpp" -#include "session.hpp" - #include #include #include @@ -13,24 +10,8 @@ namespace ZShell::llm { -namespace { - -QJsonObject makeOutput(const QString& text) { - QJsonObject obj; - obj[QStringLiteral("output")] = text; - return obj; -} - -QJsonObject makeError(const QString& message) { - QJsonObject obj; - obj[QStringLiteral("error")] = message; - return obj; -} - -} // namespace - -ShellTool::ShellTool(LlmClient* client, QObject* parent) - : LlmTool(parent), m_client(client) {} +ShellTool::ShellTool(ToolRegistry* registry, QObject* parent) + : LlmTool(registry, parent) {} ShellTool::~ShellTool() { for (auto* job : m_jobs) { @@ -125,27 +106,29 @@ QStringList ShellTool::sandboxArguments(const QString& workspace) { return args; } +bool ShellTool::available(const ToolContext& context) const { + return registry()->sandboxEnabled() && !context.workspace.isEmpty(); +} + void ShellTool::execute( - const QString& toolCallId, - const QJsonObject& args, - int outputBudgetChars, - std::function done) { - Q_UNUSED(toolCallId); + const ToolContext& context, const QJsonObject& args, ToolDone done) { const QString command = args[QStringLiteral("command")].toString(); if (command.trimmed().isEmpty()) { - done(makeError(QStringLiteral("Missing required argument 'command'"))); + done({false, QStringLiteral("Missing required argument 'command'")}); return; } - LlmClient* client = m_client; - if (!client || !client->sandboxEnabled()) { - done(makeError(QStringLiteral( - "The file sandbox is disabled, so no commands can run"))); + if (!registry()->sandboxEnabled()) { + done( + {false, + QStringLiteral( + "The file sandbox is disabled, so no commands can run")}); return; } - const QString workspace = client->activeWorkspace(); + const QString workspace = context.workspace; if (workspace.isEmpty()) { - done(makeError( - QStringLiteral("No sandbox available: no chat session is active"))); + done( + {false, + QStringLiteral("No sandbox available: no chat session is active")}); return; } QDir().mkpath(workspace); @@ -156,7 +139,7 @@ void ShellTool::execute( MaxTimeoutSeconds); auto* job = new Job; job->timeoutSeconds = timeoutSeconds; - job->outputBudgetChars = outputBudgetChars; + job->outputBudgetChars = context.outputBudgetChars; job->done = std::move(done); m_jobs.append(job); @@ -230,9 +213,8 @@ void ShellTool::finishJob(Job* job) { "Command timed out after %1 seconds and was killed.\n") .arg(job->timeoutSeconds); - int budget = inlineBudgetChars(); - if (job->outputBudgetChars > 0) - budget = qMin(budget, job->outputBudgetChars); + int budget = job->outputBudgetChars > 0 ? job->outputBudgetChars + : registry()->outputBudgetChars(1); if (output.size() > budget) output = output.left(budget) + QStringLiteral( "\n[output truncated: %1 characters " @@ -242,17 +224,12 @@ void ShellTool::finishJob(Job* job) { const QString result = note + QStringLiteral("exit code: %1\n").arg(exitCode) + (output.isEmpty() ? QStringLiteral("(no output)") : output); + const bool ok = !job->cancelled && !job->timedOut && exitCode == 0; - QJsonObject resultObject; - if (!job->cancelled && !job->timedOut && exitCode == 0) - resultObject = makeOutput(result); - else - resultObject = makeError(result); - - std::function done = std::move(job->done); + ToolDone done = std::move(job->done); if (process) process->deleteLater(); delete job; - if (done) done(resultObject); + if (done) done({ok, result}); } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/shelltool.hpp b/Plugins/ZShell/Llm/shelltool.hpp index de1e205..c4f8f4a 100644 --- a/Plugins/ZShell/Llm/shelltool.hpp +++ b/Plugins/ZShell/Llm/shelltool.hpp @@ -11,23 +11,21 @@ class QTimer; namespace ZShell::llm { -class LlmClient; - class ShellTool : public LlmTool { Q_OBJECT public: - explicit ShellTool(LlmClient* client, QObject* parent = nullptr); + explicit ShellTool(ToolRegistry* registry, QObject* parent = nullptr); ~ShellTool() override; QString name() const override; QString description() const override; QJsonObject parameters() const override; + bool available(const ToolContext& context) const override; void execute( - const QString& toolCallId, + const ToolContext& context, const QJsonObject& args, - int outputBudgetChars, - std::function done) override; + ToolDone done) override; void cancel() override; static constexpr int DefaultTimeoutSeconds = 60; @@ -39,7 +37,7 @@ class ShellTool : public LlmTool { QPointer timer; int timeoutSeconds = DefaultTimeoutSeconds; int outputBudgetChars = 0; - std::function done; + ToolDone done; QString startError; bool timedOut = false; bool cancelled = false; @@ -50,7 +48,6 @@ class ShellTool : public LlmTool { void finishJob(Job* job); - QPointer m_client; QList m_jobs; }; diff --git a/Plugins/ZShell/Llm/tool.cpp b/Plugins/ZShell/Llm/tool.cpp index 1bb8107..622ab00 100644 --- a/Plugins/ZShell/Llm/tool.cpp +++ b/Plugins/ZShell/Llm/tool.cpp @@ -2,20 +2,14 @@ namespace ZShell::llm { -LlmTool::LlmTool(QObject* parent) : QObject(parent) {} +LlmTool::LlmTool(ToolRegistry* registry, QObject* parent) + : QObject(parent), m_registry(registry) {} LlmTool::~LlmTool() = default; -void LlmTool::setContextSize(int value) { - if (m_contextSize == value) return; - m_contextSize = value; -} - -int LlmTool::inlineBudgetChars() const { - if (m_contextSize <= 0) return DefaultInlineChars; - const int tokens = - qBound(512, m_contextSize / 4, DefaultInlineChars / CharsPerToken); - return tokens * CharsPerToken; +bool LlmTool::available(const ToolContext& context) const { + Q_UNUSED(context); + return true; } void LlmTool::cancel() {} @@ -33,30 +27,22 @@ QJsonObject LlmTool::specification() const { ToolRegistry::ToolRegistry(QObject* parent) : QObject(parent) {} -void ToolRegistry::setEnabled(bool value) { - if (m_enabled == value) return; - m_enabled = value; - Q_EMIT enabledChanged(); -} - void ToolRegistry::registerTool(LlmTool* tool) { if (!tool || m_tools.contains(tool)) return; tool->setParent(this); - tool->setContextSize(m_contextSize); m_tools.append(tool); } LlmTool* ToolRegistry::tool(const QString& name) const { - for (const auto* tool : m_tools) - if (tool->name() == name) return const_cast(tool); + for (auto* tool : m_tools) + if (tool->name() == name) return tool; return nullptr; } -QJsonArray ToolRegistry::specifications() const { - if (!m_enabled) return {}; +QJsonArray ToolRegistry::specifications(const ToolContext& context) const { QJsonArray specs; - for (const auto* tool : m_tools) - specs.append(tool->specification()); + for (auto* tool : m_tools) + if (tool->available(context)) specs.append(tool->specification()); return specs; } @@ -68,8 +54,25 @@ void ToolRegistry::cancelAll() { void ToolRegistry::setContextSize(int value) { if (m_contextSize == value) return; m_contextSize = value; - for (auto* tool : m_tools) - tool->setContextSize(value); +} + +void ToolRegistry::setEnabled(bool value) { + if (m_enabled == value) return; + m_enabled = value; +} + +void ToolRegistry::setSandboxEnabled(bool value) { + if (m_sandbox == value) return; + m_sandbox = value; +} + +int ToolRegistry::outputBudgetChars(int callCount) const { + if (m_contextSize <= 0) return DefaultInlineChars; + const int tokens = qBound( + MinBudgetTokens, + m_contextSize / qMax(1, callCount), + DefaultInlineChars / CharsPerToken); + return tokens * CharsPerToken; } } // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/tool.hpp b/Plugins/ZShell/Llm/tool.hpp index 3ce0d86..53a1d39 100644 --- a/Plugins/ZShell/Llm/tool.hpp +++ b/Plugins/ZShell/Llm/tool.hpp @@ -10,61 +10,76 @@ namespace ZShell::llm { +struct ToolResult { + bool ok = false; + QString text; +}; + +struct ToolContext { + QString toolCallId; + QString workspace; + int outputBudgetChars = 0; +}; + +using ToolDone = std::function; + +class ToolRegistry; + class LlmTool : public QObject { Q_OBJECT public: - explicit LlmTool(QObject* parent = nullptr); + explicit LlmTool(ToolRegistry* registry, QObject* parent = nullptr); ~LlmTool() override; [[nodiscard]] virtual QString name() const = 0; [[nodiscard]] virtual QString description() const = 0; [[nodiscard]] virtual QJsonObject parameters() const = 0; + [[nodiscard]] virtual bool available(const ToolContext& context) const; + virtual void execute( - const QString& toolCallId, - const QJsonObject& args, - int outputBudgetChars, - std::function done) = 0; + const ToolContext& context, const QJsonObject& args, ToolDone done) = 0; + virtual void cancel(); [[nodiscard]] QJsonObject specification() const; - [[nodiscard]] int contextSize() const { return m_contextSize; } - void setContextSize(int value); + [[nodiscard]] ToolRegistry* registry() const { return m_registry; } - [[nodiscard]] int inlineBudgetChars() const; - - static constexpr int CharsPerToken = 4; - static constexpr int DefaultInlineChars = 64 * 1024; - - protected: - int m_contextSize = 0; + private: + ToolRegistry* m_registry; }; class ToolRegistry : public QObject { Q_OBJECT - Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged) - public: explicit ToolRegistry(QObject* parent = nullptr); - [[nodiscard]] bool enabled() const { return m_enabled; } - void setEnabled(bool value); - void registerTool(LlmTool* tool); [[nodiscard]] LlmTool* tool(const QString& name) const; - [[nodiscard]] QJsonArray specifications() const; + [[nodiscard]] QList tools() const { return m_tools; } + [[nodiscard]] QJsonArray specifications(const ToolContext& context) const; void cancelAll(); + [[nodiscard]] bool enabled() const { return m_enabled; } + void setEnabled(bool value); + [[nodiscard]] bool sandboxEnabled() const { return m_sandbox; } + void setSandboxEnabled(bool value); + + [[nodiscard]] int contextSize() const { return m_contextSize; } void setContextSize(int value); - Q_SIGNALS: - void enabledChanged(); + [[nodiscard]] int outputBudgetChars(int callCount) const; + + static constexpr int CharsPerToken = 4; + static constexpr int DefaultInlineChars = 64 * 1024; + static constexpr int MinBudgetTokens = 512; private: bool m_enabled = true; + bool m_sandbox = true; int m_contextSize = 0; QList m_tools; }; diff --git a/Plugins/ZShell/Llm/turn.cpp b/Plugins/ZShell/Llm/turn.cpp new file mode 100644 index 0000000..fe39d74 --- /dev/null +++ b/Plugins/ZShell/Llm/turn.cpp @@ -0,0 +1,391 @@ +#include "turn.hpp" + +#include "attachment.hpp" +#include "message.hpp" +#include "messagemodel.hpp" +#include "prompts.hpp" +#include "session.hpp" +#include "workspace.hpp" + +#include +#include + +namespace ZShell::llm { + +namespace { + +constexpr int kMinContextTokens = 512; +constexpr int kToolResultStubChars = 512; +constexpr double kPromptEstimateDiscount = 10.0 / 12.0; + +qsizetype jsonValueChars(const QJsonValue& value) { + switch (value.type()) { + case QJsonValue::String: + return value.toString().size(); + case QJsonValue::Array: { + qsizetype total = 0; + for (const QJsonValue& item : value.toArray()) + total += jsonValueChars(item); + return total; + } + case QJsonValue::Object: { + qsizetype total = 0; + const QJsonObject obj = value.toObject(); + for (auto it = obj.constBegin(); it != obj.constEnd(); ++it) + total += jsonValueChars(it.value()); + return total; + } + default: + return 2; + } +} + +int estimateMessageTokens(const QJsonObject& message) { + return int(jsonValueChars(message) / ToolRegistry::CharsPerToken) + 4; +} + +int estimateArrayTokens(const QJsonArray& messages) { + int total = 0; + for (const QJsonValue& value : messages) + total += estimateMessageTokens(value.toObject()); + return total; +} + +int completionReserve(int contextSize) { + return qBound(2048, contextSize / 8, 8192); +} + +void shrinkUnitToBudget(QList& unit, int budgetTokens) { + static const QStringList kTextKeys = { + QStringLiteral("content"), QStringLiteral("reasoning_content")}; + for (int pass = 0; pass < 8; ++pass) { + int tokens = 0; + for (const QJsonObject& message : unit) + tokens += estimateMessageTokens(message); + if (tokens <= budgetTokens) return; + qsizetype bestMessage = -1; + QString bestKey; + qsizetype bestLength = 0; + for (qsizetype i = 0; i < unit.size(); ++i) { + const QJsonObject message = unit.at(i); + for (const QString& key : kTextKeys) { + const QJsonValue value = message.value(key); + if (value.isString() && value.toString().size() > bestLength) { + bestMessage = i; + bestKey = key; + bestLength = value.toString().size(); + } + } + } + if (bestMessage < 0) return; + QString text = unit.at(bestMessage).value(bestKey).toString(); + text.truncate(qMax(32, text.size() * 3 / 4)); + const QString marker = QStringLiteral("\n[…]"); + if (!text.endsWith(marker)) text += marker; + unit[bestMessage][bestKey] = text; + } +} + +} // namespace + +Turn::Turn( + ChatSession* session, + ChatGeneration* generation, + const ToolRegistry& registry) + : m_session(session), m_generation(generation), m_registry(registry) {} + +int Turn::targetRow() const { + if (!m_session) return -1; + const ChatMessage* message = m_generation ? m_generation->message() + : nullptr; + return message ? m_session->messagesModel()->rowOf(message) : -1; +} + +int Turn::transcriptTokens() const { + return estimateArrayTokens(m_transcript); +} + +int Turn::promptBudgetTokens() const { + const int contextSize = m_registry.contextSize(); + if (contextSize <= 0) return 0; + return int( + (contextSize - completionReserve(contextSize)) * + kPromptEstimateDiscount); +} + +int Turn::batchSize() const { + int size = 0; + for (const Call& call : m_calls) + if (call.seen) ++size; + return size; +} + +int Turn::outputBudgetChars() const { + const int batch = qMax(1, batchSize()); + const int promptLimit = promptBudgetTokens(); + if (promptLimit <= 0) return m_registry.outputBudgetChars(batch); + const int available = promptLimit - transcriptTokens(); + const int tokens = qMax(available / batch, ToolRegistry::MinBudgetTokens); + return tokens * ToolRegistry::CharsPerToken; +} + +void Turn::resetRound() { + roundDone = false; + streamDone = false; + toolPhase = false; + pendingCalls = 0; + finishReason.clear(); + m_calls.clear(); + m_results.clear(); +} + +void Turn::appendToTranscript(const QJsonObject& message) { + m_transcript.append(message); +} + +QJsonObject Turn::roundAssistantMessage() { + QJsonObject assistant; + assistant[QStringLiteral("role")] = QStringLiteral("assistant"); + + const QString content = m_generation->content(); + const QString reasoning = m_generation->reasoning(); + if (content.size() > m_contentMark) { + assistant[QStringLiteral("content")] = content.mid(m_contentMark); + m_contentMark = content.size(); + } + if (reasoning.size() > m_reasoningMark) { + assistant[QStringLiteral("reasoning_content")] = + reasoning.mid(m_reasoningMark); + m_reasoningMark = reasoning.size(); + } + + QJsonArray calls; + for (const Call& call : m_calls) { + if (!call.seen) continue; + QJsonObject function; + function[QStringLiteral("name")] = call.name; + function[QStringLiteral("arguments")] = call.arguments; + QJsonObject entry; + entry[QStringLiteral("id")] = call.id; + entry[QStringLiteral("type")] = QStringLiteral("function"); + entry[QStringLiteral("function")] = function; + calls.append(entry); + } + assistant[QStringLiteral("tool_calls")] = calls; + return assistant; +} + +QJsonArray Turn::toolMessages() const { + QJsonArray messages; + for (int i = 0; i < m_calls.size(); ++i) { + const Call& call = m_calls.at(i); + if (!call.seen) continue; + QJsonObject toolMessage; + toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); + toolMessage[QStringLiteral("tool_call_id")] = call.id; + toolMessage[QStringLiteral("content")] = m_results.at(i).text; + messages.append(toolMessage); + } + return messages; +} + +bool Turn::roundHasCalls() const { + for (const Call& call : m_calls) + if (call.seen) return true; + return false; +} + +void Turn::appendSandboxPreamble(QJsonArray& messages) const { + if (!m_registry.sandboxEnabled()) return; + const QString workspace = m_session->workspace()->path(); + if (workspace.isEmpty()) return; + + const auto* model = m_session->messagesModel(); + QStringList files; + QSet seenPaths; + for (int row = 0; row < model->rowCount(); ++row) { + const auto* message = model->at(row); + if (message->role() != ChatMessage::Role::User) continue; + for (const auto* attachment : message->attachments()) { + if (attachment->kind() != Attachment::Kind::File) continue; + const QString relativePath = + attachment->path().mid(workspace.size() + 1); + if (seenPaths.contains(relativePath)) continue; + seenPaths.insert(relativePath); + files.append(QStringLiteral("- %1 (available as %2)") + .arg(attachment->name(), relativePath)); + } + } + if (files.isEmpty()) return; + + QJsonObject preamble; + preamble[QStringLiteral("role")] = QStringLiteral("system"); + preamble[QStringLiteral("content")] = + prompts::sandboxPreamble(workspace, files); + messages.prepend(preamble); +} + +QJsonArray Turn::contextMessages() const { + const auto* model = m_session->messagesModel(); + const int stopBeforeRow = targetRow(); + + QList> units; + for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { + const auto* message = model->at(row); + if (message->role() == ChatMessage::Role::User) { + if (message->content().isEmpty() && + message->attachments().isEmpty()) + continue; + + QList unit; + + QJsonObject user; + user[QStringLiteral("role")] = QStringLiteral("user"); + const QString text = message->content(); + const QList attachments = message->attachments(); + if (attachments.isEmpty()) { + user[QStringLiteral("content")] = text; + } else { + QJsonArray parts; + if (!text.isEmpty()) { + QJsonObject textPart; + textPart[QStringLiteral("type")] = QStringLiteral("text"); + textPart[QStringLiteral("text")] = text; + parts.append(textPart); + } + for (const auto* attachment : attachments) { + if (attachment->kind() != Attachment::Kind::Image) continue; + QFile file(attachment->path()); + if (!file.open(QIODevice::ReadOnly)) continue; + const QByteArray base64 = file.readAll().toBase64(); + file.close(); + if (base64.isEmpty()) continue; + QJsonObject imageUrl; + imageUrl[QStringLiteral("url")] = + QStringLiteral("data:%1;base64,%2") + .arg(attachment->mimeType()) + .arg(QString::fromLatin1(base64)); + QJsonObject imagePart; + imagePart[QStringLiteral("type")] = + QLatin1String("image_url"); + imagePart[QStringLiteral("image_url")] = imageUrl; + parts.append(imagePart); + } + if (parts.isEmpty()) + user[QStringLiteral("content")] = text; + else + user[QStringLiteral("content")] = parts; + } + unit.append(user); + units.append(unit); + continue; + } + + const ChatGeneration* generation = message->activeGeneration(); + if (!generation) continue; + const QList calls = generation->toolCalls(); + const bool hasAnyContent = !generation->content().isEmpty() || + !generation->reasoning().isEmpty() || + !calls.isEmpty(); + if (!hasAnyContent) continue; + + QJsonObject assistant; + assistant[QStringLiteral("role")] = QStringLiteral("assistant"); + if (!calls.isEmpty() || !generation->content().isEmpty()) + assistant[QStringLiteral("content")] = generation->content(); + if (!generation->reasoning().isEmpty()) + assistant[QStringLiteral("reasoning_content")] = + generation->reasoning(); + if (!calls.isEmpty()) { + QJsonArray callArray; + for (const auto* call : calls) { + QJsonObject function; + function[QStringLiteral("name")] = call->name(); + function[QStringLiteral("arguments")] = call->arguments(); + QJsonObject entry; + entry[QStringLiteral("id")] = call->toolCallId(); + entry[QStringLiteral("type")] = QStringLiteral("function"); + entry[QStringLiteral("function")] = function; + callArray.append(entry); + } + assistant[QStringLiteral("tool_calls")] = callArray; + } + + QList unit; + unit.append(assistant); + for (const auto* call : calls) { + QJsonObject toolMessage; + toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); + toolMessage[QStringLiteral("tool_call_id")] = call->toolCallId(); + toolMessage[QStringLiteral("content")] = call->result(); + unit.append(toolMessage); + } + units.append(unit); + } + + if (m_registry.contextSize() > 0) { + const int reserve = transcriptTokens(); + int budget = m_registry.contextSize() - + completionReserve(m_registry.contextSize()) - reserve; + budget = qMax(budget, kMinContextTokens); + + int total = 0; + for (const QList& unit : units) + for (const QJsonObject& message : unit) + total += estimateMessageTokens(message); + while (units.size() > 1 && total > budget) { + for (const QJsonObject& message : units.first()) + total -= estimateMessageTokens(message); + units.removeFirst(); + } + while (units.size() > 1 && + units.first().first()[QStringLiteral("role")].toString() == + QLatin1String("assistant")) { + for (const QJsonObject& message : units.first()) + total -= estimateMessageTokens(message); + units.removeFirst(); + } + if (units.size() == 1) { + QList single = units.first(); + shrinkUnitToBudget(single, budget); + units[0] = single; + } + } + + QJsonArray messages; + for (const QList& unit : units) + for (const QJsonObject& message : unit) + messages.append(message); + + for (const QJsonValue& value : m_transcript) + messages.append(value); + + if (m_registry.contextSize() > 0) { + const int promptLimit = promptBudgetTokens(); + while (estimateArrayTokens(messages) > promptLimit) { + bool stubbed = false; + for (qsizetype i = 0; i < messages.size(); ++i) { + QJsonObject message = messages.at(i).toObject(); + if (message.value("role").toString() != QLatin1String("tool")) + continue; + const QString content = message.value("content").toString(); + if (content.size() <= kToolResultStubChars) continue; + QString stub = content.left(kToolResultStubChars); + stub += QStringLiteral( + "\n[truncated to fit the context window; was " + "%1 characters]") + .arg(content.size()); + message["content"] = stub; + messages[i] = message; + stubbed = true; + break; + } + if (!stubbed) break; + } + } + + appendSandboxPreamble(messages); + return messages; +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/turn.hpp b/Plugins/ZShell/Llm/turn.hpp new file mode 100644 index 0000000..7669a57 --- /dev/null +++ b/Plugins/ZShell/Llm/turn.hpp @@ -0,0 +1,81 @@ +#pragma once + +#include "generation.hpp" +#include "segment.hpp" +#include "tool.hpp" + +#include +#include +#include +#include + +namespace ZShell::llm { + +class ChatSession; + +class Turn { + public: + Turn( + ChatSession* session, + ChatGeneration* generation, + const ToolRegistry& registry); + + [[nodiscard]] ChatSession* session() const { return m_session; } + [[nodiscard]] ChatGeneration* generation() const { return m_generation; } + [[nodiscard]] const ToolRegistry& registry() const { return m_registry; } + + [[nodiscard]] QJsonArray contextMessages() const; + [[nodiscard]] int transcriptTokens() const; + [[nodiscard]] int promptBudgetTokens() const; + + [[nodiscard]] int targetRow() const; + + void resetRound(); + + [[nodiscard]] QJsonObject roundAssistantMessage(); + [[nodiscard]] QJsonArray toolMessages() const; + [[nodiscard]] bool roundHasCalls() const; + + struct Call { + QString id; + QString name; + QString arguments; + QPointer segment; + bool seen = false; + }; + + QList& calls() { return m_calls; } + [[nodiscard]] const QList& calls() const { return m_calls; } + + QList& results() { return m_results; } + [[nodiscard]] const QList& results() const { return m_results; } + + void appendToTranscript(const QJsonObject& message); + [[nodiscard]] const QJsonArray& transcript() const { return m_transcript; } + + [[nodiscard]] int batchSize() const; + [[nodiscard]] int outputBudgetChars() const; + + int round = 0; + int pendingCalls = 0; + bool roundDone = false; + bool streamDone = false; + bool toolPhase = false; + bool approvalPending = false; + bool contextRetryUsed = false; + QString finishReason; + + private: + void appendSandboxPreamble(QJsonArray& messages) const; + + ChatSession* m_session; + ChatGeneration* m_generation; + const ToolRegistry& m_registry; + QJsonArray m_transcript; + QList m_calls; + QList m_results; + qsizetype m_contentMark = 0; + qsizetype m_reasoningMark = 0; +}; + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/webfetchtool.cpp b/Plugins/ZShell/Llm/webfetchtool.cpp index 1d5e76f..71c859f 100644 --- a/Plugins/ZShell/Llm/webfetchtool.cpp +++ b/Plugins/ZShell/Llm/webfetchtool.cpp @@ -10,30 +10,16 @@ namespace ZShell::llm { -const QString WebFetchTool::StoragePath = - QStringLiteral("/tmp/zshell-llm/webfetch"); - namespace { const char* kUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"; -QJsonObject makeOutput(const QString& text) { - QJsonObject obj; - obj[QStringLiteral("output")] = text; - return obj; -} - -QJsonObject makeError(const QString& message) { - QJsonObject obj; - obj[QStringLiteral("error")] = message; - return obj; -} - } // namespace -WebFetchTool::WebFetchTool(QObject* parent) : LlmTool(parent) {} +WebFetchTool::WebFetchTool(ToolRegistry* registry, QObject* parent) + : LlmTool(registry, parent) {} WebFetchTool::~WebFetchTool() { for (auto* job : m_jobs) { @@ -52,9 +38,9 @@ QString WebFetchTool::description() const { "Fetch content from an HTTP or HTTPS URL and return it as plain " "text or raw HTML. HTML pages are reduced to their visible text " "by default. Only a limited amount of content is returned inline; " - "the full output of every call is saved under " - "/tmp/zshell-llm/webfetch/, where it can be paged through with " - "the readfile tool. This tool is read-only."); + "the full output of every call is saved in the sandbox directory " + "of the conversation, where it can be paged through with the " + "readfile tool. This tool is read-only."); } QJsonObject WebFetchTool::parameters() const { @@ -94,7 +80,7 @@ QJsonObject WebFetchTool::parameters() const { return schema; } -void WebFetchTool::completeJob(Job* job, QJsonObject result) { +void WebFetchTool::completeJob(Job* job, ToolResult result) { QMetaObject::invokeMethod( this, [this, job, result = std::move(result)]() mutable { @@ -110,19 +96,23 @@ void WebFetchTool::completeJob(Job* job, QJsonObject result) { } void WebFetchTool::execute( - const QString& toolCallId, - const QJsonObject& args, - int outputBudgetChars, - std::function done) { + const ToolContext& context, const QJsonObject& args, ToolDone done) { auto* job = new Job; job->done = std::move(done); - job->toolCallId = toolCallId; - job->budgetChars = outputBudgetChars; + job->toolCallId = context.toolCallId; + job->budgetChars = context.outputBudgetChars > 0 + ? context.outputBudgetChars + : registry()->outputBudgetChars(1); + job->storagePath = + context.workspace.isEmpty() + ? QDir(QDir::tempPath()) + .filePath(QStringLiteral("zshell-llm/webfetch")) + : context.workspace + QStringLiteral("/webfetch"); m_jobs.append(job); auto fail = [this, job](const QString& message) { job->timer->stop(); - completeJob(job, makeError(message)); + completeJob(job, {false, message}); }; const QString urlText = args[QStringLiteral("url")].toString().trimmed(); @@ -194,23 +184,27 @@ void WebFetchTool::execute( if (job->tooLarge) { completeJob( job, - makeError(QStringLiteral( - "Response too large (exceeds the 5 MB " - "limit"))); + ToolResult{ + false, + QStringLiteral( + "Response too large (exceeds the 5 MB " + "limit")}); return; } if (error != QNetworkReply::NoError) { completeJob( job, - makeError( - QStringLiteral("Request failed: %1").arg(errorString))); + ToolResult{ + false, + QStringLiteral("Request failed: %1").arg(errorString)}); return; } if (status < 200 || status >= 300) { completeJob( job, - makeError( - QStringLiteral("Server returned status %1").arg(status))); + ToolResult{ + false, + QStringLiteral("Server returned status %1").arg(status)}); return; } @@ -220,9 +214,10 @@ void WebFetchTool::execute( if (mime.startsWith(QLatin1String("image/"))) { completeJob( job, - makeError( + ToolResult{ + false, QStringLiteral("Unsupported fetched image content type: %1") - .arg(mime))); + .arg(mime)}); return; } const bool textual = @@ -236,9 +231,10 @@ void WebFetchTool::execute( if (!textual) { completeJob( job, - makeError( + ToolResult{ + false, QStringLiteral("Unsupported fetched file content type: %1") - .arg(mime))); + .arg(mime)}); return; } @@ -249,9 +245,8 @@ void WebFetchTool::execute( const QString savedPath = saveToFile(*job, content, mime); + const int budget = job->budgetChars; QString output = content; - int budget = inlineBudgetChars(); - if (job->budgetChars > 0) budget = qMin(budget, job->budgetChars); if (content.size() > budget) { output = content.left(budget); const QString note = @@ -269,13 +264,13 @@ void WebFetchTool::execute( ". The remaining content is not " "available."); } - completeJob(job, makeOutput(output)); + completeJob(job, ToolResult{true, output}); }); } QString WebFetchTool::saveToFile( const Job& job, const QString& content, const QString& mime) const { - QDir dir(StoragePath); + QDir dir(job.storagePath); if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) return QString(); QString fileName; diff --git a/Plugins/ZShell/Llm/webfetchtool.hpp b/Plugins/ZShell/Llm/webfetchtool.hpp index c992c77..0a47ac3 100644 --- a/Plugins/ZShell/Llm/webfetchtool.hpp +++ b/Plugins/ZShell/Llm/webfetchtool.hpp @@ -20,19 +20,17 @@ class WebFetchTool : public LlmTool { static constexpr int MaxResponseBytes = 5 * 1024 * 1024; static constexpr int DefaultTimeoutSeconds = 30; static constexpr int MaxTimeoutSeconds = 120; - static const QString StoragePath; - explicit WebFetchTool(QObject* parent = nullptr); + explicit WebFetchTool(ToolRegistry* registry, QObject* parent = nullptr); ~WebFetchTool() override; QString name() const override; QString description() const override; QJsonObject parameters() const override; void execute( - const QString& toolCallId, + const ToolContext& context, const QJsonObject& args, - int outputBudgetChars, - std::function done) override; + ToolDone done) override; void cancel() override; static QString extractTextFromHtml(const QString& html); @@ -46,11 +44,12 @@ class WebFetchTool : public LlmTool { bool tooLarge = false; QString format; QString toolCallId; + QString storagePath; int budgetChars = 0; - std::function done; + ToolDone done; }; - void completeJob(Job* job, QJsonObject result); + void completeJob(Job* job, ToolResult result); QString saveToFile( const Job& job, const QString& content, const QString& mime) const; diff --git a/Plugins/ZShell/Llm/workspace.cpp b/Plugins/ZShell/Llm/workspace.cpp new file mode 100644 index 0000000..6dc22df --- /dev/null +++ b/Plugins/ZShell/Llm/workspace.cpp @@ -0,0 +1,89 @@ +#include "workspace.hpp" + +#include "session.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ZShell::llm { + +Workspace::Workspace(ChatSession* session, QObject* parent) + : QObject(parent), m_session(session) { + m_path = pathFor(session->id()); +} + +QString Workspace::pathFor(const QString& sessionId) { + const QString state = + QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation); + return state + QStringLiteral("/zshell/sandboxes/") + sessionId; +} + +bool Workspace::ensure() { + return QDir().mkpath(m_path); +} + +QList Workspace::importFiles( + const QStringList& paths, QString& error) { + error.clear(); + if (!ensure()) { + error = QStringLiteral("Could not create the sandbox directory"); + return {}; + } + + QMimeDatabase mimeDatabase; + QList result; + for (const QString& rawPath : paths) { + const QFileInfo info(rawPath); + if (!info.exists() || !info.isFile()) { + qWarning() << "Workspace: skipping" << rawPath + << ": file not found or not a file"; + error = QStringLiteral("File not found: %1").arg(info.fileName()); + continue; + } + + const QString mimeType = mimeDatabase.mimeTypeForFile(info).name(); + const bool isImage = + mimeType.startsWith(QLatin1String("image/"), Qt::CaseInsensitive); + const qint64 cap = isImage ? MaxImageBytes : MaxFileBytes; + if (info.size() > cap) { + qWarning() << "Workspace: skipping" << rawPath << ": too large (" + << info.size() << "bytes, cap" << cap << ")"; + error = QStringLiteral("File too large: %1").arg(info.fileName()); + continue; + } + + QString destination = m_path + QLatin1Char('/') + info.fileName(); + int suffix = 1; + while (QFile::exists(destination)) { + const QString extension = info.suffix().isEmpty() + ? QString() + : QLatin1Char('.') + info.suffix(); + destination = m_path + QLatin1Char('/') + info.completeBaseName() + + QStringLiteral("_%1%2").arg(suffix).arg(extension); + ++suffix; + } + + if (!QFile::copy(info.absoluteFilePath(), destination)) { + qWarning() << "Workspace: skipping" << rawPath << ": copy to" + << destination << "failed"; + error = + QStringLiteral("Could not copy file: %1").arg(info.fileName()); + continue; + } + + result.append(new Attachment( + info.fileName(), + destination, + isImage ? Attachment::Kind::Image : Attachment::Kind::File, + mimeType, + info.size(), + m_session)); + } + return result; +} + +} // namespace ZShell::llm diff --git a/Plugins/ZShell/Llm/workspace.hpp b/Plugins/ZShell/Llm/workspace.hpp new file mode 100644 index 0000000..e5739e9 --- /dev/null +++ b/Plugins/ZShell/Llm/workspace.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "attachment.hpp" + +#include +#include +#include + +namespace ZShell::llm { + +class ChatSession; + +class Workspace : public QObject { + Q_OBJECT + + public: + explicit Workspace(ChatSession* session, QObject* parent = nullptr); + + [[nodiscard]] QString path() const { return m_path; } + [[nodiscard]] static QString pathFor(const QString& sessionId); + + [[nodiscard]] bool ensure(); + + [[nodiscard]] QList importFiles( + const QStringList& paths, QString& error); + + static constexpr qint64 MaxImageBytes = 20 * 1024 * 1024; + static constexpr qint64 MaxFileBytes = 100 * 1024 * 1024; + + private: + ChatSession* m_session; + QString m_path; +}; + +} // namespace ZShell::llm From 0a503bc27c53be21ed9d7204d1f26b2357e0110a Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 18 Oct 2026 13:03:25 +0200 Subject: [PATCH 88/90] fix: remove unimplemented method --- Plugins/ZShell/Llm/llmclient.hpp | 1 - 1 file changed, 1 deletion(-) diff --git a/Plugins/ZShell/Llm/llmclient.hpp b/Plugins/ZShell/Llm/llmclient.hpp index a9430ed..58f457a 100644 --- a/Plugins/ZShell/Llm/llmclient.hpp +++ b/Plugins/ZShell/Llm/llmclient.hpp @@ -70,7 +70,6 @@ class LlmClient : public QObject { Q_INVOKABLE void selectModel(const QString& id); Q_INVOKABLE void stop(); Q_INVOKABLE void refreshModels(); - Q_INVOKABLE void refreshFromServer(); void startGeneration(ChatSession* session, ChatGeneration* target); void approveTools(ZShell::llm::ChatGeneration* generation); From 08d42e1f8aceaec40b0c85ba01be65689b1d9beb Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 18 Oct 2026 15:23:31 +0200 Subject: [PATCH 89/90] chore: formatter & clangd lsp option --- .clangd | 5 +++ CMakeLists.txt | 13 ++++++-- Plugins/ZShell/Blobs/blobrect.hpp | 10 ++---- Plugins/ZShell/Blobs/blobshape.cpp | 16 ++++------ Plugins/ZShell/Components/carouselview.cpp | 22 ++++++------- Plugins/ZShell/Components/chatlistview.cpp | 4 --- .../ZShell/Components/sparklinecontent.cpp | 6 ++-- .../ZShell/Components/sparklinesceneitem.cpp | 26 ++++++++------- Plugins/ZShell/Components/wavyline.hpp | 2 +- Plugins/ZShell/HyprPlugins/main.cpp | 32 +++++++------------ Plugins/ZShell/Internal/hyprextras.cpp | 8 ++--- Plugins/ZShell/Internal/strokecanvasitem.cpp | 2 +- .../ZShell/Internal/strokecanvasrenderer.cpp | 2 +- Plugins/ZShell/Llm/filetool.cpp | 2 +- Plugins/cmake/pch.cmake | 1 + 15 files changed, 73 insertions(+), 78 deletions(-) create mode 100644 .clangd diff --git a/.clangd b/.clangd new file mode 100644 index 0000000..ddd9476 --- /dev/null +++ b/.clangd @@ -0,0 +1,5 @@ +CompileFlags: + Remove: + # GCC-only flags from the system's default spec that clang/clangd cannot + # parse. They appear in compile_commands.json but are not set by the project. + - -mno-direct-extern-access diff --git a/CMakeLists.txt b/CMakeLists.txt index fc9f50a..b091ec4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -61,10 +61,12 @@ set(CMAKE_INSTALL_MESSAGE NEVER) add_compile_options( -Wall -Wextra -Wpedantic -Wshadow -Wconversion - -Wold-style-cast -Wnull-dereference -Wdouble-promotion - -Wformat=2 -Wfloat-equal -Woverloaded-virtual + -Wnull-dereference -Wdouble-promotion + -Wformat=2 -Wfloat-equal -Wsign-conversion -Wredundant-decls -Wswitch -Wunreachable-code + $<$:-Wold-style-cast> + $<$:-Woverloaded-virtual> ) @@ -188,4 +190,11 @@ if("m3shapes" IN_LIST ENABLE_MODULES) if(TARGET m3shapesplugin) set_target_properties(m3shapesplugin PROPERTIES INSTALL_RPATH "$ORIGIN") endif() + + # Silence warnings that come from m3shapes files we do not manage. + foreach(m3shapes_target m3shapes_core m3shapes_morph m3shapes_shapes m3shapes m3shapesplugin) + if(TARGET ${m3shapes_target}) + target_compile_options(${m3shapes_target} PRIVATE -Wno-shadow -Wno-float-equal) + endif() + endforeach() endif() diff --git a/Plugins/ZShell/Blobs/blobrect.hpp b/Plugins/ZShell/Blobs/blobrect.hpp index 68df1c3..967e339 100644 --- a/Plugins/ZShell/Blobs/blobrect.hpp +++ b/Plugins/ZShell/Blobs/blobrect.hpp @@ -50,9 +50,9 @@ class BlobRect : public BlobShape { [[nodiscard]] qreal damping() const { return m_damping; } - void setDamping(qreal d) { - if (!qFuzzyCompare(m_damping, d)) { - m_damping = d; + void setDamping(qreal value) { + if (!qFuzzyCompare(m_damping, value)) { + m_damping = value; emit dampingChanged(); } } @@ -110,7 +110,6 @@ class BlobRect : public BlobShape { void checkAtRest(float speed); void beginRetire(); - // Physics state QPointF m_prevScenePos; QElapsedTimer m_elapsed; bool m_physicsActive = false; @@ -122,13 +121,10 @@ class BlobRect : public BlobShape { float m_retire01 = 0.0f; float m_retire11 = 1.0f; - // Symmetric 2x2 deformation matrix components (3 independent: m00, m01, - // m11) Rest state is identity: m00=1, m01=0, m11=1 float m_dm00 = 1.0f; float m_dm01 = 0.0f; float m_dm11 = 1.0f; - // Spring velocities for each component float m_dmVel00 = 0.0f; float m_dmVel01 = 0.0f; float m_dmVel11 = 0.0f; diff --git a/Plugins/ZShell/Blobs/blobshape.cpp b/Plugins/ZShell/Blobs/blobshape.cpp index e33b2f3..c73d02c 100644 --- a/Plugins/ZShell/Blobs/blobshape.cpp +++ b/Plugins/ZShell/Blobs/blobshape.cpp @@ -168,10 +168,6 @@ void BlobShape::updatePolish() { if (other->width() <= 0 || other->height() <= 0) continue; - // Excluded shapes stay in the rect data. Exclusion only gates the smooth - // blend (via excludeMask below); ownership must still see every shape, - // otherwise two nodes can both believe they own a pixel and the pixel is - // blended twice. const QPointF otherScene = other->mapToScene(QPointF(0, 0)); bool include = false; @@ -196,7 +192,7 @@ void BlobShape::updatePolish() { const QMatrix4x4& dm = other->m_deformMatrix; const float a = dm(0, 0), b = dm(1, 0); - const float c = dm(0, 1), d = dm(1, 1); + const float c = dm(0, 1), e = dm(1, 1); BlobRectData r; r.cx = static_cast(otherScene.x() + other->width() / 2.0); @@ -207,19 +203,19 @@ void BlobShape::updatePolish() { r.offsetX = dm(0, 3); r.offsetY = dm(1, 3); - const float det = a * d - c * b; + const float det = a * e - c * b; const float invDet = std::abs(det) > 1e-6f ? 1.0f / det : 1.0f; - r.invDeform[0] = d * invDet; + r.invDeform[0] = e * invDet; r.invDeform[1] = -b * invDet; r.invDeform[2] = -c * invDet; r.invDeform[3] = a * invDet; - const float halfTr = 0.5f * (a + d); - const float halfDiff = 0.5f * (a - d); + const float halfTr = 0.5f * (a + e); + const float halfDiff = 0.5f * (a - e); r.minEig = halfTr - std::sqrt(halfDiff * halfDiff + c * c); r.screenHalfX = std::abs(a) * r.hw + std::abs(c) * r.hh; - r.screenHalfY = std::abs(b) * r.hw + std::abs(d) * r.hh; + r.screenHalfY = std::abs(b) * r.hw + std::abs(e) * r.hh; m_cachedRects.append(r); rectShapes.append(other); diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp index 0a5aa66..da2842a 100644 --- a/Plugins/ZShell/Components/carouselview.cpp +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -187,7 +187,7 @@ void CarouselView::setModel(const QVariantList& m) { emit modelChanged(); - const int newCount = m_model.size(); + const int newCount = static_cast(m_model.size()); if (newCount == 0) { m_currentRealIndex = -1; @@ -647,7 +647,7 @@ qreal CarouselView::sampleSize(qreal childLoc) const { } int CarouselView::realIndexOf(int virtualIndex) const { - const int count = m_model.size(); + const int count = static_cast(m_model.size()); if (count == 0) return -1; @@ -668,7 +668,7 @@ qreal CarouselView::snapTargetX(qreal x) const { } void CarouselView::wrapContentIfNeeded() { - const int count = m_model.size(); + const int count = static_cast(m_model.size()); if (count == 0) return; @@ -707,7 +707,7 @@ void CarouselView::wrapContentIfNeeded() { } void CarouselView::updateCurrentIndexFromContentX(bool emitPreview) { - const int count = m_model.size(); + const int count = static_cast(m_model.size()); if (count == 0) return; @@ -856,8 +856,8 @@ void CarouselView::positionSlot(const Slot& slot) const { return rightIndex == n ? m_arrangement.outerSpacing : m_itemSpacing; }; - const auto leftGap = [this, n](int leftIndex) { - return leftIndex == -n ? m_arrangement.outerSpacing : m_itemSpacing; + const auto leftGap = [this, n](int gapIndex) { + return gapIndex == -n ? m_arrangement.outerSpacing : m_itemSpacing; }; qreal center = 0; @@ -974,7 +974,7 @@ void CarouselView::releaseAllItems() { } void CarouselView::relayout() { - const int count = m_model.size(); + const int count = static_cast(m_model.size()); if (count == 0 || width() <= 0 || m_focalWidth <= 0) { for (Slot& s : m_slots) { @@ -1038,7 +1038,7 @@ void CarouselView::relayout() { m_slots.append(Slot{}); slotUsedThisPass.append(false); - freeSlot = m_slots.size() - 1; + freeSlot = static_cast(m_slots.size()) - 1; } updateSlotContent(m_slots[freeSlot], v); @@ -1092,7 +1092,7 @@ void CarouselView::snapToNearest() { } void CarouselView::goToIndex(int realIndex) { - const int count = m_model.size(); + const int count = static_cast(m_model.size()); if (count == 0) return; @@ -1126,7 +1126,7 @@ void CarouselView::decrementCurrentIndex() { } void CarouselView::centerInstantlyOnReal(int realIndex) { - const int count = m_model.size(); + const int count = static_cast(m_model.size()); if (count == 0) return; @@ -1220,7 +1220,7 @@ qreal CarouselView::sampledVelocity() const { const VelocitySample& oldest = m_velocitySamples.first(); const VelocitySample& newest = m_velocitySamples.last(); - const qreal dt = newest.t - oldest.t; + const qreal dt = static_cast(newest.t - oldest.t); if (dt < 8) return 0; diff --git a/Plugins/ZShell/Components/chatlistview.cpp b/Plugins/ZShell/Components/chatlistview.cpp index 56ff397..90e48bf 100644 --- a/Plugins/ZShell/Components/chatlistview.cpp +++ b/Plugins/ZShell/Components/chatlistview.cpp @@ -252,8 +252,6 @@ int ChatListView::count() const { } bool ChatListView::event(QEvent* e) { - // qDebug() << "ChatListView::event() runs"; - return QQuickItem::event(e); } @@ -366,7 +364,6 @@ void ChatListView::updatePolish() { m_animateY = false; const int layoutSize = static_cast(m_layout.size()); const qint64 now = m_clock.elapsed(); - bool wrote = false; for (auto& entry : m_delegates) { if (!entry.item || entry.pendingRemoval || entry.pendingInsert) continue; @@ -397,7 +394,6 @@ void ChatListView::updatePolish() { entry.item->setY(target); entry.hasPropY = false; } - wrote = true; } applyAnchor(); diff --git a/Plugins/ZShell/Components/sparklinecontent.cpp b/Plugins/ZShell/Components/sparklinecontent.cpp index 8d51704..97b4dd5 100644 --- a/Plugins/ZShell/Components/sparklinecontent.cpp +++ b/Plugins/ZShell/Components/sparklinecontent.cpp @@ -50,8 +50,6 @@ void SparklineContent::setLine2(internal::CircularBuffer* buffer) { void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) { if (!buffer) return; - // New data, or the buffer's own smoothingRadius changed (it re-emits - // valuesChanged in that case too) -> geometry is stale. connect( buffer, &internal::CircularBuffer::valuesChanged, @@ -67,7 +65,7 @@ void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) { void SparklineContent::setLine1Color(const QColor& color) { if (m_line1Color == color) return; m_line1Color = color; - update(); // visual-only, no geometry rebuild needed + update(); } void SparklineContent::setLine2Color(const QColor& color) { @@ -173,7 +171,7 @@ SparklineContent::LineGeometry SparklineContent::buildGeometry( if (m_curveSmoothing && points.size() > 2) { const qreal tension = 1.0; const qreal k = tension / 6.0; - const int n = points.size(); + const int n = static_cast(points.size()); for (int i = 0; i < n - 1; ++i) { const QPointF& p0 = points[qMax(i - 1, 0)]; diff --git a/Plugins/ZShell/Components/sparklinesceneitem.cpp b/Plugins/ZShell/Components/sparklinesceneitem.cpp index 42170e3..eedac27 100644 --- a/Plugins/ZShell/Components/sparklinesceneitem.cpp +++ b/Plugins/ZShell/Components/sparklinesceneitem.cpp @@ -84,7 +84,6 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample( points.reserve(len); for (int i = 0; i < len; ++i) { - // Already smoothed once, incrementally, by the buffer itself. const qreal value = qBound(0.0, buffer->smoothedAt(i), m_maxValue); @@ -99,7 +98,7 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample( if (m_curveSmoothing && points.size() > 2) { const qreal tension = 1.0; const qreal k = tension / 6.0; - const int n = points.size(); + const int n = static_cast(points.size()); for (int i = 0; i < n - 1; ++i) { const QPointF& p0 = points[qMax(i - 1, 0)]; const QPointF& p1 = points[i]; @@ -125,9 +124,6 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample( } } - // Flatten the curve to a polyline once, here, on data change only. - // This is CPU math only -- no QPainter draw calls, no image, no - // texture upload happen as a result of this. const auto subpaths = path.toSubpathPolygons(); if (subpaths.isEmpty()) return sample; @@ -139,7 +135,7 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample( void SparklineSceneItem::writeFillGeometry( QSGGeometryNode* node, const LineSample& sample) { - const int n = sample.polyline.size(); + const int n = static_cast(sample.polyline.size()); auto* geometry = node->geometry(); geometry->allocate(n * 2); geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); @@ -147,8 +143,10 @@ void SparklineSceneItem::writeFillGeometry( auto* vertices = geometry->vertexDataAsPoint2D(); for (int i = 0; i < n; ++i) { const QPointF& p = sample.polyline[i]; - vertices[i * 2].set(p.x(), p.y()); - vertices[i * 2 + 1].set(p.x(), sample.fillBottom); + vertices[i * 2].set( + static_cast(p.x()), static_cast(p.y())); + vertices[i * 2 + 1].set( + static_cast(p.x()), static_cast(sample.fillBottom)); } node->markDirty(QSGNode::DirtyGeometry); @@ -156,7 +154,7 @@ void SparklineSceneItem::writeFillGeometry( void SparklineSceneItem::writeStrokeGeometry( QSGGeometryNode* node, const LineSample& sample, qreal lineWidth) { - const int n = sample.polyline.size(); + const int n = static_cast(sample.polyline.size()); auto* geometry = node->geometry(); geometry->allocate(n * 2); geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); @@ -179,8 +177,12 @@ void SparklineSceneItem::writeStrokeGeometry( const QPointF& p = sample.polyline[i]; const QPointF offset = normal * half; - vertices[i * 2].set(p.x() + offset.x(), p.y() + offset.y()); - vertices[i * 2 + 1].set(p.x() - offset.x(), p.y() - offset.y()); + vertices[i * 2].set( + static_cast(p.x() + offset.x()), + static_cast(p.y() + offset.y())); + vertices[i * 2 + 1].set( + static_cast(p.x() - offset.x()), + static_cast(p.y() - offset.y())); } node->markDirty(QSGNode::DirtyGeometry); @@ -243,7 +245,7 @@ QSGNode* SparklineSceneItem::updatePaintNode( } QMatrix4x4 matrix; - matrix.translate(-stepX() * m_slideProgress, 0); + matrix.translate(static_cast(-stepX() * m_slideProgress), 0.0f); transformNode->setMatrix(matrix); const bool needsGeometryWrite = samplesRebuilt || justCreated; diff --git a/Plugins/ZShell/Components/wavyline.hpp b/Plugins/ZShell/Components/wavyline.hpp index 5ce3b8e..4025664 100644 --- a/Plugins/ZShell/Components/wavyline.hpp +++ b/Plugins/ZShell/Components/wavyline.hpp @@ -85,7 +85,7 @@ class WavyLine : public QCanvasPainterItem { [[nodiscard]] qreal value() const; void setValue(qreal value); -signals: + signals: void lineWidthChanged(); void amplitudeMultiplierChanged(); void frequencyChanged(); diff --git a/Plugins/ZShell/HyprPlugins/main.cpp b/Plugins/ZShell/HyprPlugins/main.cpp index 39953e9..8e8f2e4 100644 --- a/Plugins/ZShell/HyprPlugins/main.cpp +++ b/Plugins/ZShell/HyprPlugins/main.cpp @@ -37,12 +37,10 @@ static std::filesystem::path g_runtimeDir{"/tmp"}; static std::filesystem::path g_cacheBase{"/tmp/.cache"}; static void initEnvPaths() { - if (const char* env = getenv("XDG_RUNTIME_DIR")) - g_runtimeDir = env; + if (const char* env = getenv("XDG_RUNTIME_DIR")) g_runtimeDir = env; if (const char* env = getenv("XDG_CACHE_HOME")) { - if (*env) - g_cacheBase = env; + if (*env) g_cacheBase = env; } else if (const char* home = getenv("HOME")) { g_cacheBase = std::filesystem::path(home) / ".cache"; } @@ -50,8 +48,7 @@ static void initEnvPaths() { static std::ofstream& debugLog() { static std::ofstream log( - g_runtimeDir / "zshell-nightlight-debug.log", - std::ios::app); + g_runtimeDir / "zshell-nightlight-debug.log", std::ios::app); return log; } @@ -173,15 +170,14 @@ static bool hkSaveBufferForMirror(CHyprOpenGLImpl* thisptr, const CBox& box) { static void installShader() { auto* gl = g_pHyprOpenGL.get(); - if (!gl) - return; + if (!gl) return; ensureShaderFile(shaderPath(), defaultShaderSource()); gl->applyScreenShader(shaderPath().string()); auto* shader = gl->m_finalScreenShader.get(); if (shader && shader->program() >= 1 && - shader->getUniformLocation(SHADER_TINT) != -1) { + shader->getUniformLocation(SHADER_TINT) != -1) { g_uniformMode = true; } else { g_uniformMode = false; @@ -196,8 +192,7 @@ static void installShader() { static void setUniformGain(const std::array& gain) { auto* gl = g_pHyprOpenGL.get(); auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr; - if (!gl || !shader) - return; + if (!gl || !shader) return; gl->useShader(gl->m_finalScreenShader); shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]); @@ -214,8 +209,7 @@ static void hkBegin( if (fb) { auto* renderer = g_pHyprRenderer.get(); - if (renderer) - renderer->m_renderData.blockScreenShader = true; + if (renderer) renderer->m_renderData.blockScreenShader = true; } if (!g_active) return; @@ -223,17 +217,15 @@ static void hkBegin( auto* gl = g_pHyprOpenGL.get(); if (!gl) return; - if (gl->m_finalScreenShader.get() && - gl->m_finalScreenShader->program() < 1) + if (gl->m_finalScreenShader.get() && gl->m_finalScreenShader->program() < 1) installShader(); if (!gl->m_finalScreenShader.get() || - gl->m_finalScreenShader->program() < 1) + gl->m_finalScreenShader->program() < 1) return; if (g_transition.active) { auto* monPtr = mon.get(); - if (!monPtr) - return; + if (!monPtr) return; const float elapsed = std::chrono::duration( std::chrono::steady_clock::now() - g_transition.start) @@ -255,9 +247,9 @@ static void hkBegin( ensureShaderFile( bakedShaderPath(g_currentGain), bakedShaderSource(g_currentGain)); - gl->applyScreenShader(bakedShaderPath(g_currentGain).string()); + gl->applyScreenShader(bakedShaderPath(g_currentGain).string()); + } } - } if (t >= 1.f) { g_transition.active = false; diff --git a/Plugins/ZShell/Internal/hyprextras.cpp b/Plugins/ZShell/Internal/hyprextras.cpp index fd28611..3baf4dc 100644 --- a/Plugins/ZShell/Internal/hyprextras.cpp +++ b/Plugins/ZShell/Internal/hyprextras.cpp @@ -182,10 +182,10 @@ static QString buildHlConfigCall(const QString& key, const QVariant& value) { } static QColor colorFromInt(quint32 value) { - const int a = (value >> 24) & 0xFF; - const int r = (value >> 16) & 0xFF; - const int g = (value >> 8) & 0xFF; - const int b = value & 0xFF; + const int a = static_cast(value >> 24) & 0xFF; + const int r = static_cast(value >> 16) & 0xFF; + const int g = static_cast(value >> 8) & 0xFF; + const int b = static_cast(value) & 0xFF; return QColor(r, g, b, a); } diff --git a/Plugins/ZShell/Internal/strokecanvasitem.cpp b/Plugins/ZShell/Internal/strokecanvasitem.cpp index dcb7e47..50bc676 100644 --- a/Plugins/ZShell/Internal/strokecanvasitem.cpp +++ b/Plugins/ZShell/Internal/strokecanvasitem.cpp @@ -72,7 +72,7 @@ static QCanvasPath buildStrokePath( void StrokeCanvasItem::undoLastGroup() { if (m_strokes.isEmpty()) return; const int lastGroup = m_strokes.last().groupId; - for (int i = m_strokes.size() - 1; + for (int i = static_cast(m_strokes.size()) - 1; i >= 0 && m_strokes[i].groupId == lastGroup; --i) m_strokes.remove(i); diff --git a/Plugins/ZShell/Internal/strokecanvasrenderer.cpp b/Plugins/ZShell/Internal/strokecanvasrenderer.cpp index c109728..11b2d08 100644 --- a/Plugins/ZShell/Internal/strokecanvasrenderer.cpp +++ b/Plugins/ZShell/Internal/strokecanvasrenderer.cpp @@ -154,7 +154,7 @@ void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem* item) { for (const auto& stroke : canvas->m_strokes) canvasGroupIds.insert(stroke.groupId); - for (int i = m_strokes.size() - 1; i >= 0; --i) { + for (int i = static_cast(m_strokes.size()) - 1; i >= 0; --i) { if (!canvasGroupIds.contains(m_strokes[i].groupId)) { if (m_strokes[i].groupId >= 0) m_pendingGroupRemovals.append(m_strokes[i].groupId); diff --git a/Plugins/ZShell/Llm/filetool.cpp b/Plugins/ZShell/Llm/filetool.cpp index c58ad82..2e368b9 100644 --- a/Plugins/ZShell/Llm/filetool.cpp +++ b/Plugins/ZShell/Llm/filetool.cpp @@ -119,7 +119,7 @@ void FileReadTool::execute( return; } const QString head = content.left(limit); - const int nextOffset = offset + head.toUtf8().size(); + const int nextOffset = offset + static_cast(head.toUtf8().size()); const QString output = head + QStringLiteral( diff --git a/Plugins/cmake/pch.cmake b/Plugins/cmake/pch.cmake index c5685fb..814d6b0 100644 --- a/Plugins/cmake/pch.cmake +++ b/Plugins/cmake/pch.cmake @@ -1,4 +1,5 @@ add_library(zshell-pch INTERFACE) +target_compile_options(zshell-pch INTERFACE $<$:-Wno-null-dereference>) target_precompile_headers(zshell-pch INTERFACE From 6781078467d8233099620c69b4d1bb9ccc337a34 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 19 Oct 2026 19:21:57 +0200 Subject: [PATCH 90/90] fix: short nightlight fade duration if missed start or end time or if manually activated --- Helpers/Nightlight.qml | 2 +- Modules/Settings/Pages/WallpaperPage.qml | 2 +- Plugins/ZShell/Services/nightlightmanager.cpp | 25 ++++++++++--------- Plugins/ZShell/Services/nightlightmanager.hpp | 6 ++--- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/Helpers/Nightlight.qml b/Helpers/Nightlight.qml index 29857f3..e64958d 100644 --- a/Helpers/Nightlight.qml +++ b/Helpers/Nightlight.qml @@ -23,7 +23,7 @@ Singleton { function toggleNightLight(): void { service.manualToggle = true; - service.toggle(); + service.toggle(true); } NightlightManager { diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml index 570019f..f3fcb6e 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -199,7 +199,7 @@ PageBase { stepSize: 1 subtext: qsTr("How long it takes for the gradual nightlight to finish (s)") text: qsTr("Fade duration") - to: 60 * 5 + to: 60 * 20 value: Config.display.nightlight.fadeDuration / 1000 onMoved: v => Config.display.nightlight.fadeDuration = v * 1000 diff --git a/Plugins/ZShell/Services/nightlightmanager.cpp b/Plugins/ZShell/Services/nightlightmanager.cpp index ea50b1d..a155c84 100644 --- a/Plugins/ZShell/Services/nightlightmanager.cpp +++ b/Plugins/ZShell/Services/nightlightmanager.cpp @@ -129,15 +129,15 @@ void NightlightManager::setFadeDuration(double duration) { emit fadeDurationChanged(); } -void NightlightManager::toggle() { +void NightlightManager::toggle(bool fast) { if (m_enabled) { - end(); + end(fast ? 2 : m_fadeDuration); } else { - start(); + start(fast ? 2 : m_fadeDuration); } } -void NightlightManager::start() { +void NightlightManager::start(double fade) { if (m_enabled && m_initialized) return; m_initialized = true; @@ -149,9 +149,7 @@ void NightlightManager::start() { m_process.setProgram("hyprctl"); m_process.setArguments( {"dispatch", - QString("hl.plugin.zshell.nlSet(%1, %2)") - .arg(m_temp) - .arg(m_fadeDuration)}); + QString("hl.plugin.zshell.nlSet(%1, %2)").arg(m_temp).arg(fade)}); m_process.startDetached(); } else { m_process.setProgram("hyprctl"); @@ -161,7 +159,7 @@ void NightlightManager::start() { } } -void NightlightManager::end() { +void NightlightManager::end(double fade) { if (!m_enabled && m_initialized) return; m_initialized = true; @@ -172,8 +170,7 @@ void NightlightManager::end() { if (m_useNativeNightlight) { m_process.setProgram("hyprctl"); m_process.setArguments( - {"dispatch", - QString("hl.plugin.zshell.nlDisable(%1)").arg(m_fadeDuration)}); + {"dispatch", QString("hl.plugin.zshell.nlDisable(%1)").arg(fade)}); m_process.startDetached(); } else { m_process.setProgram("hyprctl"); @@ -195,10 +192,14 @@ void NightlightManager::apply() { isDarkTime = (currentMin >= m_startTime || currentMin < m_endTime); } + const auto activationTime = isDarkTime ? m_startTime : m_endTime; + const auto elapsed = (currentMin - activationTime + 1440) % 1440; + const bool pastActivation = elapsed > 20; + if (isDarkTime) { - start(); + start(pastActivation ? 2 : m_fadeDuration); } else { - end(); + end(pastActivation ? 2 : m_fadeDuration); } } diff --git a/Plugins/ZShell/Services/nightlightmanager.hpp b/Plugins/ZShell/Services/nightlightmanager.hpp index 43b341d..c12f3ca 100644 --- a/Plugins/ZShell/Services/nightlightmanager.hpp +++ b/Plugins/ZShell/Services/nightlightmanager.hpp @@ -54,7 +54,7 @@ class NightlightManager : public QObject { [[nodiscard]] int lastTemp() const; [[nodiscard]] int currentTemp() const; - Q_INVOKABLE void toggle(); + Q_INVOKABLE void toggle(bool fast); Q_INVOKABLE void apply(); void setStartTime(const int& time); @@ -105,8 +105,8 @@ class NightlightManager : public QObject { QByteArray m_socketBuffer; QTimer m_reconnectTimer; - void start(); - void end(); + void start(double fade); + void end(double fade); [[nodiscard]] QString hyprlandDir() const; [[nodiscard]] QString eventSocketPath() const; [[nodiscard]] QString resolvePluginPath() const;