11 Commits
Author SHA1 Message Date
AramJonghu c9f7856982 fix(Docker): add required deps to dockerfile
JS/TS / fmt (pull_request) Successful in 11s
JS/TS / lint (pull_request) Successful in 11s
Python / static (pull_request) Successful in 45s
Rust / fmt (pull_request) Successful in 2m0s
Rust / build (pull_request) Successful in 2m24s
Rust / clippy (pull_request) Successful in 2m24s
Python / verify (pull_request) Successful in 2m48s
C++ / fmt (pull_request) Successful in 4s
C++ / clang-tidy (pull_request) Canceled after 5m34s
C++ / build (pull_request) Canceled after 0s
2026-08-31 22:46:42 +02:00
zachandAramJonghu 51dc0fc026 chore: format python codeicons 2026-08-31 22:44:57 +02:00
zach cef91caebc chore: format llm c files
C++ / fmt (pull_request) Successful in 16s
C++ / build (pull_request) Failing after 19s
JS/TS / fmt (pull_request) Successful in 10s
C++ / clang-tidy (pull_request) Failing after 29s
JS/TS / lint (pull_request) Successful in 23s
Python / static (pull_request) Failing after 48s
Rust / fmt (pull_request) Successful in 1m15s
Rust / build (pull_request) Successful in 1m46s
Rust / clippy (pull_request) Successful in 1m33s
Python / verify (pull_request) Successful in 2m16s
2026-08-31 19:06:49 +02:00
zach c5a089adaf chore: format llm c files
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 22s
JS/TS / lint (pull_request) Successful in 16s
Python / static (pull_request) Failing after 37s
C++ / clang-tidy (pull_request) Failing after 1m19s
Rust / fmt (pull_request) Successful in 1m24s
Rust / clippy (pull_request) Successful in 1m59s
Rust / build (pull_request) Successful in 2m12s
Python / verify (pull_request) Successful in 2m48s
2026-08-31 19:05:22 +02:00
zach 52feb6006a chore: format llm c files
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 9s
JS/TS / lint (pull_request) Successful in 9s
JS/TS / fmt (pull_request) Successful in 16s
C++ / clang-tidy (pull_request) Failing after 43s
Python / static (pull_request) Failing after 57s
Rust / fmt (pull_request) Successful in 1m30s
Rust / build (pull_request) Successful in 2m5s
Rust / clippy (pull_request) Successful in 1m55s
Python / verify (pull_request) Successful in 2m23s
2026-08-31 19:02:26 +02:00
zach dbb51ceadd feat: config object migration path and add new options to settings
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 12s
JS/TS / fmt (pull_request) Successful in 15s
JS/TS / lint (pull_request) Successful in 15s
C++ / clang-tidy (pull_request) Failing after 33s
Python / static (pull_request) Failing after 36s
Rust / fmt (pull_request) Successful in 1m14s
Rust / build (pull_request) Successful in 1m49s
Rust / clippy (pull_request) Successful in 1m33s
Python / verify (pull_request) Successful in 2m10s
2026-08-31 18:43:41 +02:00
zach 487615f482 fix: shader fade animation linear curve 2026-08-31 14:09:54 +02:00
zach d4e936a7e2 add nightlight hyprland plugin + rename hyprsunsetmanager -> nightlightmanager to control both 2026-08-31 13:53:37 +02:00
zach 703d804746 fab menu and model selector 2026-08-29 18:56:59 +02:00
zach 327270f3fa better support for opening chat in floating window 2026-08-28 14:15:57 +02:00
zach 8a66c3b064 expandable dialogs for color scheme, fonts and scheduling modes 2026-08-27 16:57:02 +02:00
86 changed files with 4525 additions and 2763 deletions
-1
View File
@@ -30,7 +30,6 @@ Flickable {
onTriggered: { onTriggered: {
root.doneFakeFlick = false; root.doneFakeFlick = false;
root.returnToBounds();
} }
} }
} }
+3 -3
View File
@@ -12,7 +12,7 @@ ScrollBar {
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight 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 axisLength: isHorizontal ? root.width : root.height
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize) readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize) readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
@@ -102,9 +102,9 @@ ScrollBar {
var newPos = contentPosFromThumbStart(thumbStart); var newPos = contentPosFromThumbStart(thumbStart);
if (root.isHorizontal) if (root.isHorizontal)
root.flickable.contentX = newPos; root.flickable.contentX = newPos + root.flickable.originX;
else else
root.flickable.contentY = newPos; root.flickable.contentY = newPos + root.flickable.originY;
} }
function visualThumbStart() { function visualThumbStart() {
+5 -10
View File
@@ -13,21 +13,17 @@ import qs.Paths
Singleton { Singleton {
id: root id: root
readonly property M3Palette current: M3Palette { readonly property M3Palette current: M3Palette {}
}
property bool currentLight property bool currentLight
property string flavour property string flavour
readonly property bool light: showPreview ? previewLight : currentLight readonly property bool light: showPreview ? previewLight : currentLight
readonly property M3Palette palette: showPreview ? preview : current readonly property M3Palette palette: showPreview ? preview : current
readonly property M3Palette preview: M3Palette { readonly property M3Palette preview: M3Palette {}
}
property bool previewLight property bool previewLight
property string scheme property string scheme
property bool showPreview property bool showPreview
readonly property M3TPalette tPalette: M3TPalette { readonly property M3TPalette tPalette: M3TPalette {}
} readonly property Transparency transparency: Transparency {}
readonly property Transparency transparency: Transparency {
}
readonly property alias wallLuminance: analyser.luminance readonly property alias wallLuminance: analyser.luminance
function alterColor(c: color, a: real, layer: int): color { function alterColor(c: color, a: real, layer: int): color {
@@ -112,8 +108,7 @@ Singleton {
function setMode(mode: string): void { function setMode(mode: string): void {
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]); Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]);
Config.general.color.mode = mode; Config.colors.mode = mode;
Config.save();
} }
function swapRG(c: color): color { function swapRG(c: color): color {
+8 -6
View File
@@ -9,13 +9,13 @@ Singleton {
id: root id: root
readonly property bool enabled: service.enabled 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 property bool manualToggle: false
readonly property int start: Config.general.color.scheduleHyprsunsetStart readonly property int start: Config.display.nightlight.scheduleStart
readonly property int temp: Config.general.color.hyprsunsetTemp readonly property int temp: Config.display.nightlight.temp
function checkStartup(): void { function checkStartup(): void {
if (!Config.general.color.scheduleHyprsunset) if (!Config.display.nightlight.schedule)
return; return;
service.apply(); service.apply();
@@ -26,12 +26,14 @@ Singleton {
service.toggle(); service.toggle();
} }
HyprsunsetManager { NightlightManager {
id: service id: service
activeAuto: Config.general.color.scheduleHyprsunset activeAuto: Config.display.nightlight.schedule
endTime: root.end endTime: root.end
startTime: root.start startTime: root.start
temp: root.temp temp: root.temp
fadeDuration: Config.display.nightlight.fadeDuration / 1000
useNativeNightlight: Config.display.nightlight.useNative
} }
} }
+6 -6
View File
@@ -11,14 +11,14 @@ import qs.Services
Singleton { Singleton {
id: root id: root
readonly property int darkEnd: Config.general.color.scheduleDarkEnd readonly property int darkEnd: Config.colors.scheduleDarkEnd
readonly property int darkStart: Config.general.color.scheduleDarkStart readonly property int darkStart: Config.colors.scheduleDarkStart
readonly property bool enabled: Config.general.color.scheduleDark && Config.general.color.schemeGeneration readonly property bool enabled: Config.colors.scheduleDark && Config.colors.schemeGen
function applyDarkMode() { function applyDarkMode() {
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "dark"]); 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'"]); Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-dark'"]);
@@ -30,13 +30,13 @@ Singleton {
function applyLightMode() { function applyLightMode() {
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "light"]); 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(["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`]); 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"]); Quickshell.execDetached(["sed", "-i", "'s/\\(vim.cmd.colorscheme \\).*/\\1\"onelight\"/'", "~/.config/nvim/lua/config/load-colorscheme.lua"]);
} }
+3 -3
View File
@@ -38,7 +38,7 @@ Searcher {
previewPath = path; previewPath = path;
showPreview = true; showPreview = true;
if (Config.general.color.schemeGeneration) if (Config.colors.schemeGen)
previewColorsProc.running = true; previewColorsProc.running = true;
} }
@@ -62,8 +62,8 @@ Searcher {
WallpaperPath.currentWallpaperPath = path; WallpaperPath.currentWallpaperPath = path;
Quickshell.screens.forEach(n => setCrop(n.name, Qt.rect(0, 0, 1, 1), 1.0)); 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}`]); 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) if (Config.colors.schemeGen)
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.general.color.mode}`]); Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.colors.mode}`]);
} }
function stopPreview(): void { function stopPreview(): void {
@@ -87,10 +87,6 @@ Item {
signal requestClose signal requestClose
function scrollToBottom(): void {
Qt.callLater(() => listLoader.item?.positionViewAtBeginning());
}
function send(text: string): void { function send(text: string): void {
if (text.trim() === "") if (text.trim() === "")
return; return;
@@ -129,11 +125,15 @@ Item {
isRound: true isRound: true
type: IconButton.Tonal type: IconButton.Tonal
enabled: !ChatState.isWindow
visible: enabled
onClicked: root.requestClose() onClicked: root.requestClose()
} }
CustomText { CustomText {
Layout.fillWidth: true Layout.fillWidth: ChatState.isWindow ? false : true
Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter
elide: Text.ElideRight elide: Text.ElideRight
font.pointSize: Tokens.font.size.larger font.pointSize: Tokens.font.size.larger
text: qsTr(root.chatData.title) text: qsTr(root.chatData.title)
@@ -151,21 +151,20 @@ Item {
anchors.topMargin: Tokens.spacing.medium anchors.topMargin: Tokens.spacing.medium
active: root.chatData && root.chatData.loaded active: root.chatData && root.chatData.loaded
sourceComponent: ListView { sourceComponent: VerticalFadeListView {
id: list id: list
property bool userScrolledUp: false property bool userScrolledUp: false
property real lastCHeight: 0.0 property real lastCHeight: 0.0
function scrollToBottom(): void { function scrollToBottom(): void {
scrollAnim.to = 0;
scrollAnim.start(); scrollAnim.start();
} }
cacheBuffer: height * 20 cacheBuffer: Math.max(height * 20, 0)
clip: true clip: true
// fadeAmount: 0.05 fadeAmount: 0.05
// fadeThreshold: Tokens.padding.medium fadeThreshold: Tokens.padding.medium
model: root.chatData.messagesModel model: root.chatData.messagesModel
spacing: 0 spacing: 0
rotation: 180 rotation: 180
@@ -238,6 +237,7 @@ Item {
property: "contentY" property: "contentY"
target: list target: list
type: Anim.DefaultEffects type: Anim.DefaultEffects
to: list.originY
} }
WheelInverter { WheelInverter {
@@ -255,7 +255,7 @@ Item {
anchors.fill: parent anchors.fill: parent
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
visible: !root.chatData visible: root.chatData.messageCount < 1
} }
Item { Item {
@@ -2,6 +2,7 @@ import QtQuick
import ZShell.Components import ZShell.Components
import ZShell.Config import ZShell.Config
import ZShell.Llm import ZShell.Llm
import qs.Effects
import qs.Components import qs.Components
import qs.Services import qs.Services
@@ -9,12 +10,13 @@ CustomClippingRect {
id: root id: root
property bool expanded: false property bool expanded: false
property bool highlighted: false
required property ChatSession modelData required property ChatSession modelData
signal clicked(content: ChatSession) signal clicked(content: ChatSession)
signal remove(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: { implicitHeight: {
let h = 0; let h = 0;
@@ -33,6 +35,20 @@ CustomClippingRect {
return h; return h;
} }
radius: Tokens.rounding.large 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 { Behavior on implicitHeight {
enabled: titleText.readOnly enabled: titleText.readOnly
@@ -49,6 +65,7 @@ CustomClippingRect {
anchors.topMargin: Tokens.padding.large anchors.topMargin: Tokens.padding.large
font.pointSize: Tokens.font.size.extraLarge font.pointSize: Tokens.font.size.extraLarge
text: root.modelData.icon || "check" text: root.modelData.icon || "check"
color: Colors.palette.m3onSurface
} }
Item { Item {
@@ -85,31 +102,73 @@ CustomClippingRect {
} }
} }
TextEditBase { Item {
id: titleText id: titleWrapper
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: readOnly ? 0 : Tokens.padding.large
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2 anchors.right: parent.right
font.pointSize: Tokens.font.size.normal implicitHeight: titleText.implicitHeight + Tokens.padding.small * 2
text: root.modelData.title
readOnly: true
Behavior on anchors.leftMargin { layer.enabled: true
Anim {} layer.effect: OpacityMask {
} maskSource: fadeMask
Behavior on anchors.topMargin {
Anim {}
} }
onEditingFinished: root.modelData.title = text TextFieldBase {
onReadOnlyChanged: { id: titleText
if (!readOnly) {
this.forceActiveFocus(); anchors.left: parent.left
cursorPosition = text.length; anchors.leftMargin: readOnly ? 0 : Tokens.padding.large
} else { anchors.top: parent.top
root.forceActiveFocus(); 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 id: timestamp
anchors.left: parent.left anchors.left: parent.left
anchors.top: titleText.bottom anchors.top: title.bottom
anchors.topMargin: Tokens.spacing.extraSmall anchors.topMargin: Tokens.spacing.extraSmall
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
@@ -188,7 +247,7 @@ CustomClippingRect {
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
maximumLineCount: 1 maximumLineCount: 1
opacity: !root.expanded ? 1 : 0 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 { Behavior on y {
Anim {} Anim {}
@@ -213,7 +272,7 @@ CustomClippingRect {
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
maximumLineCount: 5 maximumLineCount: 5
opacity: root.expanded ? 1 : 0 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 wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Behavior on y { Behavior on y {
@@ -286,7 +345,7 @@ CustomClippingRect {
implicitHeight: expandIcon.implicitHeight implicitHeight: expandIcon.implicitHeight
implicitWidth: expandIcon.implicitHeight implicitWidth: expandIcon.implicitHeight
radius: Tokens.rounding.full radius: Tokens.rounding.full
color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 3) color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
StateLayer { StateLayer {
onClicked: root.expanded = !root.expanded onClicked: root.expanded = !root.expanded
@@ -298,6 +357,7 @@ CustomClippingRect {
anchors.centerIn: parent anchors.centerIn: parent
anchors.verticalCenterOffset: root.expanded ? -1 : 1 anchors.verticalCenterOffset: root.expanded ? -1 : 1
rotation: root.expanded ? 180 : 0 rotation: root.expanded ? 180 : 0
color: Colors.palette.m3onSurface
text: "expand_more" text: "expand_more"
Behavior on anchors.verticalCenterOffset { Behavior on anchors.verticalCenterOffset {
+346 -11
View File
@@ -13,9 +13,10 @@ Item {
property alias list: list property alias list: list
property alias model: list.model property alias model: list.model
property bool highlight: false
signal deleteChatRequest(content: ChatSession) signal deleteChatRequest(content: ChatSession)
signal loadChatRequest(content: ChatSession) signal loadChatRequest(content: ChatSession, index: int)
signal newChatRequest signal newChatRequest
CustomText { CustomText {
@@ -35,45 +36,379 @@ Item {
CustomListView { CustomListView {
id: list id: list
anchors.bottom: parent.bottom anchors.bottom: modelsContainer.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.top: header.bottom anchors.top: header.bottom
anchors.topMargin: Tokens.spacing.medium anchors.topMargin: Tokens.spacing.medium
cacheBuffer: height * 2
clip: true clip: true
spacing: Tokens.spacing.medium 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 { delegate: ChatDelegate {
id: chat 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) 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 { IconButton {
id: newChatBtn id: fabRoot
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.small
anchors.right: parent.right anchors.right: parent.right
anchors.margins: Tokens.padding.small
padding: 8
font.pointSize: Math.round(18 * 1.2) font.pointSize: Math.round(18 * 1.2)
icon: "add" icon: "add"
padding: 8 isRound: ChatState.fabExpanded
radius: Tokens.rounding.full
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: { onClicked: {
root.newChatRequest(); modelsContainer.expanded = false;
ChatState.fabExpanded = !ChatState.fabExpanded;
} }
Elevation { Elevation {
anchors.fill: parent anchors.fill: parent
level: newChatBtn.stateLayer.containsMouse ? 4 : 3 level: fabRoot.stateLayer.containsMouse ? 4 : 3
radius: parent.radius radius: parent.radius
z: -1 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;
}
}
}
}
}
} }
@@ -18,45 +18,19 @@ Item {
Component.onCompleted: { Component.onCompleted: {
if (ChatState.inChat) { if (ChatState.inChat) {
stackLoader.item?.push(chatList); stack.push(chatList);
stackLoader.item?.push(chatContent, { stack.push(chatContent, {
"chatData": ChatState.chatSession "chatData": ChatState.chatSession
}); });
} else } else
stackLoader.item?.push(chatList); stack.push(chatList);
} }
Loader { StackView {
id: stackLoader id: stack
active: !ChatState.isWindow || root.width <= (ChatState.screen.width / 4)
anchors.fill: parent 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 { Component {
id: chatList id: chatList
@@ -68,8 +42,8 @@ Item {
onDeleteChatRequest: chat => { onDeleteChatRequest: chat => {
Chat.chats.remove(chat); Chat.chats.remove(chat);
} }
onLoadChatRequest: chat => { onLoadChatRequest: (chat, index) => {
stackLoader.item?.push(chatContent, { stack.push(chatContent, {
"chatData": chat "chatData": chat
}); });
ChatState.inChat = true; ChatState.inChat = true;
@@ -77,7 +51,7 @@ Item {
} }
onNewChatRequest: { onNewChatRequest: {
const data = Chat.chats.insert(); const data = Chat.chats.insert();
stackLoader.item?.push(chatContent, { stack.push(chatContent, {
"chatData": data "chatData": data
}); });
ChatState.inChat = true; ChatState.inChat = true;
@@ -91,7 +65,7 @@ Item {
ChatContent { ChatContent {
onRequestClose: { onRequestClose: {
stackLoader.item?.pop(); stack.pop();
ChatState.inChat = false; ChatState.inChat = false;
} }
} }
@@ -3,12 +3,20 @@ pragma Singleton
import QtQuick import QtQuick
import Quickshell import Quickshell
import ZShell.Llm import ZShell.Llm
import qs.Helpers
Singleton { Singleton {
id: root id: root
property ChatSession chatSession property ChatSession chatSession
readonly property string title: chatSession ? chatSession.title : "Chat"
property int currentIdx: -1
property bool inChat: false property bool inChat: false
property bool fabExpanded: false
property bool isWindow: false property bool isWindow: false
property ShellScreen screen property ShellScreen screen
property bool sidebarVisible: Visibilities.getForActive().sidebar
onSidebarVisibleChanged: if (!sidebarVisible)
fabExpanded = false
} }
@@ -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
}
}
}
}
@@ -5,28 +5,28 @@ import qs.Components
import qs.Services import qs.Services
ColumnLayout { ColumnLayout {
id: root id: root
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
Item { Item {
Layout.fillHeight: true Layout.fillHeight: true
} }
MaterialIcon { MaterialIcon {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3outlineVariant color: Colors.tPalette.m3outlineVariant
font.pointSize: 36 font.pointSize: 36
text: "chat" text: "chat"
} }
CustomText { CustomText {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant color: Colors.tPalette.m3onSurfaceVariant
text: qsTr("No messages yet") text: qsTr("Send a message to get started")
} }
Item { Item {
Layout.fillHeight: true Layout.fillHeight: true
} }
} }
@@ -11,7 +11,6 @@ Item {
property color mathColor: Colors.palette.m3onSurface property color mathColor: Colors.palette.m3onSurface
property real fontSize: Tokens.font.size.large 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 implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2
LlmMathText { LlmMathText {
@@ -17,7 +17,8 @@ MouseArea {
required property ChatMessage modelData required property ChatMessage modelData
property bool reasoningExpanded: false property bool reasoningExpanded: false
readonly property var blocks: blockify(current.segments) readonly property var blocks: blockify(current.segments)
property real savedContentY: -1 property real savedOffset: -1
property bool stickActive: false
function handleReasoningToggle(expanded: bool): void { function handleReasoningToggle(expanded: bool): void {
const view = root.ListView.view; const view = root.ListView.view;
@@ -25,14 +26,16 @@ MouseArea {
return; return;
if (expanded) { if (expanded) {
root.savedContentY = view.contentY; root.savedOffset = view.contentY - root.y;
root.reasoningExpanded = true; root.reasoningExpanded = true;
} else { } else {
root.reasoningExpanded = false; root.reasoningExpanded = false;
if (root.savedContentY !== -1) { root.stickActive = false;
restoreAnim.to = root.savedContentY; settleTimer.stop();
if (!isNaN(root.savedOffset)) {
restoreAnim.to = root.y + root.savedOffset;
restoreAnim.restart(); restoreAnim.restart();
root.savedContentY = -1; root.savedOffset = NaN;
} }
} }
} }
@@ -69,6 +72,30 @@ MouseArea {
preventStealing: false preventStealing: false
implicitHeight: layout.implicitHeight + Tokens.padding.medium 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 { Behavior on current {
id: changeAnim 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 { Anim {
id: restoreAnim id: restoreAnim
@@ -3,6 +3,7 @@ pragma Singleton
import Quickshell import Quickshell
import QtQuick import QtQuick
import ZShell.Config import ZShell.Config
import qs.Helpers
import qs.Services import qs.Services
Singleton { Singleton {
@@ -10,6 +11,7 @@ Singleton {
function create(parent: Item, props: var): void { function create(parent: Item, props: var): void {
chatComp.createObject(parent ?? dummy, props); chatComp.createObject(parent ?? dummy, props);
Visibilities.getForActive().sidebar = false;
ChatState.isWindow = true; ChatState.isWindow = true;
} }
@@ -31,7 +33,7 @@ Singleton {
minimumSize.height: Config.sidebar.sizes.width minimumSize.height: Config.sidebar.sizes.width
minimumSize.width: Config.sidebar.sizes.width minimumSize.width: Config.sidebar.sizes.width
surfaceFormat.opaque: false surfaceFormat.opaque: false
title: qsTr("ZShell - Chat") title: qsTr("ZShell - %1").arg(ChatState.title)
Component.onCompleted: ChatState.screen = screen Component.onCompleted: ChatState.screen = screen
onVisibleChanged: { onVisibleChanged: {
@@ -41,7 +43,7 @@ Singleton {
} }
} }
ChatPanel { SidebarView {
id: chat id: chat
anchors.fill: parent anchors.fill: parent
@@ -2,90 +2,109 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Controls
import Quickshell import Quickshell
import ZShell.Config import ZShell.Config
import ZShell.Llm import ZShell.Llm
import qs.Components import qs.Components
import qs.Modules.Notifications.Sidebar.Chat.Content
import qs.Services import qs.Services
Item { RowLayout {
id: root id: root
property int breakpoint: 700 property int breakpoint: 700
property alias conversationModel: sidebar.model property alias conversationModel: sidebar.model
property var currentConversation: null // whatever model item is "open"
readonly property bool isWide: root.width >= root.breakpoint readonly property bool isWide: root.width >= root.breakpoint
property bool narrowShowsSidebar: true property bool narrowShowsSidebar: true
function closeConversation() { function openConversation(conv: ChatSession, index: int): void {
narrowShowsSidebar = true;
}
function openConversation(conv) {
currentConversation = conv;
narrowShowsSidebar = false; // in narrow mode, jump to the chat
} }
ChatList { ChatList {
id: sidebar id: sidebar
anchors.bottom: parent.bottom Layout.fillHeight: true
anchors.left: parent.left Layout.margins: Tokens.padding.medium
anchors.right: root.isWide ? undefined : parent.right Layout.rightMargin: Tokens.spacing.medium
anchors.top: parent.top Layout.maximumWidth: Config.sidebar.sizes.width
implicitWidth: 20 Layout.preferredWidth: root.width / 4
list.currentIndex: -1 Layout.minimumWidth: Config.sidebar.sizes.width / 2
list.highlightFollowsCurrentItem: false highlight: true
Behavior on anchors.right {
AnchorAnim {}
}
Behavior on implicitWidth { Behavior on implicitWidth {
Anim {} 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 { model: ScriptModel {
values: Chat.chats.values values: Chat.chats.values
} }
anchors.onRightChanged: {
if (anchors.right === undefined)
implicitWidth = 20;
}
onLoadChatRequest: (chat, index) => { onLoadChatRequest: (chat, index) => {
sidebar.list.currentIndex = index; ChatState.currentIdx = index;
root.openConversation(chat); 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 { Item {
anchors.bottom: parent.bottom Layout.margins: Tokens.padding.extraLarge
anchors.left: sidebar.right Layout.leftMargin: Tokens.spacing.extraLarge
anchors.right: parent.right Layout.topMargin: Tokens.padding.large
anchors.top: parent.top Layout.preferredWidth: Config.sidebar.sizes.width * 2
child: conversationView 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 id: conversationView
anchors.fill: root ChatContent {}
anchors.leftMargin: root.isWide ? Config.sidebar.sizes.width / 2 : 0
chatData: root.currentConversation
} }
} }
+11 -3
View File
@@ -15,14 +15,17 @@ Item {
required property Component content required property Component content
required property string header required property string header
property int horizontalContentMargin property int horizontalContentMargin
required property string icon property string icon
required property string label required property string label
property alias iconLabel: openButton.iconLabel
property alias rowButton: openButton
property string subtext: "" property string subtext: ""
property bool open property bool open
property real openHeight: Math.min(rootParent.height * 0.8, 600) property real openHeight: Math.min(rootParent.height * 0.8, 600)
property real openWidth: Math.min(rootParent.width * 0.8, 400) property real openWidth: Math.min(rootParent.width * 0.8, 400)
required property Item rootParent required property Item rootParent
property bool separateContent property bool separateContent
required property string settingAnchor
property bool first: false property bool first: false
property bool last: false property bool last: false
@@ -57,6 +60,7 @@ Item {
anchors.fill: parent anchors.fill: parent
enabled: false enabled: false
hoverEnabled: enabled hoverEnabled: enabled
preventStealing: true
parent: root.open ? root.rootParent : root parent: root.open ? root.rootParent : root
onClicked: root.open = false onClicked: root.open = false
@@ -77,7 +81,8 @@ Item {
backdrop.enabled: true backdrop.enabled: true
dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased
dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased
dialogBg.radius: Tokens.rounding.largeIncreased dialogBg.topRightRadius: Tokens.rounding.largeIncreased
dialogBg.topLeftRadius: Tokens.rounding.largeIncreased
dialogContent.opacity: 1 dialogContent.opacity: 1
dialogWrapper.height: root.openHeight dialogWrapper.height: root.openHeight
dialogWrapper.width: root.openWidth dialogWrapper.width: root.openWidth
@@ -105,7 +110,7 @@ Item {
} }
Anim { Anim {
properties: "opacity,radius,bottomLeftRadius,bottomRightRadius" properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
type: Anim.DefaultEffects type: Anim.DefaultEffects
} }
@@ -172,6 +177,7 @@ Item {
sourceComponent: MouseArea { sourceComponent: MouseArea {
onWheel: event => event.accepted = true onWheel: event => event.accepted = true
preventStealing: true
ColumnLayout { ColumnLayout {
anchors.bottomMargin: Tokens.padding.largeIncreased anchors.bottomMargin: Tokens.padding.largeIncreased
@@ -224,6 +230,7 @@ Item {
text: qsTr("Cancel") text: qsTr("Cancel")
type: TextButton.Text type: TextButton.Text
verticalPadding: Tokens.padding.small verticalPadding: Tokens.padding.small
stateLayer.preventStealing: true
onClicked: { onClicked: {
root.cancelled(); root.cancelled();
@@ -237,6 +244,7 @@ Item {
isRound: true isRound: true
text: root.acceptLabel text: root.acceptLabel
type: TextButton.Text type: TextButton.Text
stateLayer.preventStealing: true
verticalPadding: Tokens.padding.small verticalPadding: Tokens.padding.small
onClicked: { onClicked: {
@@ -93,10 +93,13 @@ DialogRowButton {
onOpenChanged: { onOpenChanged: {
if (open) { if (open) {
root.rootParent.interactive = false;
if (!initialSelect) if (!initialSelect)
selectedItem = null; selectedItem = null;
else else
selectedItem = initialSelect; selectedItem = initialSelect;
} else {
root.rootParent.interactive = true;
} }
} }
} }
+24 -2
View File
@@ -17,6 +17,7 @@ ConnectedRect {
property alias subtext: subLabel.text property alias subtext: subLabel.text
property alias text: label.text property alias text: label.text
property string trailingIcon property string trailingIcon
property string activeItem
signal clicked(event: MouseEvent) signal clicked(event: MouseEvent)
@@ -40,15 +41,16 @@ ConnectedRect {
spacing: Tokens.spacing.medium spacing: Tokens.spacing.medium
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
MaterialIcon { MaterialIcon {
id: iconLabel id: iconLabel
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
visible: root.icon !== ""
fill: 1 fill: 1
text: root.icon
font.pointSize: Tokens.font.size.normal 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 { Loader {
active: root.trailingIcon active: root.trailingIcon
asynchronous: true asynchronous: true
+128
View File
@@ -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;
}
}
}
+246
View File
@@ -0,0 +1,246 @@
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
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
}
}
}
}
@@ -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;
}
}
}
@@ -10,12 +10,14 @@ ConnectedRect {
property alias checked: switchButton.checked property alias checked: switchButton.checked
property int horizontalPadding: Tokens.padding.largeIncreased property int horizontalPadding: Tokens.padding.largeIncreased
required property Component popup
property alias subtext: subtext.text property alias subtext: subtext.text
property alias text: text.text property alias text: text.text
property alias icon: icon
property string iconText: "open_in_new"
property int verticalPadding: Tokens.padding.small property int verticalPadding: Tokens.padding.small
signal clicked(checked: bool) signal clicked(event: MouseEvent)
signal toggled(checked: bool)
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: layout.implicitHeight + verticalPadding * 2 implicitHeight: layout.implicitHeight + verticalPadding * 2
@@ -50,11 +52,11 @@ ConnectedRect {
anchors.right: switchButton.left anchors.right: switchButton.left
anchors.rightMargin: Tokens.spacing.medium anchors.rightMargin: Tokens.spacing.medium
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "open_in_new" text: root.iconText
} }
StateLayer { StateLayer {
onClicked: PopupManager.requestOpen(root.popup) onClicked: e => root.clicked(e)
} }
CustomSwitch { CustomSwitch {
@@ -64,6 +66,6 @@ ConnectedRect {
anchors.rightMargin: root.horizontalPadding anchors.rightMargin: root.horizontalPadding
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
onClicked: root.clicked(checked) onClicked: root.toggled(checked)
} }
} }
+421 -519
View File
@@ -6,15 +6,13 @@ import qs.Components
import qs.Helpers import qs.Helpers
import qs.Services import qs.Services
CustomClippingRect { ColumnLayout {
id: root id: root
required property var object required property int start
required property list<string> settings required property int end
property bool shouldBeActive: true signal endEdit(time: int)
signal startEdit(time: int)
signal applySettings(startTime: int, endTime: int)
signal close
function convertHour(timeValue: int): int { function convertHour(timeValue: int): int {
return Math.floor(timeValue / 60); return Math.floor(timeValue / 60);
@@ -28,555 +26,459 @@ CustomClippingRect {
return hour * 60 + minute; return hour * 60 + minute;
} }
color: Colors.palette.m3surfaceContainer spacing: Tokens.spacing.medium
implicitHeight: column.implicitHeight + column.anchors.margins * 2 + buttonRow.implicitHeight + buttonRow.anchors.topMargin
implicitWidth: column.implicitWidth + column.anchors.margins * 2
radius: Tokens.rounding.largeIncreased
ColumnLayout { RowLayout {
id: column CustomRect {
id: startHourRect
anchors.left: parent.left Layout.preferredHeight: 72
anchors.margins: Tokens.padding.largeIncreased Layout.preferredWidth: 96
anchors.right: parent.right color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
anchors.top: parent.top implicitHeight: 72
spacing: Tokens.spacing.medium 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 { CustomText {
text: qsTr("Select time") id: startSeparator
font.pointSize: Tokens.font.size.extraLarge
text: ":"
} }
RowLayout { CustomRect {
CustomRect { id: startMinuteRect
id: startHourRect
Layout.preferredHeight: 72 Layout.preferredHeight: 72
Layout.preferredWidth: 96 Layout.preferredWidth: 96
color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
implicitHeight: 72 implicitHeight: 72
implicitWidth: 96 implicitWidth: 96
radius: Tokens.rounding.small 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: ":"
}
CustomRect { 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 Behavior on border.width {
Layout.preferredWidth: 96 Anim {}
color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest }
implicitHeight: 72 }
implicitWidth: 96
radius: Tokens.rounding.small
CustomRect { TextFieldBase {
anchors.fill: parent id: startMinuteField
border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: startMinuteField.focus ? 2 : 0
radius: parent.radius - border.width
Behavior on border.width { function setConfigText(): string {
Anim { var val = root.convertMinute(root.start);
} if (val === 0) {
return "00";
} }
return String(val);
} }
TextFieldBase { anchors.left: parent.left
id: startMinuteField anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
function setConfigText(setting: string): string { clip: true
var val = root.convertMinute(root.object[setting]); color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
if (val === 0) { font.family: "Roboto"
return "00"; font.letterSpacing: -0.25
} font.pixelSize: 56
return String(val); font.weight: 400
} horizontalAlignment: TextInput.AlignHCenter
text: setConfigText()
anchors.left: parent.left verticalAlignment: TextInput.AlignVCenter
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();
}
}
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true; event.accepted = true;
} if (startMinuteField.text.length >= 2) {
onCursorPositionChanged: cursorPosition = 2 startMinuteField.text = "0" + startMinuteField.text[0];
onTextEdited: { } else if (startMinuteField.text.length === 1) {
if (startMinuteField.text === "") startMinuteField.text = "0";
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 {
} }
} return;
} } else if (event.key === Qt.Key_Escape) {
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();
}
}
event.accepted = true; event.accepted = true;
} startMinuteField.text = setConfigText();
onCursorPositionChanged: cursorPosition = 2 startMinuteField.focus = false;
onTextEdited: { } else if (event.key === Qt.Key_Return) {
if (endHourField.text === "") startMinuteField.focus = false;
return; return;
var val = parseInt(endHourField.text); } else if (event.key === Qt.Key_Tab) {
if (isNaN(val)) endHourField.focus = true;
return; } else if (event.key === Qt.Key_Backtab) {
val = Math.max(0, Math.min(23, val)); startHourField.focus = true;
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);
} }
anchors.left: parent.left if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
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();
}
}
event.accepted = true; event.accepted = true;
} var digit = event.text;
onCursorPositionChanged: cursorPosition = 2 var textLen = startMinuteField.text.length;
onTextEdited: {
if (endMinuteField.text === "") if (textLen >= 2 && startMinuteField.text[0] !== '0') {
return; return;
var val = parseInt(endMinuteField.text); }
if (isNaN(val))
return; var val = 0;
val = Math.max(0, Math.min(23, val)); if (textLen === 0) {
var newText = val.toString(); val = parseInt(digit);
if (newText !== endMinuteField.text) } else if (textLen === 1) {
endMinuteField.text = newText; 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 { Item {
CustomText { id: spacer
Layout.preferredWidth: spacer.x + spacer.width
text: qsTr("Start") 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 { TextFieldBase {
Layout.preferredWidth: endMinuteRect.width + endHourRect.width id: endHourField
text: qsTr("End")
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 { RowLayout {
id: buttonRow CustomText {
Layout.preferredWidth: spacer.x + spacer.width
anchors.bottom: parent.bottom text: qsTr("Start")
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
} }
ButtonRow { CustomText {
spacing: Tokens.spacing.medium Layout.preferredWidth: endMinuteRect.width + endHourRect.width
text: qsTr("End")
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);
}
}
} }
} }
} }
+10 -4
View File
@@ -12,6 +12,7 @@ CustomSwitch {
property alias last: bg.last property alias last: bg.last
property string settingAnchor property string settingAnchor
property string subtext property string subtext
property string iconText
function flashHighlight(): void { function flashHighlight(): void {
bg.flashHighlight(); bg.flashHighlight();
@@ -63,8 +64,7 @@ CustomSwitch {
text: root.text text: root.text
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
} }
@@ -81,10 +81,16 @@ CustomSwitch {
visible: root.subtext visible: root.subtext
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
} }
MaterialIcon {
id: icon
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.iconText ?? ""
}
} }
} }
+1 -6
View File
@@ -24,8 +24,7 @@ CustomClippingRect {
radius: Tokens.rounding.largeIncreased + Tokens.padding.small radius: Tokens.rounding.largeIncreased + Tokens.padding.small
Behavior on blobColor { Behavior on blobColor {
CAnim { CAnim {}
}
} }
TapHandler { TapHandler {
@@ -70,8 +69,4 @@ CustomClippingRect {
anchors.top: parent.top anchors.top: parent.top
sState: root.sState sState: root.sState
} }
PopupOverlay {
anchors.fill: parent
}
} }
+4
View File
@@ -30,6 +30,10 @@ QtObject {
Component { Component {
WallpaperSelect {} WallpaperSelect {}
} }
Component {
ColorsFonts {}
}
} }
}, },
Component { Component {
+13
View File
@@ -1,6 +1,7 @@
import QtQuick import QtQuick
import qs.Components import qs.Components
import ZShell.Config import ZShell.Config
import qs.Services
Item { Item {
id: root id: root
@@ -41,6 +42,18 @@ Item {
objectName: "PageContainer" objectName: "PageContainer"
Component.onCompleted: root.loadPage(root.sState.currentPageIdx) 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 { Connections {
@@ -55,6 +55,10 @@ PageBase {
enabled: Object.keys(model).length > 0 enabled: Object.keys(model).length > 0
header: qsTr("Add new entry") header: qsTr("Add new entry")
icon: "add" icon: "add"
settingAnchor: "panels-bar-status-icons-add-item"
onOpenChanged: {
root.sState.dimmed = open;
}
last: true last: true
label: qsTr("Add entry") label: qsTr("Add entry")
model: { model: {
@@ -75,10 +75,18 @@ PageBase {
acceptLabel: qsTr("Confirm") acceptLabel: qsTr("Confirm")
enabled: Object.keys(model).length > 0 enabled: Object.keys(model).length > 0
settingAnchor: "panels-sidebar-select-scheme"
separateContent: false
header: qsTr("Select scheme") header: qsTr("Select scheme")
first: true first: true
last: true last: true
icon: "add" icon: "format_paint"
iconLabel.font.pointSize: Tokens.font.size.large
rowButton.trailingIcon: "open_in_new"
onOpenChanged: {
root.sState.dimmed = open;
}
rowButton.activeItem: root.schemes.find(s => s.id === Config.llm.appearance.scheme).label
label: qsTr("Color scheme") label: qsTr("Color scheme")
subtext: qsTr("Select the color scheme used for code blocks") subtext: qsTr("Select the color scheme used for code blocks")
model: root.schemes model: root.schemes
@@ -88,6 +88,10 @@ PageBase {
enabled: Object.keys(model).length > 0 enabled: Object.keys(model).length > 0
header: qsTr("Add new entry") header: qsTr("Add new entry")
icon: "add" icon: "add"
onOpenChanged: {
root.sState.dimmed = open;
}
settingAnchor: "panels-sidebar-quick-toggles-add-item"
label: qsTr("Add entry") label: qsTr("Add entry")
last: true last: true
model: { model: {
+2 -2
View File
@@ -202,12 +202,12 @@ PageBase {
} }
ToggleRow { ToggleRow {
checked: Config.general.color.smart checked: Config.colors.smart
settingAnchor: "services-smart-color-scheme" settingAnchor: "services-smart-color-scheme"
subtext: qsTr("Derive theme mode from the wallpaper") subtext: qsTr("Derive theme mode from the wallpaper")
text: qsTr("Smart color scheme") text: qsTr("Smart color scheme")
onToggled: Config.general.color.smart = checked onToggled: Config.colors.smart = checked
} }
SelectRow { SelectRow {
@@ -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<string> 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.colors.schemeGen ? "enabled" : "disabled")
checked: Config.colors.schemeGen
settingAnchor: "colors-fonts-dynamic-colors"
onToggled: {
Config.colors.schemeGen = checked;
if (checked)
Colors.setMode(Config.colors.mode);
}
}
DialogSelectButton {
settingAnchor: "colors-fonts-preset-scheme"
model: root.schemes
acceptLabel: qsTr("Confirm")
enabled: !Config.colors.schemeGen
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.colors.schemeGen && 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.colors.schemeGen && 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;
}
}
}
}
+107 -59
View File
@@ -33,18 +33,35 @@ PageBase {
} }
} }
IconTextButton { ButtonRow {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
enabled: Config.background.enabled spacing: Tokens.spacing.small
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: "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 { ToggleRow {
@@ -76,85 +93,116 @@ PageBase {
onToggled: Colors.setMode(checked ? "dark" : "light") onToggled: Colors.setMode(checked ? "dark" : "light")
} }
OverlayRow { TimeDialogSelect {
id: darkMode
readonly property string endTime: { readonly property string endTime: {
var d = new Date(0, 0, 0, 0, 0, 0, 0); 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"); return Qt.formatTime(d, "hh:mm AP");
} }
readonly property string startTime: { readonly property string startTime: {
var d = new Date(0, 0, 0, 0, 0, 0, 0); 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"); return Qt.formatTime(d, "hh:mm AP");
} }
checked: Config.general.color.scheduleDark rowButton.checked: Config.colors.scheduleDark
rowButton.onToggled: Config.colors.scheduleDark = rowButton.checked
start: Config.colors.scheduleDarkStart
end: Config.colors.scheduleDarkEnd
first: true first: true
last: true
settingAnchor: "style-schedule-dark-mode" 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) 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 { onOpenChanged: {
TimeInput { root.sState.dimmed = open;
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()
}
} }
onClicked: value => { onAccepted: {
Config.general.color.scheduleDark = value; Config.colors.scheduleDarkStart = startCurrent;
Config.colors.scheduleDarkEnd = endCurrent;
ModeScheduler.checkStartup();
} }
} }
OverlayRow { TimeDialogSelect {
id: hyprsunset
readonly property string endTime: { readonly property string endTime: {
var d = new Date(0, 0, 0, 0, 0, 0, 0); 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"); return Qt.formatTime(d, "hh:mm AP");
} }
readonly property string startTime: { readonly property string startTime: {
var d = new Date(0, 0, 0, 0, 0, 0, 0); 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"); return Qt.formatTime(d, "hh:mm AP");
} }
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("Nightlight will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime)
label: qsTr("Schedule nightlight")
onOpenChanged: {
root.sState.dimmed = open;
}
onAccepted: {
Config.display.nightlight.scheduleStart = startCurrent;
Config.display.nightlight.scheduleEnd = endCurrent;
Hyprsunset.checkStartup();
}
}
ToggleRow {
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
checked: Config.general.color.scheduleHyprsunset
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 last: true
settingAnchor: "style-schedule-hyprsunset" stepSize: 1
subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) subtext: qsTr("How long it takes for the gradual nightlight to finish (s)")
text: qsTr("Schedule hyprsunset") text: qsTr("Fade duration")
to: 60 * 5
value: Config.display.nightlight.fadeDuration / 1000
popup: Component { onMoved: v => Config.display.nightlight.fadeDuration = v * 1000
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()
}
}
onClicked: value => {
Config.general.color.scheduleHyprsunset = value;
}
} }
} }
} }
-20
View File
@@ -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;
}
}
-47
View File
@@ -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
}
}
+1
View File
@@ -16,6 +16,7 @@ QtObject {
property string searchText property string searchText
property DesktopEntry selectedApp property DesktopEntry selectedApp
property BluetoothDevice selectedBtDevice property BluetoothDevice selectedBtDevice
property bool dimmed
property string selectedWallpaperCategory property string selectedWallpaperCategory
property list<int> subPageIdxStack property list<int> subPageIdxStack
+8
View File
@@ -80,3 +80,11 @@ add_subdirectory(Components)
add_subdirectory(Blobs) add_subdirectory(Blobs)
add_subdirectory(Config) add_subdirectory(Config)
add_subdirectory(Llm) add_subdirectory(Llm)
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()
+2
View File
@@ -5,6 +5,7 @@ qml_module(ZShell-config
configobject.hpp configobject.cpp configobject.hpp configobject.cpp
configlist.hpp configlist.cpp configlist.hpp configlist.cpp
config.hpp config.cpp config.hpp config.cpp
migration.hpp migration.cpp
anim.hpp anim.cpp anim.hpp anim.cpp
tokens.hpp tokens.hpp
appearance.hpp appearance.hpp
@@ -13,6 +14,7 @@ qml_module(ZShell-config
clipboard.hpp clipboard.hpp
colors.hpp colors.hpp
dashboard.hpp dashboard.hpp
display.hpp
dock.hpp dock.hpp
general.hpp general.hpp
launcher.hpp launcher.hpp
+7
View File
@@ -22,6 +22,13 @@ class Colors : public ConfigObject {
CONFIG_SUBOBJECT(Presets, presets) CONFIG_SUBOBJECT(Presets, presets)
CFG_PROPERTY(QString, schemeType, QStringLiteral("fidelity")) CFG_PROPERTY(QString, schemeType, QStringLiteral("fidelity"))
CFG_PROPERTY(bool, scheduleDark, false)
CFG_PROPERTY(int, scheduleDarkEnd, 600)
CFG_PROPERTY(int, scheduleDarkStart, 1140)
CFG_PROPERTY(bool, schemeGen, true)
CFG_PROPERTY(bool, neovimColors, false)
CFG_PROPERTY(QString, mode, QStringLiteral("dark"))
CFG_PROPERTY(bool, smart, false)
public: public:
explicit Colors(QObject* parent = nullptr) explicit Colors(QObject* parent = nullptr)
+14 -7
View File
@@ -6,11 +6,13 @@
#include "clipboard.hpp" #include "clipboard.hpp"
#include "colors.hpp" #include "colors.hpp"
#include "dashboard.hpp" #include "dashboard.hpp"
#include "display.hpp"
#include "dock.hpp" #include "dock.hpp"
#include "general.hpp" #include "general.hpp"
#include "launcher.hpp" #include "launcher.hpp"
#include "llm.hpp" #include "llm.hpp"
#include "lock.hpp" #include "lock.hpp"
#include "migration.hpp"
#include "notifs.hpp" #include "notifs.hpp"
#include "osd.hpp" #include "osd.hpp"
#include "screenshot.hpp" #include "screenshot.hpp"
@@ -44,6 +46,7 @@ Config::Config(QObject* parent)
, m_clipboard(new Clipboard(this)) , m_clipboard(new Clipboard(this))
, m_colors(new Colors(this)) , m_colors(new Colors(this))
, m_dashboard(new Dashboard(this)) , m_dashboard(new Dashboard(this))
, m_display(new Display(this))
, m_dock(new Dock(this)) , m_dock(new Dock(this))
, m_general(new General(this)) , m_general(new General(this))
, m_launcher(new Launcher(this)) , m_launcher(new Launcher(this))
@@ -91,8 +94,7 @@ Config* Config::instance() {
} }
Config* Config::create(QQmlEngine*, QJSEngine*) { Config* Config::create(QQmlEngine*, QJSEngine*) {
if (!s_instance) if (!s_instance) s_instance = new Config();
s_instance = new Config();
return s_instance; return s_instance;
} }
@@ -111,11 +113,14 @@ void Config::loadSync() {
QJsonObject before; QJsonObject before;
bool existed = false; bool existed = false;
bool migrated = false;
if (f.open(QIODevice::ReadOnly)) { if (f.open(QIODevice::ReadOnly)) {
const auto doc = QJsonDocument::fromJson(f.readAll()); const auto doc = QJsonDocument::fromJson(f.readAll());
if (doc.isObject()) { if (doc.isObject()) {
before = doc.object(); const QJsonObject raw = doc.object();
before = ConfigMigrations::apply(raw);
migrated = before != raw;
existed = true; existed = true;
} else { } else {
qInfo() << "Config: existing config at" << filePath() qInfo() << "Config: existing config at" << filePath()
@@ -130,7 +135,7 @@ void Config::loadSync() {
m_loading = false; m_loading = false;
const auto after = toJson().toObject(); const auto after = toJson().toObject();
if (!existed || after != before) saveNow(); if (!existed || migrated || after != before) saveNow();
} }
void Config::updateWatch() { void Config::updateWatch() {
@@ -176,16 +181,18 @@ void Config::loadAsync() {
if (f.open(QIODevice::ReadOnly)) { if (f.open(QIODevice::ReadOnly)) {
const auto doc = QJsonDocument::fromJson(f.readAll()); const auto doc = QJsonDocument::fromJson(f.readAll());
const bool valid = doc.isObject(); const bool valid = doc.isObject();
const QJsonObject before = valid ? doc.object() : QJsonObject(); const QJsonObject raw = valid ? doc.object() : QJsonObject();
const QJsonObject before = ConfigMigrations::apply(raw);
const bool migrated = valid && before != raw;
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
this, this,
[this, valid, before]() { [this, valid, migrated, before]() {
loadFromJson(QJsonValue(before)); loadFromJson(QJsonValue(before));
m_loading = false; m_loading = false;
const auto after = toJson().toObject(); const auto after = toJson().toObject();
if (!valid || after != before) saveNow(); if (!valid || migrated || after != before) saveNow();
if (m_reloadPending) m_reloadTimer.start(); if (m_reloadPending) m_reloadTimer.start();
}, },
+3
View File
@@ -21,6 +21,7 @@ class Bar;
class Clipboard; class Clipboard;
class Colors; class Colors;
class Dashboard; class Dashboard;
class Display;
class Dock; class Dock;
class General; class General;
class Launcher; class Launcher;
@@ -44,6 +45,7 @@ class Config : public ConfigObject {
Q_MOC_INCLUDE("clipboard.hpp") Q_MOC_INCLUDE("clipboard.hpp")
Q_MOC_INCLUDE("colors.hpp") Q_MOC_INCLUDE("colors.hpp")
Q_MOC_INCLUDE("dashboard.hpp") Q_MOC_INCLUDE("dashboard.hpp")
Q_MOC_INCLUDE("display.hpp")
Q_MOC_INCLUDE("dock.hpp") Q_MOC_INCLUDE("dock.hpp")
Q_MOC_INCLUDE("general.hpp") Q_MOC_INCLUDE("general.hpp")
Q_MOC_INCLUDE("launcher.hpp") Q_MOC_INCLUDE("launcher.hpp")
@@ -62,6 +64,7 @@ class Config : public ConfigObject {
CONFIG_SUBOBJECT(Clipboard, clipboard) CONFIG_SUBOBJECT(Clipboard, clipboard)
CONFIG_SUBOBJECT(Colors, colors) CONFIG_SUBOBJECT(Colors, colors)
CONFIG_SUBOBJECT(Dashboard, dashboard) CONFIG_SUBOBJECT(Dashboard, dashboard)
CONFIG_SUBOBJECT(Display, display)
CONFIG_SUBOBJECT(Dock, dock) CONFIG_SUBOBJECT(Dock, dock)
CONFIG_SUBOBJECT(General, general) CONFIG_SUBOBJECT(General, general)
CONFIG_SUBOBJECT(Launcher, launcher) CONFIG_SUBOBJECT(Launcher, launcher)
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include "configobject.hpp"
#include <qqmlintegration.h>
namespace ZShell::config {
class Nightlight : public ConfigObject {
Q_OBJECT
QML_ANONYMOUS
CFG_PROPERTY(bool, schedule, true)
CFG_PROPERTY(int, scheduleStart, 1200)
CFG_PROPERTY(int, scheduleEnd, 570)
CFG_PROPERTY(bool, useNative, true)
CFG_PROPERTY(int, fadeDuration, 2000)
CFG_PROPERTY(int, temp, 2600)
public:
explicit Nightlight(QObject* parent = nullptr) : ConfigObject(parent) {}
};
class Display : public ConfigObject {
Q_OBJECT
QML_ANONYMOUS
CONFIG_SUBOBJECT(Nightlight, nightlight)
public:
explicit Display(QObject* parent = nullptr)
: ConfigObject(parent), m_nightlight(new Nightlight(this)) {}
};
} // namespace ZShell::config
-22
View File
@@ -91,26 +91,6 @@ class Battery : public ConfigObject {
explicit Battery(QObject* parent = nullptr) : ConfigObject(parent) {} 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, 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 { class Idle : public ConfigObject {
Q_OBJECT Q_OBJECT
QML_ANONYMOUS QML_ANONYMOUS
@@ -135,7 +115,6 @@ class General : public ConfigObject {
CONFIG_SUBOBJECT(Apps, apps) CONFIG_SUBOBJECT(Apps, apps)
CONFIG_SUBOBJECT(Battery, battery) CONFIG_SUBOBJECT(Battery, battery)
CONFIG_SUBOBJECT(ColorSettings, color)
CFG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss")) CFG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss"))
CFG_PROPERTY(bool, desktopIcons, true) CFG_PROPERTY(bool, desktopIcons, true)
CONFIG_SUBOBJECT(Idle, idle) CONFIG_SUBOBJECT(Idle, idle)
@@ -151,7 +130,6 @@ class General : public ConfigObject {
: ConfigObject(parent) : ConfigObject(parent)
, m_apps(new Apps(this)) , m_apps(new Apps(this))
, m_battery(new Battery(this)) , m_battery(new Battery(this))
, m_color(new ColorSettings(this))
, m_idle(new Idle(this)) {} , m_idle(new Idle(this)) {}
}; };
+123
View File
@@ -0,0 +1,123 @@
#include "migration.hpp"
#include <QDebug>
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<ConfigMigrationRule>& ConfigMigrations::rules() {
static const QList<ConfigMigrationRule> 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
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <QJsonObject>
#include <QStringList>
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<ConfigMigrationRule>& rules();
// Applies all rules to `json`, returning the migrated object.
[[nodiscard]] static QJsonObject apply(const QJsonObject& json);
};
} // namespace ZShell::config
+12
View File
@@ -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")
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <array>
#include <cmath>
#include <algorithm>
namespace ZShell::services {
inline std::array<float, 3> 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<float>(r), static_cast<float>(g), static_cast<float>(b)};
}
} // namespace ZShell::services
+447
View File
@@ -0,0 +1,447 @@
#include <any>
#include <atomic>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <mutex>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#define private public
#include <hyprland/src/plugins/PluginAPI.hpp>
#include <hyprland/src/render/OpenGL.hpp>
#include <hyprland/src/render/Renderer.hpp>
#undef private
#include <hyprland/src/managers/EventManager.hpp>
extern "C" {
#include <lua.h>
#include <lauxlib.h>
}
#include "colortemp.hpp"
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<uint64_t> g_logSeq{0};
static void dlog(const std::string& msg) {
std::lock_guard<std::mutex> 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<float, 3>& 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<float, 3>& 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<float, 3> 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<float, 3> fromGain{1.f, 1.f, 1.f};
std::array<float, 3> 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<float, 3>& 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<IFramebuffer>,
std::optional<CRegion>);
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<origSaveMirror>(
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<float, 3>& 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<IFramebuffer> fb,
std::optional<CRegion> finalDamage) {
(*reinterpret_cast<origBegin>(
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<float>(
std::chrono::steady_clock::now() - g_transition.start)
.count();
const float t =
std::clamp(elapsed / g_transition.durationSec, 0.f, 1.f);
for (size_t i = 0; i < 3; ++i)
g_currentGain[i] =
g_transition.fromGain[i] +
(g_transition.toGain[i] - g_transition.fromGain[i]) * t;
if (g_uniformMode) {
setUniformGain(g_currentGain);
} else {
const int step = static_cast<int>(t * 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<int>(luaL_checknumber(L, 1));
const float durationSec =
lua_gettop(L) >= 2 ? static_cast<float>(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<float>(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<SFunctionMatch>& 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<void*>(&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<void*>(&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() {}
+13 -18
View File
@@ -10,9 +10,10 @@
namespace ZShell::llm { namespace ZShell::llm {
Chat::Chat(QObject* parent) Chat::Chat(QObject* parent)
: QObject(parent), m_store(new ChatStore(this)), m_client(new LlmClient(this)) { : QObject(parent)
if (!config::Config::instance()) , m_store(new ChatStore(this))
new config::Config(); , m_client(new LlmClient(this)) {
if (!config::Config::instance()) new config::Config();
m_store->setLlmClient(m_client); m_store->setLlmClient(m_client);
m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
@@ -44,10 +45,8 @@ Chat::Chat(QObject* parent)
} }
Q_EMIT busyChanged(); Q_EMIT busyChanged();
}); });
connect( connect(m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged);
m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged); connect(m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
connect(
m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
connect( connect(
m_client, m_client,
&LlmClient::availableModelsChanged, &LlmClient::availableModelsChanged,
@@ -88,7 +87,7 @@ Chat::Chat(QObject* parent)
this, this,
[this](ChatSession* session, const QString& title) { [this](ChatSession* session, const QString& title) {
qInfo() << "Chat: applying generated title" << session->id() qInfo() << "Chat: applying generated title" << session->id()
<< title << "(was" << session->title() << ")"; << title << "(was" << session->title() << ")";
session->setTitle(title); session->setTitle(title);
m_store->saveMeta(session); m_store->saveMeta(session);
}); });
@@ -97,8 +96,8 @@ Chat::Chat(QObject* parent)
&LlmClient::iconSuggested, &LlmClient::iconSuggested,
this, this,
[this](ChatSession* session, const QString& icon) { [this](ChatSession* session, const QString& icon) {
qInfo() << "Chat: applying generated icon" << session->id() qInfo() << "Chat: applying generated icon" << session->id() << icon
<< icon << "(was" << session->icon() << ")"; << "(was" << session->icon() << ")";
session->setIcon(icon); session->setIcon(icon);
m_store->saveMeta(session); m_store->saveMeta(session);
}); });
@@ -141,8 +140,7 @@ QString Chat::streamingChatId() const {
Chat* Chat::s_instance = nullptr; Chat* Chat::s_instance = nullptr;
Chat* Chat::create(QQmlEngine*, QJSEngine*) { Chat* Chat::create(QQmlEngine*, QJSEngine*) {
if (!s_instance) if (!s_instance) s_instance = new Chat();
s_instance = new Chat();
return s_instance; return s_instance;
} }
@@ -151,8 +149,7 @@ void Chat::stop() {
} }
void Chat::dismissError() { void Chat::dismissError() {
if (m_lastError.isEmpty()) if (m_lastError.isEmpty()) return;
return;
m_lastError.clear(); m_lastError.clear();
Q_EMIT lastErrorChanged(); Q_EMIT lastErrorChanged();
} }
@@ -162,11 +159,9 @@ void Chat::refreshModels() {
} }
void Chat::selectModel(const QString& id) { void Chat::selectModel(const QString& id) {
if (id.isEmpty()) if (id.isEmpty()) return;
return;
m_client->setModel(id); m_client->setModel(id);
if (auto* config = config::Config::instance()) if (auto* config = config::Config::instance()) config->llm()->set_model(id);
config->llm()->set_model(id);
} }
} // namespace ZShell::llm } // namespace ZShell::llm
+9 -3
View File
@@ -25,12 +25,18 @@ class Chat : public QObject {
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged) Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged)
Q_PROPERTY(QString model READ model NOTIFY modelChanged) 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(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(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) 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: public:
explicit Chat(QObject* parent = nullptr); explicit Chat(QObject* parent = nullptr);
+245 -298
View File
@@ -37,24 +37,16 @@ QString segmentTypeName(LlmSegment::Type type) {
} }
LlmSegment::Type segmentTypeFromName(const QString& name) { LlmSegment::Type segmentTypeFromName(const QString& name) {
if (name == QLatin1String("tool_call")) if (name == QLatin1String("tool_call")) return LlmSegment::Type::ToolCall;
return LlmSegment::Type::ToolCall; if (name == QLatin1String("content")) return LlmSegment::Type::Content;
if (name == QLatin1String("content"))
return LlmSegment::Type::Content;
return LlmSegment::Type::Reasoning; 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) { QString sqlText(const QString& value) {
if (value.isNull()) if (value.isNull()) return QStringLiteral("");
return QStringLiteral("");
return 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 { struct SegmentRow {
QString type; QString type;
QString text; QString text;
@@ -82,14 +74,13 @@ struct MessageRow {
} // namespace } // namespace
ChatStore::ChatStore(QObject* parent) ChatStore::ChatStore(QObject* parent)
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) { : QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
openDb(); openDb();
load(); load();
} }
ChatStore::~ChatStore() { ChatStore::~ChatStore() {
if (m_connectionName.isEmpty()) if (m_connectionName.isEmpty()) return;
return;
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
db.close(); db.close();
QSqlDatabase::removeDatabase(m_connectionName); QSqlDatabase::removeDatabase(m_connectionName);
@@ -110,7 +101,7 @@ void ChatStore::openDb() {
db.setDatabaseName(m_dbPath); db.setDatabaseName(m_dbPath);
if (!db.open()) { if (!db.open()) {
qWarning() << "ChatStore: failed to open database" << m_dbPath << ":" qWarning() << "ChatStore: failed to open database" << m_dbPath << ":"
<< db.lastError().text(); << db.lastError().text();
return; return;
} }
{ {
@@ -119,59 +110,55 @@ void ChatStore::openDb() {
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral( "CREATE TABLE IF NOT EXISTS sessions (\n"
"CREATE TABLE IF NOT EXISTS sessions (\n" " id TEXT PRIMARY KEY,\n"
" id TEXT PRIMARY KEY,\n" " title TEXT NOT NULL DEFAULT '',\n"
" title TEXT NOT NULL DEFAULT '',\n" " icon TEXT NOT NULL DEFAULT '',\n"
" icon TEXT NOT NULL DEFAULT '',\n" " created_at INTEGER NOT NULL,\n"
" created_at INTEGER NOT NULL,\n" " updated_at INTEGER NOT NULL,\n"
" updated_at INTEGER NOT NULL,\n" " pinned INTEGER NOT NULL DEFAULT 0\n"
" pinned INTEGER NOT NULL DEFAULT 0\n" ")"));
")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral( "CREATE TABLE IF NOT EXISTS messages (\n"
"CREATE TABLE IF NOT EXISTS messages (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " session_id TEXT NOT NULL REFERENCES sessions (id) "
" session_id TEXT NOT NULL REFERENCES sessions (id) " "ON DELETE CASCADE,\n"
"ON DELETE CASCADE,\n" " role TEXT NOT NULL,\n"
" role TEXT NOT NULL,\n" " timestamp INTEGER NOT NULL\n"
" timestamp INTEGER NOT NULL\n" ")"));
")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral( "CREATE TABLE IF NOT EXISTS generations (\n"
"CREATE TABLE IF NOT EXISTS generations (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " message_id INTEGER NOT NULL REFERENCES messages "
" message_id INTEGER NOT NULL REFERENCES messages " "(id) ON DELETE CASCADE,\n"
"(id) ON DELETE CASCADE,\n" " timestamp INTEGER NOT NULL,\n"
" timestamp INTEGER NOT NULL,\n" " is_active INTEGER NOT NULL DEFAULT 1\n"
" is_active INTEGER NOT NULL DEFAULT 1\n" ")"));
")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral( "CREATE TABLE IF NOT EXISTS segments (\n"
"CREATE TABLE IF NOT EXISTS segments (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " generation_id INTEGER NOT NULL REFERENCES generations "
" generation_id INTEGER NOT NULL REFERENCES generations " "(id) ON DELETE CASCADE,\n"
"(id) ON DELETE CASCADE,\n" " type TEXT NOT NULL,\n"
" type TEXT NOT NULL,\n" " text TEXT NOT NULL DEFAULT '',\n"
" text TEXT NOT NULL DEFAULT '',\n" " name TEXT NOT NULL DEFAULT '',\n"
" name TEXT NOT NULL DEFAULT '',\n" " tool_call_id TEXT NOT NULL DEFAULT '',\n"
" tool_call_id TEXT NOT NULL DEFAULT '',\n" " arguments TEXT NOT NULL DEFAULT '',\n"
" arguments TEXT NOT NULL DEFAULT '',\n" " result TEXT NOT NULL DEFAULT '',\n"
" result TEXT NOT NULL DEFAULT '',\n" " status INTEGER NOT NULL DEFAULT 0,\n"
" status INTEGER NOT NULL DEFAULT 0,\n" " elapsed_ms INTEGER NOT NULL DEFAULT 0,\n"
" elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" " timestamp INTEGER NOT NULL\n"
" timestamp INTEGER NOT NULL\n" ")"));
")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
@@ -200,8 +187,7 @@ QVariantList ChatStore::values() const {
} }
ChatSession* ChatStore::at(int index) const { ChatSession* ChatStore::at(int index) const {
if (index < 0 || index >= m_sessions.size()) if (index < 0 || index >= m_sessions.size()) return nullptr;
return nullptr;
return m_sessions.at(index); return m_sessions.at(index);
} }
@@ -218,7 +204,7 @@ ChatSession* ChatStore::insert(int index) {
query.bindValue(":updated_at", now); query.bindValue(":updated_at", now);
if (!query.exec()) if (!query.exec())
qWarning() << "ChatStore: failed to insert session" << id << ":" qWarning() << "ChatStore: failed to insert session" << id << ":"
<< query.lastError().text(); << query.lastError().text();
} }
auto* session = new ChatSession(id, this); auto* session = new ChatSession(id, this);
session->setMeta(QString(), now, now, 0); session->setMeta(QString(), now, now, 0);
@@ -238,8 +224,7 @@ void ChatStore::remove(ChatSession* chat) {
} }
void ChatStore::removeSession(ChatSession* session) { void ChatStore::removeSession(ChatSession* session) {
if (!session || !m_sessions.contains(session)) if (!session || !m_sessions.contains(session)) return;
return;
const QList<ChatSession*> before = m_sessions; const QList<ChatSession*> before = m_sessions;
Q_EMIT sessionRemoved(session); Q_EMIT sessionRemoved(session);
{ {
@@ -255,7 +240,7 @@ void ChatStore::removeSession(ChatSession* session) {
void ChatStore::move(int from, int to) { void ChatStore::move(int from, int to) {
if (from < 0 || from >= m_sessions.size() || to < 0 || if (from < 0 || from >= m_sessions.size() || to < 0 ||
to >= m_sessions.size() || from == to) to >= m_sessions.size() || from == to)
return; return;
m_sessions.move(from, to); m_sessions.move(from, to);
Q_EMIT valuesChanged(); Q_EMIT valuesChanged();
@@ -269,8 +254,7 @@ void ChatStore::clear() {
ChatSession* ChatStore::sessionById(const QString& id) { ChatSession* ChatStore::sessionById(const QString& id) {
for (auto* session : m_sessions) for (auto* session : m_sessions)
if (session->id() == id) if (session->id() == id) return session;
return session;
return nullptr; return nullptr;
} }
@@ -279,32 +263,28 @@ void ChatStore::setLlmClient(LlmClient* client) {
} }
void ChatStore::persist(ChatSession* session) { void ChatStore::persist(ChatSession* session) {
if (!session || !m_sessions.contains(session)) if (!session || !m_sessions.contains(session)) return;
return;
if (!session->isLoaded()) { 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); m_pendingPersists.insert(session);
return; return;
} }
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
if (!saveSession(session)) if (!saveSession(session)) return;
return;
sortAndNotify(); sortAndNotify();
} }
void ChatStore::saveMeta(ChatSession* session) { void ChatStore::saveMeta(ChatSession* session) {
if (!session || !m_sessions.contains(session)) if (!session || !m_sessions.contains(session)) return;
return;
QSqlQuery query(db()); QSqlQuery query(db());
query.prepare("UPDATE sessions SET title = :title, icon = :icon " query.prepare(
"WHERE id = :id"); "UPDATE sessions SET title = :title, icon = :icon "
"WHERE id = :id");
query.bindValue(":title", sqlText(session->title())); query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon())); query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":id", session->id()); query.bindValue(":id", session->id());
if (!query.exec()) if (!query.exec())
qWarning() << "ChatStore: failed to save meta for" << session->id() qWarning() << "ChatStore: failed to save meta for" << session->id()
<< ":" << query.lastError().text(); << ":" << query.lastError().text();
} }
bool ChatStore::saveSession(ChatSession* session) { bool ChatStore::saveSession(ChatSession* session) {
@@ -312,7 +292,7 @@ bool ChatStore::saveSession(ChatSession* session) {
QSqlDatabase handle = db(); QSqlDatabase handle = db();
if (!handle.transaction()) { if (!handle.transaction()) {
qWarning() << "ChatStore: failed to begin transaction:" qWarning() << "ChatStore: failed to begin transaction:"
<< handle.lastError().text(); << handle.lastError().text();
return false; return false;
} }
bool ok = true; bool ok = true;
@@ -338,18 +318,18 @@ bool ChatStore::saveSession(ChatSession* session) {
"INSERT INTO messages (session_id, role, timestamp) " "INSERT INTO messages (session_id, role, timestamp) "
"VALUES (:id, :role, :timestamp)"); "VALUES (:id, :role, :timestamp)");
QSqlQuery generationInsert(handle); QSqlQuery generationInsert(handle);
ok = ok && generationInsert.prepare( ok = ok &&
"INSERT INTO generations (message_id, timestamp, is_active) " generationInsert.prepare(
"VALUES (:mid, :timestamp, :is_active)"); "INSERT INTO generations (message_id, timestamp, is_active) "
"VALUES (:mid, :timestamp, :is_active)");
QSqlQuery segmentInsert(handle); QSqlQuery segmentInsert(handle);
ok = ok && segmentInsert.prepare( ok = ok &&
"INSERT INTO segments (generation_id, type, text, name, " segmentInsert.prepare(
"tool_call_id, arguments, result, status, elapsed_ms, " "INSERT INTO segments (generation_id, type, text, name, "
"timestamp) VALUES (:gid, :type, :text, :name, " "tool_call_id, arguments, result, status, elapsed_ms, "
":tool_call_id, :arguments, :result, :status, :elapsed_ms, " "timestamp) VALUES (:gid, :type, :text, :name, "
":timestamp)"); ":tool_call_id, :arguments, :result, :status, :elapsed_ms, "
// The model holds messages most recent first; the database keeps ":timestamp)");
// natural rowid order, so iterate from the oldest row up.
const auto* model = session->messagesModel(); const auto* model = session->messagesModel();
for (int row = model->rowCount() - 1; ok && row >= 0; --row) { for (int row = model->rowCount() - 1; ok && row >= 0; --row) {
const auto* message = model->at(row); const auto* message = model->at(row);
@@ -363,8 +343,8 @@ bool ChatStore::saveSession(ChatSession* session) {
if (!messageInsert.exec()) { if (!messageInsert.exec()) {
ok = false; ok = false;
qWarning() << "ChatStore: saveSession" << id qWarning() << "ChatStore: saveSession" << id
<< "message insert failed:" << "message insert failed:"
<< messageInsert.lastError().text(); << messageInsert.lastError().text();
break; break;
} }
const int messageId = messageInsert.lastInsertId().toInt(); const int messageId = messageInsert.lastInsertId().toInt();
@@ -379,8 +359,8 @@ bool ChatStore::saveSession(ChatSession* session) {
if (!generationInsert.exec()) { if (!generationInsert.exec()) {
ok = false; ok = false;
qWarning() << "ChatStore: saveSession" << id qWarning() << "ChatStore: saveSession" << id
<< "generation insert failed:" << "generation insert failed:"
<< generationInsert.lastError().text(); << generationInsert.lastError().text();
break; break;
} }
const int generationId = const int generationId =
@@ -395,18 +375,17 @@ bool ChatStore::saveSession(ChatSession* session) {
":tool_call_id", sqlText(segment->toolCallId())); ":tool_call_id", sqlText(segment->toolCallId()));
segmentInsert.bindValue( segmentInsert.bindValue(
":arguments", sqlText(segment->arguments())); ":arguments", sqlText(segment->arguments()));
segmentInsert.bindValue(":result", sqlText(segment->result())); segmentInsert.bindValue(
":result", sqlText(segment->result()));
segmentInsert.bindValue( segmentInsert.bindValue(
":status", static_cast<int>(segment->status())); ":status", static_cast<int>(segment->status()));
segmentInsert.bindValue( segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs());
":elapsed_ms", segment->elapsedMs()); segmentInsert.bindValue(":timestamp", segment->timestamp());
segmentInsert.bindValue(
":timestamp", segment->timestamp());
if (!segmentInsert.exec()) { if (!segmentInsert.exec()) {
ok = false; ok = false;
qWarning() << "ChatStore: saveSession" << id qWarning() << "ChatStore: saveSession" << id
<< "segment insert failed:" << "segment insert failed:"
<< segmentInsert.lastError().text(); << segmentInsert.lastError().text();
break; break;
} }
} }
@@ -414,211 +393,183 @@ bool ChatStore::saveSession(ChatSession* session) {
} }
} }
if (!ok || !handle.commit()) { if (!ok || !handle.commit()) {
qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back"; qWarning() << "ChatStore: saveSession" << id
<< "commit failed, rolling back";
handle.rollback(); handle.rollback();
ok = false; ok = false;
qWarning() << "ChatStore: failed to save session" << session->id() << ":" qWarning() << "ChatStore: failed to save session" << session->id()
<< handle.lastError().text(); << ":" << handle.lastError().text();
} }
return ok; return ok;
} }
void ChatStore::loadMessagesInto(ChatSession* session) { void ChatStore::loadMessagesInto(ChatSession* session) {
if (!session) if (!session) return;
return;
const QString sessionId = session->id(); const QString sessionId = session->id();
const QString path = m_dbPath; const QString path = m_dbPath;
// SQL on a worker thread (its own connection; QSqlDatabase objects QThreadPool::globalInstance()->start([store = QPointer<ChatStore>(this),
// are thread-affine). Rows come back as plain data. session =
QThreadPool::globalInstance()->start( QPointer<ChatSession>(session),
[store = QPointer<ChatStore>(this), sessionId,
session = QPointer<ChatSession>(session), sessionId, path]() { path]() {
QList<MessageRow> rows; QList<MessageRow> rows;
const QString connName = QUuid::createUuid().toString(); const QString connName = QUuid::createUuid().toString();
{ {
QSqlDatabase db = QSqlDatabase::addDatabase( QSqlDatabase db =
QStringLiteral("QSQLITE"), connName); QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName);
db.setDatabaseName(path); db.setDatabaseName(path);
if (db.open()) { if (db.open()) {
// Tolerate the GUI thread writing while we read. QSqlQuery busy(db);
QSqlQuery busy(db); busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000")); QSqlQuery query(db);
// Newest first so the model receives rows in query.prepare(
// display order. "SELECT id, role, timestamp FROM messages "
QSqlQuery query(db); "WHERE session_id = :id ORDER BY rowid DESC");
query.prepare( query.bindValue(":id", sessionId);
"SELECT id, role, timestamp FROM messages " if (!query.exec()) {
"WHERE session_id = :id ORDER BY rowid DESC"); qWarning() << "ChatStore: failed to load messages for"
query.bindValue(":id", sessionId); << sessionId << ":" << query.lastError().text();
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 { } else {
qWarning() while (query.next()) {
<< "ChatStore: failed to open database for load:" const int messageId = query.value(0).toInt();
<< db.lastError().text(); MessageRow message;
} message.user = query.value(1).toString() ==
} QLatin1String("user");
// Remove only once every QSqlDatabase copy and query is gone; message.timestamp = query.value(2).toLongLong();
// while any reference is alive Qt refuses the removal and the QSqlQuery generationQuery(db);
// connection is left dangling in a broken state. generationQuery.prepare(
QSqlDatabase::removeDatabase(connName); "SELECT id, timestamp, is_active FROM "
"generations WHERE message_id = :mid "
// Build the object tree on the GUI thread. Deliver through "ORDER BY rowid");
// the app instance (never destroyed) and re-check the generationQuery.bindValue(":mid", messageId);
// pointers there: posting to `store` from the pool thread if (generationQuery.exec()) {
// would race with its destruction. while (generationQuery.next()) {
QMetaObject::invokeMethod( GenerationRow generation;
QCoreApplication::instance(), generation.timestamp =
[store, session, rows = std::move(rows)]() mutable { generationQuery.value(1).toLongLong();
ChatStore* st = store; generation.active =
ChatSession* s = session; generationQuery.value(2).toInt() != 0;
if (!st || !s) QSqlQuery segmentQuery(db);
return; segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, "
// Rows fetched from disk; newest first. "arguments, result, status, elapsed_ms, "
auto* model = s->model(); "timestamp FROM segments WHERE "
if (!model) "generation_id = :gid ORDER BY rowid");
return; segmentQuery.bindValue(
QList<ChatMessage*> messages; ":gid", generationQuery.value(0).toInt());
for (const MessageRow& row : rows) { if (segmentQuery.exec()) {
auto* message = model->createMessage( while (segmentQuery.next()) {
row.user ? ChatMessage::Role::User SegmentRow segment;
: ChatMessage::Role::Assistant, segment.type =
row.timestamp); segmentQuery.value(0).toString();
int activeIndex = 0; segment.text =
for (int i = 0; i < row.generations.size(); ++i) { segmentQuery.value(1).toString();
const GenerationRow& generationRow = segment.name =
row.generations.at(i); segmentQuery.value(2).toString();
auto* generation = segment.toolCallId =
message->addGeneration(generationRow.timestamp); segmentQuery.value(3).toString();
for (const SegmentRow& segmentRow : segment.arguments =
generationRow.segments) { segmentQuery.value(4).toString();
auto* segment = new LlmSegment( segment.result =
segmentTypeFromName(segmentRow.type), segmentQuery.value(5).toString();
segmentRow.timestamp, segment.status =
generation); segmentQuery.value(6).toInt();
segment->setText(segmentRow.text); segment.elapsedMs =
segment->setName(segmentRow.name); segmentQuery.value(7).toLongLong();
segment->setToolCallId(segmentRow.toolCallId); segment.timestamp =
segment->appendArguments(segmentRow.arguments); segmentQuery.value(8).toLongLong();
segment->setResult(segmentRow.result); generation.segments.append(segment);
segment->setStatus( }
static_cast<LlmSegment::Status>( } else {
segmentRow.status)); qWarning()
segment->restore(segmentRow.elapsedMs); << "ChatStore: failed to load "
generation->addSegment(segment); "segments for generation"
<< generationQuery.value(0).toInt()
<< ":"
<< segmentQuery.lastError().text();
}
message.generations.append(generation);
} }
if (generationRow.active) } else {
activeIndex = i; qWarning()
<< "ChatStore: failed to load generations "
"for message"
<< messageId << ":"
<< generationQuery.lastError().text();
} }
message->setActiveGeneration(activeIndex); rows.append(message);
messages.append(message);
} }
// Rows added live while the load was in flight are }
// newer than anything on disk; keep them in front. db.close();
if (model->rowCount() > 0) { } else {
QList<ChatMessage*> live = messages; qWarning() << "ChatStore: failed to open database for load:"
for (int r = 0; r < model->rowCount(); ++r) << db.lastError().text();
live.prepend(model->at(r)); }
messages = live; }
} QSqlDatabase::removeDatabase(connName);
if (!messages.isEmpty() || model->rowCount() > 0)
s->adoptMessages(messages);
// Mark loaded only once the model holds both the QMetaObject::invokeMethod(
// fetched history and the rows added live while the QCoreApplication::instance(),
// load ran, so a deferred startGeneration (triggered [store, session, rows = std::move(rows)]() mutable {
// by loaded()) builds its context from the complete ChatStore* st = store;
// conversation. ChatSession* s = session;
s->markLoaded(); if (!st || !s) return;
if (s->takeClearPending()) { auto* model = s->model();
// Cleared while the load was in flight; drop if (!model) return;
// everything now that the model is populated. QList<ChatMessage*> messages;
s->clear(); for (const MessageRow& row : rows) {
} else if (st->m_pendingPersists.remove(s)) { auto* message = model->createMessage(
st->persist(s); 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<LlmSegment::Status>(
segmentRow.status));
segment->restore(segmentRow.elapsedMs);
generation->addSegment(segment);
}
if (generationRow.active) activeIndex = i;
} }
}, message->setActiveGeneration(activeIndex);
Qt::QueuedConnection); messages.append(message);
}); }
if (model->rowCount() > 0) {
QList<ChatMessage*> 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() { void ChatStore::load() {
@@ -647,22 +598,18 @@ void ChatStore::sortAndNotify() {
m_sessions.begin(), m_sessions.begin(),
m_sessions.end(), m_sessions.end(),
[](const ChatSession* a, const ChatSession* b) { [](const ChatSession* a, const ChatSession* b) {
if (a->pinned() != b->pinned()) if (a->pinned() != b->pinned()) return a->pinned() > b->pinned();
return a->pinned() > b->pinned();
return a->updatedAtMs() > b->updatedAtMs(); return a->updatedAtMs() > b->updatedAtMs();
}); });
notify(before); notify(before);
} }
void ChatStore::notify(const QList<ChatSession*>& before) { void ChatStore::notify(const QList<ChatSession*>& before) {
if (before.size() != m_sessions.size()) if (before.size() != m_sessions.size()) Q_EMIT countChanged();
Q_EMIT countChanged();
bool same = before.size() == m_sessions.size(); bool same = before.size() == m_sessions.size();
for (int i = 0; same && i < m_sessions.size(); ++i) for (int i = 0; same && i < m_sessions.size(); ++i)
if (before.at(i) != m_sessions.at(i)) if (before.at(i) != m_sessions.at(i)) same = false;
same = false; if (!same) Q_EMIT valuesChanged();
if (!same)
Q_EMIT valuesChanged();
} }
} // namespace ZShell::llm } // namespace ZShell::llm
-5
View File
@@ -39,9 +39,6 @@ class ChatStore : public QObject {
void persist(ChatSession* session); void persist(ChatSession* session);
void saveMeta(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); void loadMessagesInto(ChatSession* session);
Q_SIGNALS: Q_SIGNALS:
@@ -61,8 +58,6 @@ class ChatStore : public QObject {
LlmClient* m_llmClient = nullptr; LlmClient* m_llmClient = nullptr;
QString m_connectionName; QString m_connectionName;
QString m_dbPath; QString m_dbPath;
// Sessions whose persist() ran before their messages finished
// loading; persisted once the load lands.
QSet<ChatSession*> m_pendingPersists; QSet<ChatSession*> m_pendingPersists;
[[nodiscard]] QSqlDatabase db() const; [[nodiscard]] QSqlDatabase db() const;
-1
View File
@@ -213,7 +213,6 @@ QVariantList CodeHighlighter::lookupSpans(
QMutexLocker locker(&m_cacheMutex); QMutexLocker locker(&m_cacheMutex);
const auto it = m_spanCache.constFind(key); const auto it = m_spanCache.constFind(key);
if (it == m_spanCache.constEnd() || it->code != code) return {}; 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); const qsizetype pos = m_spanCacheOrder.indexOf(key);
if (pos >= 0) m_spanCacheOrder.move(pos, m_spanCacheOrder.size() - 1); if (pos >= 0) m_spanCacheOrder.move(pos, m_spanCacheOrder.size() - 1);
return it->spans; return it->spans;
+16 -51
View File
@@ -15,38 +15,6 @@ class QJSEngine;
namespace ZShell::llm { 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 { class CodeHighlighter : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
@@ -54,17 +22,16 @@ class CodeHighlighter : public QObject {
public: public:
Q_INVOKABLE void highlight( 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*); static CodeHighlighter* create(QQmlEngine*, QJSEngine*);
struct Grammar { struct Grammar {
// Library candidates in priority order (system package, then
// Neovim copies); libs[i] pairs with symbols[i].
std::vector<std::string> libs; std::vector<std::string> libs;
std::vector<std::string> symbols; std::vector<std::string> symbols;
// Candidate query sources in priority order; first that
// compiles against the loaded grammar wins.
std::vector<const char*> queries; std::vector<const char*> queries;
}; };
@@ -73,29 +40,27 @@ class CodeHighlighter : public QObject {
bool bad = false; // permanent failure, do not retry bool bad = false; // permanent failure, do not retry
void* lib = nullptr; void* lib = nullptr;
const void* lang = nullptr; // const TSLanguage* const void* lang = nullptr; // const TSLanguage*
void* query = nullptr; // TSQuery* void* query = nullptr; // TSQuery*
}; };
[[nodiscard]] static const QHash<QString, QString>& aliases(); [[nodiscard]] static const QHash<QString, QString>& 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 uint8_t roleFor(const char* name, uint32_t length);
[[nodiscard]] static const char* roleName(uint8_t role); [[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 resolveId(const QString& language);
[[nodiscard]] static QString cacheKey(const QString& id, const QString& code); [[nodiscard]] static QString cacheKey(
// The parsing work; runs on worker threads, so the per-language const QString& id, const QString& code);
// state must be initialized under m_stateMutex and is shared as an [[nodiscard]] QVariantList doHighlight(
// immutable object afterwards. const QString& code, const QString& language) const;
[[nodiscard]] QVariantList doHighlight(const QString& code, const QString& language) const; [[nodiscard]] QVariantList lookupSpans(
// Exact-match span cache. lookupSpans() runs on the GUI thread, const QString& code, const QString& language) const;
// storeSpans() on worker threads; both take m_cacheMutex. void storeSpans(
[[nodiscard]] QVariantList lookupSpans(const QString& code, const QString& language) const; const QString& code,
void storeSpans(const QString& code, const QString& language, const QString& language,
const QVariantList& spans) const; const QVariantList& spans) const;
struct SpanCacheEntry { struct SpanCacheEntry {
QString code; // re-compared on lookup; a hash collision can QString code; // re-compared on lookup; a hash collision can
// never deliver the wrong spans // never deliver the wrong spans
QVariantList spans; QVariantList spans;
}; };
+44 -76
View File
@@ -5,22 +5,19 @@
namespace ZShell::llm { namespace ZShell::llm {
ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent) ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent)
: QObject(parent), m_timestamp(timestamp) { : QObject(parent), m_timestamp(timestamp) {
m_timer.setParent(this); m_timer.setParent(this);
m_timer.setInterval(500); m_timer.setInterval(500);
m_timer.setTimerType(Qt::CoarseTimer); m_timer.setTimerType(Qt::CoarseTimer);
connect(&m_timer, &QTimer::timeout, this, [this]() { connect(&m_timer, &QTimer::timeout, this, [this]() {
bool anyRunning = false; bool anyRunning = false;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (!segment->running()) if (!segment->running()) continue;
continue;
anyRunning = true; anyRunning = true;
segment->elapsedMsChanged(); segment->elapsedMsChanged();
} }
if (anyRunning) if (anyRunning) Q_EMIT elapsedMsChanged();
Q_EMIT elapsedMsChanged(); if (!anyRunning && !m_streaming) m_timer.stop();
if (!anyRunning && !m_streaming)
m_timer.stop();
}); });
} }
@@ -28,7 +25,7 @@ QString ChatGeneration::content() const {
QStringList parts; QStringList parts;
for (const auto* segment : m_segments) { for (const auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content || if (segment->type() != LlmSegment::Type::Content ||
segment->text().isEmpty()) segment->text().isEmpty())
continue; continue;
parts.append(segment->text()); parts.append(segment->text());
} }
@@ -39,7 +36,7 @@ QString ChatGeneration::reasoning() const {
QStringList parts; QStringList parts;
for (const auto* segment : m_segments) { for (const auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Reasoning || if (segment->type() != LlmSegment::Type::Reasoning ||
segment->text().isEmpty()) segment->text().isEmpty())
continue; continue;
parts.append(segment->text()); parts.append(segment->text());
} }
@@ -47,10 +44,8 @@ QString ChatGeneration::reasoning() const {
} }
bool ChatGeneration::reasoningActive() const { bool ChatGeneration::reasoningActive() const {
if (!m_streaming) if (!m_streaming) return false;
return false; if (!content().isEmpty()) return false;
if (!content().isEmpty())
return false;
return !hasRunningTool(); return !hasRunningTool();
} }
@@ -81,44 +76,35 @@ qint64 ChatGeneration::toolsElapsedMs() const {
int ChatGeneration::toolCallCount() const { int ChatGeneration::toolCallCount() const {
int count = 0; int count = 0;
for (const auto* segment : m_segments) for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall) if (segment->type() == LlmSegment::Type::ToolCall) ++count;
++count;
return count; return count;
} }
bool ChatGeneration::hasRunningTool() const { bool ChatGeneration::hasRunningTool() const {
for (const auto* segment : m_segments) for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall && if (segment->type() == LlmSegment::Type::ToolCall && segment->running())
segment->running())
return true; return true;
return false; return false;
} }
void ChatGeneration::updateReasoningActive() { void ChatGeneration::updateReasoningActive() {
const bool active = reasoningActive(); const bool active = reasoningActive();
if (m_reasoningActive == active) if (m_reasoningActive == active) return;
return;
m_reasoningActive = active; m_reasoningActive = active;
Q_EMIT reasoningActiveChanged(); Q_EMIT reasoningActiveChanged();
} }
void ChatGeneration::setContent(const QString& value) { 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; LlmSegment* first = nullptr;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content) if (segment->type() != LlmSegment::Type::Content) continue;
continue;
if (!first) if (!first)
first = segment; first = segment;
else else
segment->setText(QString()); segment->setText(QString());
} }
if (!first) { if (!first) {
// Do not materialize an empty content segment (e.g. the assistant if (value.isEmpty()) return;
// placeholder created before the stream starts).
if (value.isEmpty())
return;
first = new LlmSegment( first = new LlmSegment(
LlmSegment::Type::Content, LlmSegment::Type::Content,
QDateTime::currentMSecsSinceEpoch(), QDateTime::currentMSecsSinceEpoch(),
@@ -129,35 +115,30 @@ void ChatGeneration::setContent(const QString& value) {
} }
void ChatGeneration::appendContent(const QString& piece) { void ChatGeneration::appendContent(const QString& piece) {
if (piece.isEmpty()) if (piece.isEmpty()) return;
return;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (segment->type() == LlmSegment::Type::Reasoning && if (segment->type() == LlmSegment::Type::Reasoning &&
segment->running()) segment->running())
segment->close(); segment->close();
} }
openContentSegment()->appendText(piece); openContentSegment()->appendText(piece);
} }
void ChatGeneration::appendReasoning(const QString& piece) { void ChatGeneration::appendReasoning(const QString& piece) {
if (piece.isEmpty()) if (piece.isEmpty()) return;
return;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (segment->type() == LlmSegment::Type::Content && if (segment->type() == LlmSegment::Type::Content && segment->running())
segment->running())
segment->close(); segment->close();
} }
openReasoningSegment()->appendText(piece); openReasoningSegment()->appendText(piece);
} }
void ChatGeneration::setStreaming(bool value) { void ChatGeneration::setStreaming(bool value) {
if (m_streaming == value) if (m_streaming == value) return;
return;
m_streaming = value; m_streaming = value;
Q_EMIT streamingChanged(); Q_EMIT streamingChanged();
if (value) { if (value) {
if (!m_timer.isActive()) if (!m_timer.isActive()) m_timer.start();
m_timer.start();
} else { } else {
closeOpenSegments(); closeOpenSegments();
m_timer.stop(); m_timer.stop();
@@ -168,13 +149,10 @@ void ChatGeneration::setStreaming(bool value) {
LlmSegment* ChatGeneration::openContentSegment() { LlmSegment* ChatGeneration::openContentSegment() {
for (auto* segment : m_segments) for (auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::Content && if (segment->type() == LlmSegment::Type::Content && segment->running())
segment->running())
return segment; return segment;
auto* segment = new LlmSegment( auto* segment = new LlmSegment(
LlmSegment::Type::Content, LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), this);
QDateTime::currentMSecsSinceEpoch(),
this);
segment->begin(); segment->begin();
addSegment(segment); addSegment(segment);
return segment; return segment;
@@ -183,12 +161,10 @@ LlmSegment* ChatGeneration::openContentSegment() {
LlmSegment* ChatGeneration::openReasoningSegment() { LlmSegment* ChatGeneration::openReasoningSegment() {
for (auto* segment : m_segments) for (auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::Reasoning && if (segment->type() == LlmSegment::Type::Reasoning &&
segment->running()) segment->running())
return segment; return segment;
auto* segment = new LlmSegment( auto* segment = new LlmSegment(
LlmSegment::Type::Reasoning, LlmSegment::Type::Reasoning, QDateTime::currentMSecsSinceEpoch(), this);
QDateTime::currentMSecsSinceEpoch(),
this);
segment->begin(); segment->begin();
addSegment(segment); addSegment(segment);
return segment; return segment;
@@ -198,9 +174,7 @@ LlmSegment* ChatGeneration::beginToolCall(
const QString& name, const QString& toolCallId) { const QString& name, const QString& toolCallId) {
closeOpenSegments(); closeOpenSegments();
auto* segment = new LlmSegment( auto* segment = new LlmSegment(
LlmSegment::Type::ToolCall, LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this);
QDateTime::currentMSecsSinceEpoch(),
this);
segment->setName(name); segment->setName(name);
segment->setToolCallId(toolCallId); segment->setToolCallId(toolCallId);
segment->setStatus(LlmSegment::Status::Running); segment->setStatus(LlmSegment::Status::Running);
@@ -211,39 +185,33 @@ LlmSegment* ChatGeneration::beginToolCall(
} }
void ChatGeneration::addSegment(LlmSegment* segment) { void ChatGeneration::addSegment(LlmSegment* segment) {
if (!segment || m_segments.contains(segment)) if (!segment || m_segments.contains(segment)) return;
return;
segment->setParent(this); segment->setParent(this);
connect( connect(segment, &LlmSegment::textChanged, this, [this, segment]() {
segment, &LlmSegment::textChanged, this, [this, segment]() { if (segment->type() == LlmSegment::Type::Reasoning)
if (segment->type() == LlmSegment::Type::Reasoning) Q_EMIT reasoningChanged();
Q_EMIT reasoningChanged(); else if (segment->type() == LlmSegment::Type::Content)
else if (segment->type() == LlmSegment::Type::Content) Q_EMIT contentChanged();
Q_EMIT contentChanged(); updateReasoningActive();
updateReasoningActive(); });
}); connect(segment, &LlmSegment::statusChanged, this, [this]() {
connect( Q_EMIT toolStateChanged();
segment, &LlmSegment::statusChanged, this, [this]() { });
Q_EMIT toolStateChanged(); connect(segment, &LlmSegment::resultChanged, this, [this]() {
}); Q_EMIT toolStateChanged();
connect( });
segment, &LlmSegment::resultChanged, this, [this]() { connect(segment, &LlmSegment::runningChanged, this, [this]() {
Q_EMIT toolStateChanged(); Q_EMIT elapsedMsChanged();
}); Q_EMIT toolStateChanged();
connect( updateReasoningActive();
segment, &LlmSegment::runningChanged, this, [this]() { });
Q_EMIT elapsedMsChanged();
Q_EMIT toolStateChanged();
updateReasoningActive();
});
m_segments.append(segment); m_segments.append(segment);
Q_EMIT segmentsChanged(); Q_EMIT segmentsChanged();
} }
void ChatGeneration::closeOpenSegments() { void ChatGeneration::closeOpenSegments() {
for (auto* segment : m_segments) for (auto* segment : m_segments)
if (segment->running()) if (segment->running()) segment->close();
segment->close();
updateReasoningActive(); updateReasoningActive();
} }
+11 -22
View File
@@ -10,39 +10,35 @@
namespace ZShell::llm { 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 { class ChatGeneration : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
QML_UNCREATABLE("Chat generations are managed by ChatMessage") 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(QString reasoning READ reasoning NOTIFY reasoningChanged)
Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) Q_PROPERTY(
Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY
elapsedMsChanged)
Q_PROPERTY(
qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged) Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) 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(bool hasRunningTool READ hasRunningTool NOTIFY toolStateChanged)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY( Q_PROPERTY(
QList<ZShell::llm::LlmSegment*> segments READ segments QList<ZShell::llm::LlmSegment*> segments READ segments NOTIFY
NOTIFY segmentsChanged) segmentsChanged)
Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged) Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged)
public: public:
explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr);
// All content bursts, joined (the model's full answer).
[[nodiscard]] QString content() const; [[nodiscard]] QString content() const;
// Every reasoning burst, joined.
[[nodiscard]] QString reasoning() const; [[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]] bool reasoningActive() const;
[[nodiscard]] qint64 reasoningElapsedMs() const; [[nodiscard]] qint64 reasoningElapsedMs() const;
[[nodiscard]] qint64 contentElapsedMs() const; [[nodiscard]] qint64 contentElapsedMs() const;
@@ -58,18 +54,11 @@ class ChatGeneration : public QObject {
void appendReasoning(const QString& piece); void appendReasoning(const QString& piece);
void setStreaming(bool value); 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(); [[nodiscard]] LlmSegment* openContentSegment();
// The in-flight reasoning segment, or a fresh one.
[[nodiscard]] LlmSegment* openReasoningSegment(); [[nodiscard]] LlmSegment* openReasoningSegment();
// Creates and appends a running tool-call segment.
[[nodiscard]] LlmSegment* beginToolCall( [[nodiscard]] LlmSegment* beginToolCall(
const QString& name, const QString& toolCallId); const QString& name, const QString& toolCallId);
// Appends a segment created by the persistence layer.
void addSegment(LlmSegment* segment); void addSegment(LlmSegment* segment);
// Stops the clocks of every in-flight segment.
void closeOpenSegments(); void closeOpenSegments();
Q_SIGNALS: Q_SIGNALS:
+151 -241
View File
@@ -22,8 +22,7 @@ QString LlmClient::completionsPath(
QString base = endpoint.trimmed(); QString base = endpoint.trimmed();
while (base.endsWith('/')) while (base.endsWith('/'))
base.chop(1); base.chop(1);
if (!base.endsWith("/v1")) if (!base.endsWith("/v1")) base += "/v1";
base += "/v1";
return base + subpath; return base + subpath;
} }
@@ -37,28 +36,24 @@ QString LlmClient::serverErrorMessage(
if (errorValue.isObject()) { if (errorValue.isObject()) {
const QString message = const QString message =
errorValue.toObject()["message"].toString(); errorValue.toObject()["message"].toString();
if (!message.isEmpty()) if (!message.isEmpty()) return message;
return message;
} else if (!errorValue.toString().isEmpty()) { } else if (!errorValue.toString().isEmpty()) {
return errorValue.toString(); return errorValue.toString();
} }
} }
} }
return fallback.isEmpty() return fallback.isEmpty() ? QStringLiteral("Request to LLM server failed")
? QStringLiteral("Request to LLM server failed") : fallback;
: fallback;
} }
void LlmClient::setBusy(bool value) { void LlmClient::setBusy(bool value) {
if (m_busy == value) if (m_busy == value) return;
return;
m_busy = value; m_busy = value;
Q_EMIT busyChanged(); Q_EMIT busyChanged();
} }
void LlmClient::setStreamingChatId(const QString& id) { void LlmClient::setStreamingChatId(const QString& id) {
if (m_streamingChatId == id) if (m_streamingChatId == id) return;
return;
m_streamingChatId = id; m_streamingChatId = id;
Q_EMIT streamingChatIdChanged(); Q_EMIT streamingChatIdChanged();
} }
@@ -74,39 +69,32 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) {
} }
LlmClient::~LlmClient() { LlmClient::~LlmClient() {
if (m_reply) if (m_reply) m_reply->abort();
m_reply->abort();
m_tools->cancelAll(); m_tools->cancelAll();
endStream(); endStream();
} }
void LlmClient::setEndpoint(const QString& value) { void LlmClient::setEndpoint(const QString& value) {
if (m_endpoint == value) if (m_endpoint == value) return;
return;
m_endpoint = value; m_endpoint = value;
Q_EMIT endpointChanged(); Q_EMIT endpointChanged();
probeContextSize(); probeContextSize();
if (m_model.isEmpty()) if (m_model.isEmpty()) refreshModels();
refreshModels();
} }
void LlmClient::setModel(const QString& value) { void LlmClient::setModel(const QString& value) {
if (m_model == value) if (m_model == value) return;
return;
m_model = value; m_model = value;
Q_EMIT modelChanged(); Q_EMIT modelChanged();
if (m_model.isEmpty()) if (m_model.isEmpty()) refreshModels();
refreshModels();
} }
void LlmClient::setTemperature(double value) { void LlmClient::setTemperature(double value) {
m_temperature = value; m_temperature = value;
} }
void LlmClient::startGeneration( void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
ChatSession* session, ChatGeneration* target) { if (m_busy || !session || !target) return;
if (m_busy || !session || !target)
return;
m_active = session; m_active = session;
m_streaming = target; m_streaming = target;
m_streaming->setStreaming(true); m_streaming->setStreaming(true);
@@ -124,8 +112,9 @@ void LlmClient::startGeneration(
const int targetRow = const int targetRow =
model->rowOf(qobject_cast<ChatMessage*>(target->parent())); model->rowOf(qobject_cast<ChatMessage*>(target->parent()));
if (targetRow < 0) { if (targetRow < 0) {
fail(QStringLiteral("Internal error: generation target is not in the " fail(QStringLiteral(
"session")); "Internal error: generation target is not in the "
"session"));
return; return;
} }
@@ -141,8 +130,7 @@ void LlmClient::startGeneration(
} }
void LlmClient::sendRound() { void LlmClient::sendRound() {
if (!m_active || !m_streaming) if (!m_active || !m_streaming) return;
return;
m_finishReason.clear(); m_finishReason.clear();
m_callBuilders.clear(); m_callBuilders.clear();
m_callResults.clear(); m_callResults.clear();
@@ -162,13 +150,12 @@ void LlmClient::sendRound() {
const int targetRow = const int targetRow =
model->rowOf(qobject_cast<ChatMessage*>(m_streaming->parent())); model->rowOf(qobject_cast<ChatMessage*>(m_streaming->parent()));
if (targetRow < 0) { if (targetRow < 0) {
fail(QStringLiteral("Internal error: generation target is not in the " fail(QStringLiteral(
"session")); "Internal error: generation target is not in the "
"session"));
return; 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); QJsonArray messages = buildContextMessages(m_active, targetRow);
for (const QJsonValue& value : m_transcript) for (const QJsonValue& value : m_transcript)
messages.append(value); messages.append(value);
@@ -180,11 +167,9 @@ void LlmClient::sendRound() {
body[QStringLiteral("messages")] = messages; body[QStringLiteral("messages")] = messages;
body[QStringLiteral("stream")] = true; body[QStringLiteral("stream")] = true;
body[QStringLiteral("temperature")] = m_temperature; body[QStringLiteral("temperature")] = m_temperature;
if (!m_model.isEmpty()) if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model;
body[QStringLiteral("model")] = m_model;
const QJsonArray toolSpecs = m_tools->specifications(); const QJsonArray toolSpecs = m_tools->specifications();
if (!toolSpecs.isEmpty()) if (!toolSpecs.isEmpty()) body[QStringLiteral("tools")] = toolSpecs;
body[QStringLiteral("tools")] = toolSpecs;
QNetworkRequest request(url); QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
@@ -193,26 +178,22 @@ void LlmClient::sendRound() {
m_reply = m_manager.post(request, QJsonDocument(body).toJson()); m_reply = m_manager.post(request, QJsonDocument(body).toJson());
connect(m_reply, &QNetworkReply::readyRead, this, [this]() { connect(m_reply, &QNetworkReply::readyRead, this, [this]() {
if (m_reply) if (m_reply) m_buffer.append(m_reply->readAll());
m_buffer.append(m_reply->readAll());
drainBuffer(); drainBuffer();
}); });
connect(m_reply, &QNetworkReply::finished, this, [this]() { connect(m_reply, &QNetworkReply::finished, this, [this]() {
QNetworkReply* reply = m_reply; QNetworkReply* reply = m_reply;
if (!reply) if (!reply) return;
return;
m_reply = nullptr; m_reply = nullptr;
const QNetworkReply::NetworkError error = reply->error(); const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString(); const QString errorString = reply->errorString();
// Data not already consumed by readyRead is only reachable here.
const QByteArray responseBody = reply->readAll(); const QByteArray responseBody = reply->readAll();
m_buffer.append(responseBody); m_buffer.append(responseBody);
reply->deleteLater(); reply->deleteLater();
drainBuffer(); drainBuffer();
if (!m_streaming) if (!m_streaming) return;
return;
if (error == QNetworkReply::NoError) if (error == QNetworkReply::NoError)
roundFinished(); roundFinished();
@@ -230,8 +211,7 @@ QJsonArray LlmClient::buildContextMessages(
for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) {
const auto* message = model->at(row); const auto* message = model->at(row);
const auto* generation = message->activeGeneration(); const auto* generation = message->activeGeneration();
if (!generation) if (!generation) continue;
continue;
if (message->role() == ChatMessage::Role::User) { if (message->role() == ChatMessage::Role::User) {
QJsonObject user; QJsonObject user;
@@ -241,8 +221,6 @@ QJsonArray LlmClient::buildContextMessages(
continue; continue;
} }
// Assistant message: replay its tool calls (and their results)
// so the model keeps the full history of the turn.
QList<const LlmSegment*> toolSegments; QList<const LlmSegment*> toolSegments;
for (const auto* segment : generation->segments()) for (const auto* segment : generation->segments())
if (segment->type() == LlmSegment::Type::ToolCall) if (segment->type() == LlmSegment::Type::ToolCall)
@@ -278,8 +256,7 @@ QJsonArray LlmClient::buildContextMessages(
for (const auto* segment : toolSegments) { for (const auto* segment : toolSegments) {
QJsonObject toolMessage; QJsonObject toolMessage;
toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
toolMessage[QStringLiteral("tool_call_id")] = toolMessage[QStringLiteral("tool_call_id")] = segment->toolCallId();
segment->toolCallId();
toolMessage[QStringLiteral("content")] = segment->result(); toolMessage[QStringLiteral("content")] = segment->result();
messages.append(toolMessage); messages.append(toolMessage);
} }
@@ -288,51 +265,37 @@ QJsonArray LlmClient::buildContextMessages(
} }
void LlmClient::applyToolCallDelta(const QJsonObject& call) { void LlmClient::applyToolCallDelta(const QJsonObject& call) {
if (!m_streaming) if (!m_streaming) return;
return;
const int index = call[QStringLiteral("index")].toInt(-1); const int index = call[QStringLiteral("index")].toInt(-1);
if (index < 0) if (index < 0) return;
return;
while (m_callBuilders.size() <= index) while (m_callBuilders.size() <= index)
m_callBuilders.append(ToolCallBuilder{}); m_callBuilders.append(ToolCallBuilder{});
ToolCallBuilder& builder = m_callBuilders[index]; ToolCallBuilder& builder = m_callBuilders[index];
builder.seen = true; builder.seen = true;
const QString id = call[QStringLiteral("id")].toString(); const QString id = call[QStringLiteral("id")].toString();
if (!id.isEmpty()) if (!id.isEmpty()) builder.id = id;
builder.id = id;
const QJsonObject function = call[QStringLiteral("function")].toObject(); const QJsonObject function = call[QStringLiteral("function")].toObject();
const QString name = function[QStringLiteral("name")].toString(); const QString name = function[QStringLiteral("name")].toString();
if (!name.isEmpty()) if (!name.isEmpty()) builder.name = name;
builder.name = name; const QString arguments = function[QStringLiteral("arguments")].toString();
const QString arguments = if (!arguments.isEmpty()) builder.arguments += arguments;
function[QStringLiteral("arguments")].toString();
if (!arguments.isEmpty())
builder.arguments += arguments;
if (!builder.segment) { 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(); m_streaming->closeOpenSegments();
builder.segment = builder.segment = m_streaming->beginToolCall(builder.name, builder.id);
m_streaming->beginToolCall(builder.name, builder.id);
} }
builder.segment->setName(builder.name); builder.segment->setName(builder.name);
builder.segment->setToolCallId(builder.id); builder.segment->setToolCallId(builder.id);
if (!arguments.isEmpty()) if (!arguments.isEmpty()) builder.segment->appendArguments(arguments);
builder.segment->appendArguments(arguments);
} }
void LlmClient::roundFinished() { void LlmClient::roundFinished() {
// [DONE] and the reply's finished signal both funnel here; only the if (m_roundDone || !m_streaming) return;
// first may act.
if (m_roundDone || !m_streaming)
return;
m_roundDone = true; m_roundDone = true;
bool hasCalls = false; bool hasCalls = false;
for (const auto& builder : m_callBuilders) for (const auto& builder : m_callBuilders)
if (builder.seen) if (builder.seen) hasCalls = true;
hasCalls = true;
if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) { if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) {
finishTurn(); finishTurn();
return; return;
@@ -345,8 +308,6 @@ void LlmClient::roundFinished() {
m_streaming->closeOpenSegments(); 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 content = m_streaming->content();
const QString reasoning = m_streaming->reasoning(); const QString reasoning = m_streaming->reasoning();
QJsonObject assistant; QJsonObject assistant;
@@ -362,8 +323,7 @@ void LlmClient::roundFinished() {
} }
QJsonArray calls; QJsonArray calls;
for (const auto& builder : m_callBuilders) { for (const auto& builder : m_callBuilders) {
if (!builder.seen) if (!builder.seen) continue;
continue;
QJsonObject function; QJsonObject function;
function[QStringLiteral("name")] = builder.name; function[QStringLiteral("name")] = builder.name;
function[QStringLiteral("arguments")] = builder.arguments; function[QStringLiteral("arguments")] = builder.arguments;
@@ -387,112 +347,95 @@ void LlmClient::executeAllCalls() {
for (int i = 0; i < m_callBuilders.size(); ++i) { for (int i = 0; i < m_callBuilders.size(); ++i) {
const auto& call = m_callBuilders.at(i); const auto& call = m_callBuilders.at(i);
if (!call.seen) if (!call.seen) continue;
continue;
LlmTool* tool = m_tools->tool(call.name); LlmTool* tool = m_tools->tool(call.name);
QJsonObject args; QJsonObject args;
QString errorText; QString errorText;
if (!tool) { if (!tool) {
errorText = QStringLiteral("Error: unknown tool '%1'") errorText =
.arg(call.name); QStringLiteral("Error: unknown tool '%1'").arg(call.name);
} else if (!call.arguments.isEmpty()) { } else if (!call.arguments.isEmpty()) {
const QJsonDocument doc = const QJsonDocument doc =
QJsonDocument::fromJson(call.arguments.toUtf8()); QJsonDocument::fromJson(call.arguments.toUtf8());
if (!doc.isObject()) { if (!doc.isObject()) {
errorText = errorText = QStringLiteral(
QStringLiteral("Error: tool arguments are not valid " "Error: tool arguments are not valid "
"JSON: %1") "JSON: %1")
.arg(call.arguments); .arg(call.arguments);
} else { } else {
args = doc.object(); args = doc.object();
} }
} }
if (!errorText.isEmpty()) { if (!errorText.isEmpty()) {
m_callResults[i] = { errorText, false }; m_callResults[i] = {errorText, false};
if (LlmSegment* segment = call.segment) if (LlmSegment* segment = call.segment)
segment->finishTool(errorText, false); segment->finishTool(errorText, false);
continue; continue;
} }
++m_pendingCalls; ++m_pendingCalls;
tool->execute( tool->execute(args, [this, i, call](const QJsonObject& result) {
args, if (!m_streaming) return;
[this, i, call](const QJsonObject& result) { const bool success = result.contains(QStringLiteral("output"));
if (!m_streaming) const QString content =
return; success ? result[QStringLiteral("output")].toString()
const bool success = : QStringLiteral("Error: ") +
result.contains(QStringLiteral("output")); result[QStringLiteral("error")].toString();
const QString content = success m_callResults[i] = {content, success};
? result[QStringLiteral("output")].toString() if (LlmSegment* segment = call.segment)
: QStringLiteral("Error: ") + segment->finishTool(content, success);
result[QStringLiteral("error")].toString(); if (--m_pendingCalls == 0) flushCallResults();
m_callResults[i] = { content, success }; });
if (LlmSegment* segment = call.segment)
segment->finishTool(content, success);
if (--m_pendingCalls == 0)
flushCallResults();
});
} }
if (m_pendingCalls == 0) if (m_pendingCalls == 0) flushCallResults();
flushCallResults();
} }
void LlmClient::flushCallResults() { void LlmClient::flushCallResults() {
if (!m_toolPhase) if (!m_toolPhase) return;
return;
m_toolPhase = false; m_toolPhase = false;
if (!m_streaming) if (!m_streaming) return;
return;
for (int i = 0; i < m_callBuilders.size(); ++i) { for (int i = 0; i < m_callBuilders.size(); ++i) {
const auto& call = m_callBuilders.at(i); const auto& call = m_callBuilders.at(i);
if (!call.seen) if (!call.seen) continue;
continue;
QJsonObject toolMessage; QJsonObject toolMessage;
toolMessage[QStringLiteral("role")] = QStringLiteral("tool"); toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
toolMessage[QStringLiteral("tool_call_id")] = call.id; toolMessage[QStringLiteral("tool_call_id")] = call.id;
toolMessage[QStringLiteral("content")] = toolMessage[QStringLiteral("content")] = m_callResults.at(i).content;
m_callResults.at(i).content;
m_transcript.append(toolMessage); m_transcript.append(toolMessage);
} }
sendRound(); sendRound();
} }
void LlmClient::stop() { void LlmClient::stop() {
if (!m_busy) if (!m_busy) return;
return;
if (m_toolPhase) { if (m_toolPhase) {
m_tools->cancelAll(); m_tools->cancelAll();
if (m_streaming) { if (m_streaming) {
for (auto* segment : m_streaming->segments()) { for (auto* segment : m_streaming->segments()) {
if (segment->type() == LlmSegment::Type::ToolCall && if (segment->type() == LlmSegment::Type::ToolCall &&
segment->running()) segment->running())
segment->finishTool( segment->finishTool(QStringLiteral("Cancelled"), false);
QStringLiteral("Cancelled"), false);
} }
} }
finishTurn(); finishTurn();
return; return;
} }
if (m_reply) if (m_reply) m_reply->abort();
m_reply->abort();
} }
void LlmClient::endStream() { void LlmClient::endStream() {
if (!m_streaming) if (!m_streaming) return;
return;
auto* generation = m_streaming; auto* generation = m_streaming;
auto* session = m_active; auto* session = m_active;
m_streaming = nullptr; m_streaming = nullptr;
m_active = nullptr; m_active = nullptr;
generation->setStreaming(false); generation->setStreaming(false);
if (generation->content().isEmpty() && if (generation->content().isEmpty() && generation->reasoning().isEmpty() &&
generation->reasoning().isEmpty() && generation->toolCallCount() == 0) {
generation->toolCallCount() == 0) {
if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) { if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) {
if (message->generationCount() <= 1) { if (message->generationCount() <= 1) {
if (session) if (session) session->removeMessage(message);
session->removeMessage(message);
} else { } else {
message->removeGeneration(generation); message->removeGeneration(generation);
} }
@@ -503,16 +446,14 @@ void LlmClient::endStream() {
} }
void LlmClient::finishTurn() { void LlmClient::finishTurn() {
if (!m_streaming) if (!m_streaming) return;
return;
ChatSession* session = m_active; ChatSession* session = m_active;
endStream(); endStream();
if (session && m_pendingClear == session) { if (session && m_pendingClear == session) {
session->clearMessages(); session->clearMessages();
m_pendingClear.clear(); m_pendingClear.clear();
} }
if (session) if (session) session->persist();
session->persist();
} }
void LlmClient::clearOnFinish(ChatSession* session) { void LlmClient::clearOnFinish(ChatSession* session) {
@@ -520,8 +461,7 @@ void LlmClient::clearOnFinish(ChatSession* session) {
} }
void LlmClient::sessionRemoved(ChatSession* session) { void LlmClient::sessionRemoved(ChatSession* session) {
if (m_pendingClear == session) if (m_pendingClear == session) m_pendingClear.clear();
m_pendingClear.clear();
if (m_active == session) { if (m_active == session) {
stop(); stop();
endStream(); endStream();
@@ -537,8 +477,7 @@ void LlmClient::fail(const QString& message) {
void LlmClient::drainBuffer() { void LlmClient::drainBuffer() {
while (true) { while (true) {
const qsizetype newline = m_buffer.indexOf('\n'); const qsizetype newline = m_buffer.indexOf('\n');
if (newline < 0) if (newline < 0) break;
break;
const QByteArray line = m_buffer.left(newline).trimmed(); const QByteArray line = m_buffer.left(newline).trimmed();
m_buffer.remove(0, newline + 1); m_buffer.remove(0, newline + 1);
handleLine(line); handleLine(line);
@@ -546,8 +485,7 @@ void LlmClient::drainBuffer() {
} }
void LlmClient::handleLine(const QByteArray& line) { void LlmClient::handleLine(const QByteArray& line) {
if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) if (!m_streaming || line.isEmpty() || !line.startsWith("data:")) return;
return;
const QByteArray data = line.mid(5).trimmed(); const QByteArray data = line.mid(5).trimmed();
if (data == "[DONE]") { if (data == "[DONE]") {
@@ -556,42 +494,35 @@ void LlmClient::handleLine(const QByteArray& line) {
} }
const QJsonDocument doc = QJsonDocument::fromJson(data); const QJsonDocument doc = QJsonDocument::fromJson(data);
if (!doc.isObject()) if (!doc.isObject()) return;
return;
const QJsonObject obj = doc.object(); const QJsonObject obj = doc.object();
updateTokenUsage(obj); updateTokenUsage(obj);
if (obj.contains("error")) { if (obj.contains("error")) {
const QJsonObject error = obj["error"].toObject(); const QJsonObject error = obj["error"].toObject();
const QString message = error["message"].toString(); const QString message = error["message"].toString();
fail(message.isEmpty() fail(
? QStringLiteral("LLM server returned an error") message.isEmpty() ? QStringLiteral("LLM server returned an error")
: message); : message);
return; return;
} }
for (const QJsonValue& choiceValue : obj["choices"].toArray()) { for (const QJsonValue& choiceValue : obj["choices"].toArray()) {
if (!m_streaming) if (!m_streaming) continue;
continue;
const QJsonObject choice = choiceValue.toObject(); const QJsonObject choice = choiceValue.toObject();
const QJsonObject delta = choice["delta"].toObject(); const QJsonObject delta = choice["delta"].toObject();
const QString finishReason = const QString finishReason =
choice[QStringLiteral("finish_reason")].toString(); choice[QStringLiteral("finish_reason")].toString();
if (!finishReason.isEmpty()) if (!finishReason.isEmpty()) m_finishReason = finishReason;
m_finishReason = finishReason;
m_streaming->appendContent(delta["content"].toString()); m_streaming->appendContent(delta["content"].toString());
QString reasoning = QString reasoning = delta["reasoning_content"].toString();
delta["reasoning_content"].toString(); if (reasoning.isEmpty()) reasoning = delta["reasoning"].toString();
if (reasoning.isEmpty())
reasoning = delta["reasoning"].toString();
m_streaming->appendReasoning(reasoning); m_streaming->appendReasoning(reasoning);
for (const QJsonValue& callValue : for (const QJsonValue& callValue : delta["tool_calls"].toArray()) {
delta["tool_calls"].toArray()) { if (!m_streaming) break;
if (!m_streaming)
break;
applyToolCallDelta(callValue.toObject()); applyToolCallDelta(callValue.toObject());
} }
} }
@@ -600,8 +531,7 @@ void LlmClient::handleLine(const QByteArray& line) {
void LlmClient::refreshModels() { void LlmClient::refreshModels() {
const QUrl url = const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
if (!url.isValid() || url.host().isEmpty()) if (!url.isValid() || url.host().isEmpty()) return;
return;
auto* reply = m_manager.get(QNetworkRequest(url)); auto* reply = m_manager.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() { connect(reply, &QNetworkReply::finished, this, [this, reply]() {
@@ -625,8 +555,7 @@ void LlmClient::refreshModels() {
models.append(id); models.append(id);
} }
} }
if (models.isEmpty()) if (models.isEmpty()) return;
return;
m_availableModels = models; m_availableModels = models;
Q_EMIT availableModelsChanged(); Q_EMIT availableModelsChanged();
@@ -639,66 +568,55 @@ void LlmClient::refreshModels() {
} }
void LlmClient::setContextSize(int size) { void LlmClient::setContextSize(int size) {
if (size <= 0 || m_contextSize == size) if (size <= 0 || m_contextSize == size) return;
return;
m_contextSize = size; m_contextSize = size;
Q_EMIT contextSizeChanged(); Q_EMIT contextSizeChanged();
} }
void LlmClient::probeContextSize() { void LlmClient::probeContextSize() {
// llama.cpp-specific endpoint; other servers fall back to 4096.
QString base = m_endpoint.trimmed(); QString base = m_endpoint.trimmed();
while (base.endsWith('/')) while (base.endsWith('/'))
base.chop(1); base.chop(1);
const QUrl url = QUrl::fromUserInput(base + "/props"); const QUrl url = QUrl::fromUserInput(base + "/props");
if (!url.isValid() || url.host().isEmpty()) if (!url.isValid() || url.host().isEmpty()) return;
return;
auto* reply = m_manager.get(QNetworkRequest(url)); auto* reply = m_manager.get(QNetworkRequest(url));
connect( connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply, const QNetworkReply::NetworkError error = reply->error();
&QNetworkReply::finished, const QByteArray data = reply->readAll();
this, reply->deleteLater();
[this, reply]() {
const QNetworkReply::NetworkError error = reply->error();
const QByteArray data = reply->readAll();
reply->deleteLater();
int size = 0; int size = 0;
if (error == QNetworkReply::NoError) { if (error == QNetworkReply::NoError) {
const QJsonDocument doc = QJsonDocument::fromJson(data); const QJsonDocument doc = QJsonDocument::fromJson(data);
if (doc.isArray()) { if (doc.isArray()) {
for (const auto& value : doc.array()) { for (const auto& value : doc.array()) {
const QJsonObject slot = value.toObject(); const QJsonObject slot = value.toObject();
if (slot.contains("n_ctx")) { if (slot.contains("n_ctx")) {
size = slot["n_ctx"].toInt(0); size = slot["n_ctx"].toInt(0);
if (size > 0) if (size > 0) break;
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) { void LlmClient::updateTokenUsage(const QJsonObject& data) {
if (!m_active || m_contextSize <= 0) if (!m_active || m_contextSize <= 0) return;
return;
const QJsonObject usage = data["usage"].toObject(); const QJsonObject usage = data["usage"].toObject();
if (usage.isEmpty()) if (usage.isEmpty()) return;
return; const double used = usage.value("prompt_tokens").toDouble() +
const double used = usage.value("completion_tokens").toDouble();
usage.value("prompt_tokens").toDouble() + if (used > 0) m_active->setLastTokenCount(static_cast<int>(used));
usage.value("completion_tokens").toDouble();
if (used > 0)
m_active->setLastTokenCount(static_cast<int>(used));
} }
void LlmClient::shortRequest( void LlmClient::shortRequest(
@@ -708,11 +626,10 @@ void LlmClient::shortRequest(
std::function<void(QString result)> onResult) { std::function<void(QString result)> onResult) {
const QUrl url = const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions")); QUrl::fromUserInput(completionsPath(m_endpoint, "/chat/completions"));
if (!url.isValid() || url.host().isEmpty()) if (!url.isValid() || url.host().isEmpty()) return;
return;
qInfo() << "LlmClient:" << tag << "request POST" << url.toString() qInfo() << "LlmClient:" << tag << "request POST" << url.toString()
<< "model=" << m_model; << "model=" << m_model;
QNetworkRequest request(url); QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
@@ -729,8 +646,7 @@ void LlmClient::shortRequest(
messages.append(user); messages.append(user);
QJsonObject body; QJsonObject body;
if (!m_model.isEmpty()) if (!m_model.isEmpty()) body[QStringLiteral("model")] = m_model;
body[QStringLiteral("model")] = m_model;
body[QStringLiteral("stream")] = false; body[QStringLiteral("stream")] = false;
body[QStringLiteral("temperature")] = 0.3; body[QStringLiteral("temperature")] = 0.3;
body[QStringLiteral("max_tokens")] = 128; body[QStringLiteral("max_tokens")] = 128;
@@ -749,25 +665,25 @@ void LlmClient::shortRequest(
reply->deleteLater(); reply->deleteLater();
qInfo() << "LlmClient:" << tag << "request finished" qInfo() << "LlmClient:" << tag << "request finished"
<< "error=" << reply->error() << reply->errorString() << "error=" << reply->error() << reply->errorString()
<< "http=" << reply->attribute( << "http="
QNetworkRequest::HttpStatusCodeAttribute) << reply
.toInt() ->attribute(QNetworkRequest::HttpStatusCodeAttribute)
<< "response=" .toInt()
<< QString::fromUtf8(data.left(400)).simplified(); << "response="
<< QString::fromUtf8(data.left(400)).simplified();
if (reply->error() != QNetworkReply::NoError) if (reply->error() != QNetworkReply::NoError) return;
return;
const QJsonDocument doc = QJsonDocument::fromJson(data); const QJsonDocument doc = QJsonDocument::fromJson(data);
const QJsonArray choices = const QJsonArray choices =
doc.object()[QStringLiteral("choices")].toArray(); doc.object()[QStringLiteral("choices")].toArray();
if (choices.isEmpty()) if (choices.isEmpty()) return;
return; const QString result = choices.at(0)
const QString result = .toObject()[QStringLiteral("message")]
choices.at(0).toObject()[QStringLiteral("message")].toObject() .toObject()[QStringLiteral("content")]
[QStringLiteral("content")].toString() .toString()
.trimmed(); .trimmed();
qInfo() << "LlmClient:" << tag << "raw result" << result; qInfo() << "LlmClient:" << tag << "raw result" << result;
onResult(result); onResult(result);
}); });
@@ -788,24 +704,21 @@ void LlmClient::requestTitle(ChatSession* session, const QString& userText) {
} }
const auto isQuote = [](QChar c) { const auto isQuote = [](QChar c) {
return c == QLatin1Char('"') || c == QLatin1Char('\'') || return c == QLatin1Char('"') || c == QLatin1Char('\'') ||
c == QChar(u'\u201C') || c == QChar(u'\u201D') || c == QChar(u'\u201C') || c == QChar(u'\u201D') ||
c == QChar(u'\u2018') || c == QChar(u'\u2019'); c == QChar(u'\u2018') || c == QChar(u'\u2019');
}; };
while (title.size() >= 2 && isQuote(title.at(0)) && 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(); title = title.mid(1, title.size() - 2).simplified();
while (!title.isEmpty() && while (!title.isEmpty() && (title.endsWith(QLatin1Char('.')) ||
(title.endsWith(QLatin1Char('.')) || title.endsWith(QLatin1Char('!')) ||
title.endsWith(QLatin1Char('!')) || title.endsWith(QLatin1Char('?'))))
title.endsWith(QLatin1Char('?'))))
title.chop(1); title.chop(1);
if (title.size() < 2) { if (title.size() < 2) {
qWarning() << "LlmClient: title rejected (too short)" qWarning() << "LlmClient: title rejected (too short)" << title;
<< title;
return; return;
} }
if (title.size() > 48) if (title.size() > 48) title = title.left(47) + QStringLiteral("…");
title = title.left(47) + QStringLiteral("…");
qInfo() << "LlmClient: suggesting title" << title; qInfo() << "LlmClient: suggesting title" << title;
Q_EMIT titleSuggested(session, 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) { void LlmClient::requestIcon(ChatSession* session, const QString& userText) {
const QStringList icons = { const QStringList icons = {
"chat", "lightbulb", "code", "chat", "lightbulb", "code", "description",
"description", "article", "school", "article", "school", "work", "build",
"work", "build", "science", "science", "palette", "music_note", "sports_esports",
"palette", "music_note", "sports_esports", "takeout_dining", "flight", "photo_camera", "psychology_alt",
"takeout_dining", "flight", "photo_camera", "favorite", "savings", "gamepad", "auto_awesome",
"psychology_alt", "favorite", "savings",
"gamepad", "auto_awesome",
}; };
const QString prompt = const QString prompt =
QStringLiteral( QStringLiteral(
@@ -831,8 +742,7 @@ void LlmClient::requestIcon(ChatSession* session, const QString& userText) {
QStringLiteral("icon"), QStringLiteral("icon"),
prompt, prompt,
userText, userText,
[this, session = QPointer<ChatSession>(session), icons]( [this, session = QPointer<ChatSession>(session), icons](QString name) {
QString name) {
if (!session) { if (!session) {
qWarning() << "LlmClient: icon request: session gone"; qWarning() << "LlmClient: icon request: session gone";
return; return;
@@ -842,12 +752,12 @@ void LlmClient::requestIcon(ChatSession* session, const QString& userText) {
return c == QLatin1Char('"') || c == QLatin1Char('\''); return c == QLatin1Char('"') || c == QLatin1Char('\'');
}; };
while (name.size() >= 2 && isQuote(name.at(0)) && 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 = name.mid(1, name.size() - 2).simplified();
name.replace(QLatin1Char(' '), QLatin1Char('_')); name.replace(QLatin1Char(' '), QLatin1Char('_'));
if (!icons.contains(name)) { if (!icons.contains(name)) {
qWarning() << "LlmClient: icon not in list, using default" qWarning() << "LlmClient: icon not in list, using default"
<< name; << name;
name = QStringLiteral("chat"); name = QStringLiteral("chat");
} }
qInfo() << "LlmClient: suggesting icon" << name; qInfo() << "LlmClient: suggesting icon" << name;
+2 -22
View File
@@ -23,9 +23,6 @@ class ChatSession;
class LlmSegment; class LlmSegment;
class LlmTool; 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 { class LlmClient : public QObject {
Q_OBJECT Q_OBJECT
@@ -53,17 +50,10 @@ class LlmClient : public QObject {
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; }
[[nodiscard]] ChatSession* streamingSession() const { return m_active; } [[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 startGeneration(ChatSession* session, ChatGeneration* target);
void stop(); void stop();
void endStream(); void endStream();
// Clears the session's conversation once the current stream ends.
void clearOnFinish(ChatSession* session); void clearOnFinish(ChatSession* session);
// A session is about to be destroyed; drop any state pointing at it.
void sessionRemoved(ChatSession* session); void sessionRemoved(ChatSession* session);
void refreshModels(); void refreshModels();
@@ -91,23 +81,13 @@ class LlmClient : public QObject {
bool seen = false; bool seen = false;
}; };
// Sends one streaming round: context + transcript so far.
void sendRound(); void sendRound();
// The session context for a round, oldest first, ending just before
// `stopBeforeRow` (the message of the generation being streamed).
QJsonArray buildContextMessages( QJsonArray buildContextMessages(
ChatSession* session, int stopBeforeRow) const; ChatSession* session, int stopBeforeRow) const;
void applyToolCallDelta(const QJsonObject& call); 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(); void roundFinished();
// Dispatches every call of the round; tools run concurrently.
void executeAllCalls(); void executeAllCalls();
// All results in: appends the tool messages (in call order) and
// sends the next round.
void flushCallResults(); void flushCallResults();
// Ends the current turn gracefully and persists the session.
void finishTurn(); void finishTurn();
void fail(const QString& message); void fail(const QString& message);
void handleLine(const QByteArray& line); void handleLine(const QByteArray& line);
@@ -120,7 +100,8 @@ class LlmClient : public QObject {
const QString& systemPrompt, const QString& systemPrompt,
const QString& userText, const QString& userText,
std::function<void(QString result)> onResult); std::function<void(QString result)> onResult);
static QString completionsPath(const QString& endpoint, const QString& subpath); static QString completionsPath(
const QString& endpoint, const QString& subpath);
static QString serverErrorMessage( static QString serverErrorMessage(
const QByteArray& body, const QString& fallback); const QByteArray& body, const QString& fallback);
@@ -139,7 +120,6 @@ class LlmClient : public QObject {
double m_temperature = 0.7; double m_temperature = 0.7;
int m_contextSize = 0; int m_contextSize = 0;
// State of the multi-round tool loop of the current turn.
QJsonArray m_transcript; QJsonArray m_transcript;
QList<ToolCallBuilder> m_callBuilders; QList<ToolCallBuilder> m_callBuilders;
struct ToolCallResult { struct ToolCallResult {
+3 -6
View File
@@ -5,9 +5,6 @@
namespace ZShell::llm { 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 { class LlmMarkdown : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
@@ -16,9 +13,9 @@ class LlmMarkdown : public QObject {
public: public:
enum class Type : int { enum class Type : int {
Text = 0, // Paragraph, list, quote, table; "text" is markdown source Text = 0, // Paragraph, list, quote, table; "text" is markdown source
Heading, // "level" + "text" (markdown source of the content) Heading, // "level" + "text" (markdown source of the content)
Code, // "language" + "code" Code, // "language" + "code"
Math // "latex" (display math, without the $$ delimiters) Math // "latex" (display math, without the $$ delimiters)
}; };
Q_ENUM(Type) Q_ENUM(Type)
+21 -40
View File
@@ -13,15 +13,12 @@ namespace ZShell::llm {
QVariantList MarkdownParser::parse(const QString& source) { QVariantList MarkdownParser::parse(const QString& source) {
QVariantList blocks; QVariantList blocks;
if (source.trimmed().isEmpty()) if (source.trimmed().isEmpty()) return blocks;
return blocks;
const QStringList lines = source.split('\n'); const QStringList lines = source.split('\n');
// cmark-gfm line/column numbers are 1-based and inclusive.
auto sliceSource = [&](int startLine, int endLine) -> QString { auto sliceSource = [&](int startLine, int endLine) -> QString {
if (startLine < 1 || endLine < startLine) if (startLine < 1 || endLine < startLine) return QString();
return QString();
const int from = startLine; const int from = startLine;
const int to = qMin(endLine, static_cast<int>(lines.size())); const int to = qMin(endLine, static_cast<int>(lines.size()));
return lines.mid(from - 1, to - from + 1).join('\n').trimmed(); 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(); const QByteArray utf8 = source.toUtf8();
cmark_node* doc = cmark_parse_document( cmark_node* doc = cmark_parse_document(
utf8.constData(), static_cast<size_t>(utf8.size()), utf8.constData(),
static_cast<size_t>(utf8.size()),
CMARK_OPT_DEFAULT | CMARK_OPT_SOURCEPOS); CMARK_OPT_DEFAULT | CMARK_OPT_SOURCEPOS);
if (!doc) if (!doc) return blocks;
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( const QRegularExpression mathRe(
QStringLiteral("\\$\\$(.+?)\\$\\$"), QStringLiteral("\\$\\$(.+?)\\$\\$"),
QRegularExpression::DotMatchesEverythingOption); QRegularExpression::DotMatchesEverythingOption);
@@ -45,29 +38,22 @@ QVariantList MarkdownParser::parse(const QString& source) {
auto makeBlock = [&](LlmMarkdown::Type type) { auto makeBlock = [&](LlmMarkdown::Type type) {
QVariantMap block; QVariantMap block;
block.insert("type", static_cast<int>(type)); block.insert("type", static_cast<int>(type));
// Stable per-position identity ("index:type") for the QML block.insert(
// ScriptModel: blocks that survive a re-parse keep their id, so "id",
// their delegates are updated in place instead of recreated QString::number(static_cast<int>(blocks.size())) +
// (which would drop code highlights mid-stream). The type is QLatin1Char(':') + QString::number(static_cast<int>(type)));
// part of the id so a block that changes type is rebuilt.
block.insert("id",
QString::number(static_cast<int>(blocks.size()))
+ QLatin1Char(':')
+ QString::number(static_cast<int>(type)));
return block; return block;
}; };
auto appendText = [&](const QString& text) { auto appendText = [&](const QString& text) {
if (text.trimmed().isEmpty()) if (text.trimmed().isEmpty()) return;
return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Text); QVariantMap block = makeBlock(LlmMarkdown::Type::Text);
block.insert("text", text); block.insert("text", text);
blocks.append(block); blocks.append(block);
}; };
auto appendMath = [&](const QString& latex) { auto appendMath = [&](const QString& latex) {
if (latex.trimmed().isEmpty()) if (latex.trimmed().isEmpty()) return;
return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Math); QVariantMap block = makeBlock(LlmMarkdown::Type::Math);
block.insert("latex", latex); block.insert("latex", latex);
blocks.append(block); blocks.append(block);
@@ -81,8 +67,7 @@ QVariantList MarkdownParser::parse(const QString& source) {
}; };
auto appendHeading = [&](int level, const QString& text) { auto appendHeading = [&](int level, const QString& text) {
if (text.trimmed().isEmpty()) if (text.trimmed().isEmpty()) return;
return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Heading); QVariantMap block = makeBlock(LlmMarkdown::Type::Heading);
block.insert("level", level); block.insert("level", level);
block.insert("text", text); block.insert("text", text);
@@ -90,7 +75,7 @@ QVariantList MarkdownParser::parse(const QString& source) {
}; };
for (cmark_node* node = cmark_node_first_child(doc); node; 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 cmark_node_type type = cmark_node_get_type(node);
const int startLine = cmark_node_get_start_line(node); const int startLine = cmark_node_get_start_line(node);
const int endLine = cmark_node_get_end_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) { if (type == CMARK_NODE_CODE_BLOCK) {
const char* literal = cmark_node_get_literal(node); const char* literal = cmark_node_get_literal(node);
QString code = literal ? QString::fromUtf8(literal) : QString(); 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; QString language;
if (const char* info = cmark_node_get_fence_info(node); info) 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); appendCode(language, code);
continue; continue;
} }
if (type == CMARK_NODE_HEADING) { 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); const char* content = cmark_node_get_string_content(node);
appendHeading( appendHeading(
cmark_node_get_heading_level(node), cmark_node_get_heading_level(node),
@@ -125,11 +109,11 @@ QVariantList MarkdownParser::parse(const QString& source) {
int cursor = 0; int cursor = 0;
bool anyMath = false; bool anyMath = false;
for (auto it = mathRe.globalMatch(text, cursor); it.hasNext(); for (auto it = mathRe.globalMatch(text, cursor); it.hasNext();
it = mathRe.globalMatch(text, cursor)) { it = mathRe.globalMatch(text, cursor)) {
const QRegularExpressionMatch m = it.next(); const QRegularExpressionMatch m = it.next();
anyMath = true; anyMath = true;
appendText( appendText(text.mid(
text.mid(cursor, static_cast<int>(m.capturedStart() - cursor))); cursor, static_cast<int>(m.capturedStart() - cursor)));
appendMath(m.captured(1).trimmed()); appendMath(m.captured(1).trimmed());
cursor = static_cast<int>(m.capturedEnd()); cursor = static_cast<int>(m.capturedEnd());
} }
@@ -140,9 +124,6 @@ QVariantList MarkdownParser::parse(const QString& source) {
continue; continue;
} }
// Lists, block quotes, tables, horizontal rules, custom blocks:
// hand the raw markdown source to QML (rendered via
// Text.MarkdownText).
appendText(sliceSource(startLine, endLine)); appendText(sliceSource(startLine, endLine));
} }
-12
View File
@@ -5,18 +5,6 @@
namespace ZShell::llm { 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 { class MarkdownParser {
public: public:
[[nodiscard]] static QVariantList parse(const QString& source); [[nodiscard]] static QVariantList parse(const QString& source);
+99 -123
View File
@@ -16,15 +16,10 @@
namespace ZShell::llm { namespace ZShell::llm {
namespace { namespace {
// A few px of breathing room around the equation.
constexpr int kRenderMargin = 2; constexpr int kRenderMargin = 2;
constexpr unsigned int kResolutionDpi = 96; 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; 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 { struct LatinModern {
bool roman = false; bool roman = false;
bool math = false; bool math = false;
@@ -33,37 +28,44 @@ struct LatinModern {
const LatinModern& loadLatinModern() { const LatinModern& loadLatinModern() {
static const LatinModern fonts = [] { static const LatinModern fonts = [] {
LatinModern result; LatinModern result;
// addApplicationFont(QByteArray) fails in this environment, so const auto add = [](const unsigned char* data,
// the embedded bytes are staged to a per-process temp file and size_t size,
// registered through the (stable) file-based API. const QString& fileName,
const auto add = [](const unsigned char* data, size_t size, const QString& family) {
const QString& fileName, const QString& family) {
const QString path = QDir::tempPath() + QLatin1Char('/') + fileName; const QString path = QDir::tempPath() + QLatin1Char('/') + fileName;
{ {
QFile f(path); QFile f(path);
if (!f.open(QIODevice::WriteOnly) || if (!f.open(QIODevice::WriteOnly) ||
f.write(reinterpret_cast<const char*>(data), f.write(
static_cast<qint64>(size)) != static_cast<qint64>(size)) reinterpret_cast<const char*>(data),
static_cast<qint64>(size)) != static_cast<qint64>(size))
return false; return false;
} }
const int key = QFontDatabase::addApplicationFont(path); const int key = QFontDatabase::addApplicationFont(path);
if (key < 0) if (key < 0) return false;
return false;
return QFontDatabase::applicationFontFamilies(key).contains(family); return QFontDatabase::applicationFontFamilies(key).contains(family);
}; };
result.roman = result.roman = add(lmfont::lmroman10_regular,
add(lmfont::lmroman10_regular, sizeof(lmfont::lmroman10_regular), sizeof(lmfont::lmroman10_regular),
QStringLiteral("lmroman10-regular.otf"), QStringLiteral("LMRoman10")) QStringLiteral("lmroman10-regular.otf"),
&& add(lmfont::lmroman10_italic, sizeof(lmfont::lmroman10_italic), QStringLiteral("LMRoman10")) &&
QStringLiteral("lmroman10-italic.otf"), QStringLiteral("LMRoman10")) add(lmfont::lmroman10_italic,
&& add(lmfont::lmroman10_bold, sizeof(lmfont::lmroman10_bold), sizeof(lmfont::lmroman10_italic),
QStringLiteral("lmroman10-bold.otf"), QStringLiteral("LMRoman10")) QStringLiteral("lmroman10-italic.otf"),
&& add(lmfont::lmroman10_bolditalic, sizeof(lmfont::lmroman10_bolditalic), QStringLiteral("LMRoman10")) &&
QStringLiteral("lmroman10-bolditalic.otf"), add(lmfont::lmroman10_bold,
QStringLiteral("LMRoman10")); sizeof(lmfont::lmroman10_bold),
result.math = add( QStringLiteral("lmroman10-bold.otf"),
lmfont::latinmodern_math, sizeof(lmfont::latinmodern_math), QStringLiteral("LMRoman10")) &&
QStringLiteral("latinmodern-math.otf"), QStringLiteral("Latin Modern Math")); 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 result;
}(); }();
return fonts; return fonts;
@@ -72,22 +74,17 @@ const LatinModern& loadLatinModern() {
} // namespace } // namespace
LlmMathText::LlmMathText(QObject* parent) LlmMathText::LlmMathText(QObject* parent)
// No parent: the renderer is used from pool threads, and a parented : QObject(parent)
// QObject would taint children it creates there. , m_renderer(
: QObject(parent), m_renderer(std::make_shared<JKQTMathText>( std::make_shared<JKQTMathText>(nullptr, /* useFontsForGUI */ true)) {
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(); const LatinModern& fonts = loadLatinModern();
if (fonts.roman) if (fonts.roman)
m_renderer->setFontRomanAndMath(QStringLiteral("LMRoman10"), m_renderer->setFontRomanAndMath(
JKQTMathTextFontEncoding::MTFEUnicode); QStringLiteral("LMRoman10"), JKQTMathTextFontEncoding::MTFEUnicode);
if (fonts.math) { if (fonts.math) {
// Same pattern as JKQTMathText's useXITS(): the OpenType math m_renderer->setFontMathRoman(
// font supplies the math alphabet and operators from its MATH QStringLiteral("Latin Modern Math"),
// table. JKQTMathTextFontEncoding::MTFEUnicode);
m_renderer->setFontMathRoman(QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode);
m_renderer->setFallbackFontSymbols( m_renderer->setFallbackFontSymbols(
QStringLiteral("Latin Modern Math"), QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode); JKQTMathTextFontEncoding::MTFEUnicode);
@@ -95,38 +92,31 @@ LlmMathText::LlmMathText(QObject* parent)
} }
void LlmMathText::setLatex(const QString& value) { void LlmMathText::setLatex(const QString& value) {
if (m_latex == value) if (m_latex == value) return;
return;
m_latex = value; m_latex = value;
reRender(); reRender();
} }
void LlmMathText::setColor(const QColor& value) { void LlmMathText::setColor(const QColor& value) {
if (m_color == value) if (m_color == value) return;
return;
m_color = value; m_color = value;
reRender(); reRender();
} }
void LlmMathText::setFontPointSize(double value) { void LlmMathText::setFontPointSize(double value) {
if (qFuzzyCompare(m_fontPointSize, value)) if (qFuzzyCompare(m_fontPointSize, value)) return;
return;
m_fontPointSize = value; m_fontPointSize = value;
reRender(); reRender();
} }
void LlmMathText::setDevicePixelRatio(qreal value) { void LlmMathText::setDevicePixelRatio(qreal value) {
if (qFuzzyCompare(m_devicePixelRatio, value)) if (qFuzzyCompare(m_devicePixelRatio, value)) return;
return;
m_devicePixelRatio = value; m_devicePixelRatio = value;
reRender(); reRender();
} }
namespace { 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 { struct MathRender {
bool ok = false; bool ok = false;
QImage image; QImage image;
@@ -154,10 +144,9 @@ void LlmMathText::reRender() {
return; return;
} }
const QString key = m_latex const QString key = m_latex + QLatin1Char(0x1f) + m_color.name() +
+ QLatin1Char(0x1f) + m_color.name() QLatin1Char(0x1f) + QString::number(m_fontPointSize) +
+ QLatin1Char(0x1f) + QString::number(m_fontPointSize) QLatin1Char(0x1f) + QString::number(m_devicePixelRatio);
+ QLatin1Char(0x1f) + QString::number(m_devicePixelRatio);
if (auto it = mathCache().find(key); it != mathCache().end()) { if (auto it = mathCache().find(key); it != mathCache().end()) {
m_image = it->image; m_image = it->image;
m_imageUrl = it->url; m_imageUrl = it->url;
@@ -168,83 +157,70 @@ void LlmMathText::reRender() {
return; return;
} }
// A render is already running; it re-renders the latest state when if (m_inFlight) return;
// it completes (id mismatch), so there is nothing to do here.
if (m_inFlight)
return;
m_inFlight = true; m_inFlight = true;
const QString latex = m_latex; const QString latex = m_latex;
const QColor color = m_color; const QColor color = m_color;
const double pointSize = m_fontPointSize; const double pointSize = m_fontPointSize;
const qreal dpr = m_devicePixelRatio; 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; auto renderer = m_renderer;
QThreadPool::globalInstance()->start([this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() { QThreadPool::globalInstance()->start(
MathRender render; [this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() {
renderer->setFontPointSize(pointSize); MathRender render;
renderer->setFontColor(color); renderer->setFontPointSize(pointSize);
if (renderer->parse( renderer->setFontColor(color);
latex, JKQTMathText::LatexParser, JKQTMathText::DefaultParseOptions)) { if (renderer->parse(
const QImage image = renderer->drawIntoImage( latex,
/* drawBoxes */ false, JKQTMathText::LatexParser,
QColor(Qt::transparent), JKQTMathText::DefaultParseOptions)) {
kRenderMargin, const QImage image = renderer->drawIntoImage(
dpr, /* drawBoxes */ false,
kResolutionDpi); QColor(Qt::transparent),
if (!image.isNull()) { kRenderMargin,
QByteArray png; dpr,
{ kResolutionDpi);
QBuffer buffer(&png); if (!image.isNull()) {
buffer.open(QIODevice::WriteOnly); QByteArray png;
image.save(&buffer, "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;
} }
} QPointer<LlmMathText> guard(this);
// Deliver through the app instance (never destroyed) and QMetaObject::invokeMethod(
// re-check the pointer on the GUI thread: posting to `this` QCoreApplication::instance(),
// from the pool thread would race with its destruction. [guard, id, key, render = std::move(render)]() mutable {
QPointer<LlmMathText> guard(this); LlmMathText* self = guard;
QMetaObject::invokeMethod( if (!self) return;
QCoreApplication::instance(), self->m_inFlight = false;
[guard, id, key, render = std::move(render)]() mutable { if (id != self->m_requestId) {
LlmMathText* self = guard; self->reRender();
if (!self) return;
return; }
self->m_inFlight = false; if (render.ok) {
if (id != self->m_requestId) { auto& cache = mathCache();
// Superseded while the worker ran; render the if (cache.size() >= kCacheLimit) cache.clear();
// latest state. cache.insert(key, render);
self->reRender(); }
return; self->m_image = render.image;
} self->m_imageUrl = render.url;
if (render.ok) { self->m_width = render.width;
auto& cache = mathCache(); self->m_height = render.height;
if (cache.size() >= kCacheLimit) self->m_ok = render.ok;
cache.clear(); Q_EMIT self->changed();
cache.insert(key, render); },
} Qt::QueuedConnection);
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 } // namespace ZShell::llm
+6 -17
View File
@@ -13,27 +13,20 @@
namespace ZShell::llm { 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 { class LlmMathText : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
Q_PROPERTY(QString latex READ latex WRITE setLatex NOTIFY changed) Q_PROPERTY(QString latex READ latex WRITE setLatex NOTIFY changed)
Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY changed) Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY changed)
Q_PROPERTY(double fontPointSize READ fontPointSize WRITE setFontPointSize NOTIFY changed) Q_PROPERTY(
Q_PROPERTY(qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio NOTIFY changed) 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) 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) 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 width READ width NOTIFY changed)
Q_PROPERTY(qreal height READ height NOTIFY changed) Q_PROPERTY(qreal height READ height NOTIFY changed)
Q_PROPERTY(bool ok READ ok NOTIFY changed) Q_PROPERTY(bool ok READ ok NOTIFY changed)
@@ -61,8 +54,6 @@ class LlmMathText : public QObject {
private: private:
void reRender(); 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<JKQTMathText> m_renderer; std::shared_ptr<JKQTMathText> m_renderer;
QString m_latex; QString m_latex;
QColor m_color; QColor m_color;
@@ -73,8 +64,6 @@ class LlmMathText : public QObject {
qreal m_width = 0; qreal m_width = 0;
qreal m_height = 0; qreal m_height = 0;
bool m_ok = false; bool m_ok = false;
// Bumps on every reRender; a delivery carrying an older id was
// superseded and is dropped.
int m_requestId = 0; int m_requestId = 0;
bool m_inFlight = false; bool m_inFlight = false;
}; };
+8 -15
View File
@@ -16,13 +16,12 @@ ChatSession* sessionOf(const ChatMessage* message) {
} // namespace } // namespace
ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent) 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) { ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) {
auto* generation = new ChatGeneration(timestamp, this); auto* generation = new ChatGeneration(timestamp, this);
m_generations.append(generation); m_generations.append(generation);
if (m_active < 0) if (m_active < 0) m_active = static_cast<int>(m_generations.size() - 1);
m_active = static_cast<int>(m_generations.size() - 1);
Q_EMIT generationsChanged(); Q_EMIT generationsChanged();
return generation; return generation;
} }
@@ -35,8 +34,7 @@ ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) {
void ChatMessage::removeGeneration(ChatGeneration* generation) { void ChatMessage::removeGeneration(ChatGeneration* generation) {
const int index = static_cast<int>(m_generations.indexOf(generation)); const int index = static_cast<int>(m_generations.indexOf(generation));
if (index < 0) if (index < 0) return;
return;
const bool wasActive = index == m_active; const bool wasActive = index == m_active;
m_generations.removeAt(index); m_generations.removeAt(index);
delete generation; delete generation;
@@ -46,13 +44,11 @@ void ChatMessage::removeGeneration(ChatGeneration* generation) {
m_active = static_cast<int>(m_generations.size() - 1); m_active = static_cast<int>(m_generations.size() - 1);
} }
Q_EMIT generationsChanged(); Q_EMIT generationsChanged();
if (wasActive) if (wasActive) Q_EMIT activeGenerationChanged();
Q_EMIT activeGenerationChanged();
} }
void ChatMessage::setActiveInternal(int index) { void ChatMessage::setActiveInternal(int index) {
if (index < 0 || index >= m_generations.size() || index == m_active) if (index < 0 || index >= m_generations.size() || index == m_active) return;
return;
m_active = index; m_active = index;
Q_EMIT activeGenerationChanged(); Q_EMIT activeGenerationChanged();
} }
@@ -64,18 +60,15 @@ void ChatMessage::setActiveGeneration(int index) {
void ChatMessage::edit(const QString& newContent) { void ChatMessage::edit(const QString& newContent) {
if (auto* generation = activeGeneration()) if (auto* generation = activeGeneration())
generation->setContent(newContent); generation->setContent(newContent);
if (auto* session = sessionOf(this)) if (auto* session = sessionOf(this)) session->persist();
session->persist();
} }
void ChatMessage::retry() { void ChatMessage::retry() {
if (auto* session = sessionOf(this)) if (auto* session = sessionOf(this)) session->retry(this);
session->retry(this);
} }
void ChatMessage::generate() { void ChatMessage::generate() {
if (auto* session = sessionOf(this)) if (auto* session = sessionOf(this)) session->continueFrom(this);
session->continueFrom(this);
} }
} // namespace ZShell::llm } // namespace ZShell::llm
+10 -13
View File
@@ -16,24 +16,23 @@ class ChatMessage : public QObject {
Q_PROPERTY(Role role READ role CONSTANT) Q_PROPERTY(Role role READ role CONSTANT)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY(int generationCount READ generationCount NOTIFY generationsChanged)
Q_PROPERTY( Q_PROPERTY(
QList<ZShell::llm::ChatGeneration*> generations READ generations int generationCount READ generationCount NOTIFY generationsChanged)
NOTIFY generationsChanged) Q_PROPERTY(
QList<ZShell::llm::ChatGeneration*> generations READ generations NOTIFY
generationsChanged)
Q_PROPERTY( Q_PROPERTY(
ZShell::llm::ChatGeneration* activeGeneration READ activeGeneration ZShell::llm::ChatGeneration* activeGeneration READ activeGeneration
NOTIFY activeGenerationChanged) NOTIFY activeGenerationChanged)
Q_PROPERTY(int activeGenerationIndex READ activeGenerationIndex NOTIFY activeGenerationChanged) Q_PROPERTY(
int activeGenerationIndex READ activeGenerationIndex NOTIFY
activeGenerationChanged)
public: public:
enum class Role : int { enum class Role : int { User = 0, Assistant };
User = 0,
Assistant
};
Q_ENUM(Role) Q_ENUM(Role)
explicit ChatMessage( explicit ChatMessage(Role role, qint64 timestamp, QObject* parent = nullptr);
Role role, qint64 timestamp, QObject* parent = nullptr);
[[nodiscard]] Role role() const { return m_role; } [[nodiscard]] Role role() const { return m_role; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; } [[nodiscard]] qint64 timestamp() const { return m_timestamp; }
@@ -48,8 +47,7 @@ class ChatMessage : public QObject {
} }
[[nodiscard]] int activeGenerationIndex() const { return m_active; } [[nodiscard]] int activeGenerationIndex() const { return m_active; }
[[nodiscard]] ChatGeneration* generation(int index) const { [[nodiscard]] ChatGeneration* generation(int index) const {
if (index < 0 || index >= m_generations.size()) if (index < 0 || index >= m_generations.size()) return nullptr;
return nullptr;
return m_generations.at(index); return m_generations.at(index);
} }
@@ -58,7 +56,6 @@ class ChatMessage : public QObject {
Q_INVOKABLE void retry(); Q_INVOKABLE void retry();
Q_INVOKABLE void generate(); Q_INVOKABLE void generate();
// Creates an empty generation; callers fill it with segments.
ChatGeneration* addGeneration(qint64 timestamp); ChatGeneration* addGeneration(qint64 timestamp);
ChatGeneration* appendGeneration(qint64 timestamp); ChatGeneration* appendGeneration(qint64 timestamp);
void removeGeneration(ChatGeneration* generation); void removeGeneration(ChatGeneration* generation);
+2 -4
View File
@@ -100,11 +100,9 @@ void ChatMessageModel::clear() {
void ChatMessageModel::loadMessages(QList<ChatMessage*> messages) { void ChatMessageModel::loadMessages(QList<ChatMessage*> messages) {
beginResetModel(); 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) for (ChatMessage* message : m_messages)
if (std::find(messages.begin(), messages.end(), message) if (std::find(messages.begin(), messages.end(), message) ==
== messages.end()) messages.end())
delete message; delete message;
m_messages = std::move(messages); m_messages = std::move(messages);
endResetModel(); endResetModel();
+2 -11
View File
@@ -12,17 +12,14 @@ namespace ZShell::llm {
class ChatSession; 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 { class ChatMessageModel : public QAbstractListModel {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
QML_UNCREATABLE("Chat message models are owned by ChatSession") QML_UNCREATABLE("Chat message models are owned by ChatSession")
Q_PROPERTY( Q_PROPERTY(
ZShell::llm::ChatMessage* lastMessage READ lastMessage ZShell::llm::ChatMessage* lastMessage READ lastMessage NOTIFY
NOTIFY lastMessageChanged) lastMessageChanged)
public: public:
explicit ChatMessageModel(ChatSession* session, QObject* parent = nullptr); explicit ChatMessageModel(ChatSession* session, QObject* parent = nullptr);
@@ -35,7 +32,6 @@ class ChatMessageModel : public QAbstractListModel {
[[nodiscard]] QHash<int, QByteArray> roleNames() const override; [[nodiscard]] QHash<int, QByteArray> roleNames() const override;
[[nodiscard]] ChatSession* session() const { return m_session; } [[nodiscard]] ChatSession* session() const { return m_session; }
// Most recent message first.
[[nodiscard]] QList<ChatMessage*> messages() const { return m_messages; } [[nodiscard]] QList<ChatMessage*> messages() const { return m_messages; }
[[nodiscard]] ChatMessage* at(int row) const; [[nodiscard]] ChatMessage* at(int row) const;
[[nodiscard]] int rowOf(const ChatMessage* message) const; [[nodiscard]] int rowOf(const ChatMessage* message) const;
@@ -44,17 +40,12 @@ class ChatMessageModel : public QAbstractListModel {
return m_messages.isEmpty() ? nullptr : m_messages.first(); 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); 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* appendNewest(
ChatMessage::Role role, const QString& content, qint64 timestamp); ChatMessage::Role role, const QString& content, qint64 timestamp);
void removeMessage(ChatMessage* message); void removeMessage(ChatMessage* message);
void removeRange(int firstRow, int lastRow); void removeRange(int firstRow, int lastRow);
void clear(); void clear();
// Replaces every row; takes ownership of the given messages, most recent
// first.
void loadMessages(QList<ChatMessage*> messages); void loadMessages(QList<ChatMessage*> messages);
signals: signals:
+21 -52
View File
@@ -10,36 +10,27 @@
namespace ZShell::llm { namespace ZShell::llm {
namespace { namespace {
// Re-parse cadence while a segment streams; content between refreshes is
// at most this stale.
constexpr int kMarkdownRefreshMs = 150; constexpr int kMarkdownRefreshMs = 150;
} // namespace } // namespace
LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent) 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); m_markdownTimer.setInterval(kMarkdownRefreshMs);
connect( connect(&m_markdownTimer, &QTimer::timeout, this, [this]() {
&m_markdownTimer, &QTimer::timeout, this, [this]() { if (!m_markdownDirty) return;
if (!m_markdownDirty) if (parseMarkdown()) m_markdownDirty = false;
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;
});
} }
qint64 LlmSegment::elapsedMs() const { qint64 LlmSegment::elapsedMs() const {
if (m_startedAt <= 0) if (m_startedAt <= 0) return 0;
return 0;
const qint64 end = m_endedAt > 0 ? m_endedAt const qint64 end = m_endedAt > 0 ? m_endedAt
: QDateTime::currentMSecsSinceEpoch(); : QDateTime::currentMSecsSinceEpoch();
return end - m_startedAt; return end - m_startedAt;
} }
void LlmSegment::begin() { void LlmSegment::begin() {
if (m_running) if (m_running) return;
return;
m_running = true; m_running = true;
m_startedAt = QDateTime::currentMSecsSinceEpoch(); m_startedAt = QDateTime::currentMSecsSinceEpoch();
m_endedAt = 0; m_endedAt = 0;
@@ -54,62 +45,52 @@ void LlmSegment::close() {
Q_EMIT runningChanged(); Q_EMIT runningChanged();
Q_EMIT elapsedMsChanged(); 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) { if (m_type == Type::Content && m_markdownDirty) {
m_markdownTimer.stop(); m_markdownTimer.stop();
if (parseMarkdown()) if (parseMarkdown()) m_markdownDirty = false;
m_markdownDirty = false;
} }
} }
void LlmSegment::appendText(const QString& piece) { void LlmSegment::appendText(const QString& piece) {
if (piece.isEmpty()) if (piece.isEmpty()) return;
return;
m_text += piece; m_text += piece;
Q_EMIT textChanged(); Q_EMIT textChanged();
scheduleMarkdown(); scheduleMarkdown();
} }
void LlmSegment::setText(const QString& value) { void LlmSegment::setText(const QString& value) {
if (m_text == value) if (m_text == value) return;
return;
m_text = value; m_text = value;
Q_EMIT textChanged(); Q_EMIT textChanged();
scheduleMarkdown(); scheduleMarkdown();
} }
void LlmSegment::setName(const QString& value) { void LlmSegment::setName(const QString& value) {
if (m_name == value) if (m_name == value) return;
return;
m_name = value; m_name = value;
Q_EMIT nameChanged(); Q_EMIT nameChanged();
} }
void LlmSegment::setToolCallId(const QString& value) { void LlmSegment::setToolCallId(const QString& value) {
if (m_toolCallId == value) if (m_toolCallId == value) return;
return;
m_toolCallId = value; m_toolCallId = value;
Q_EMIT toolCallIdChanged(); Q_EMIT toolCallIdChanged();
} }
void LlmSegment::appendArguments(const QString& piece) { void LlmSegment::appendArguments(const QString& piece) {
if (piece.isEmpty()) if (piece.isEmpty()) return;
return;
m_arguments += piece; m_arguments += piece;
Q_EMIT argumentsChanged(); Q_EMIT argumentsChanged();
} }
void LlmSegment::setResult(const QString& value) { void LlmSegment::setResult(const QString& value) {
if (m_result == value) if (m_result == value) return;
return;
m_result = value; m_result = value;
Q_EMIT resultChanged(); Q_EMIT resultChanged();
} }
void LlmSegment::setStatus(Status value) { void LlmSegment::setStatus(Status value) {
if (m_status == value) if (m_status == value) return;
return;
m_status = value; m_status = value;
Q_EMIT statusChanged(); Q_EMIT statusChanged();
} }
@@ -126,39 +107,27 @@ void LlmSegment::restore(qint64 elapsedMs) {
} }
void LlmSegment::scheduleMarkdown() { void LlmSegment::scheduleMarkdown() {
// Content segments only; reasoning/tool output is never parsed. if (m_type != Type::Content) return;
// (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; m_markdownDirty = true;
if (!m_markdownTimer.isActive()) if (!m_markdownTimer.isActive()) m_markdownTimer.start();
m_markdownTimer.start();
} }
bool LlmSegment::parseMarkdown() { bool LlmSegment::parseMarkdown() {
if (m_parseInFlight) if (m_parseInFlight) return false;
return false;
m_parseInFlight = true; m_parseInFlight = true;
const QString text = m_text; const QString text = m_text;
QThreadPool::globalInstance()->start([this, text]() { QThreadPool::globalInstance()->start([this, text]() {
const QVariantList blocks = MarkdownParser::parse(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<LlmSegment> guard(this); QPointer<LlmSegment> guard(this);
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
QCoreApplication::instance(), QCoreApplication::instance(),
[guard, blocks]() { [guard, blocks]() {
LlmSegment* seg = guard; LlmSegment* seg = guard;
if (!seg) if (!seg) return;
return;
seg->m_parseInFlight = false; seg->m_parseInFlight = false;
seg->m_markdown = blocks; seg->m_markdown = blocks;
Q_EMIT seg->markdownChanged(); 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); Qt::QueuedConnection);
}); });
+2 -25
View File
@@ -8,10 +8,6 @@
namespace ZShell::llm { 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 { class LlmSegment : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
@@ -27,26 +23,13 @@ class LlmSegment : public QObject {
Q_PROPERTY(Status status READ status NOTIFY statusChanged) Q_PROPERTY(Status status READ status NOTIFY statusChanged)
Q_PROPERTY(bool running READ running NOTIFY runningChanged) Q_PROPERTY(bool running READ running NOTIFY runningChanged)
Q_PROPERTY(qint64 elapsedMs READ elapsedMs NOTIFY elapsedMsChanged) 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) Q_PROPERTY(QVariantList markdown READ markdown NOTIFY markdownChanged)
public: public:
enum class Type : int { enum class Type : int { Reasoning = 0, ToolCall, Content };
Reasoning = 0,
ToolCall,
Content
};
Q_ENUM(Type) Q_ENUM(Type)
enum class Status : int { enum class Status : int { None = 0, Running, Success, Error };
None = 0,
Running,
Success,
Error
};
Q_ENUM(Status) Q_ENUM(Status)
explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
@@ -63,9 +46,7 @@ class LlmSegment : public QObject {
[[nodiscard]] qint64 elapsedMs() const; [[nodiscard]] qint64 elapsedMs() const;
[[nodiscard]] QVariantList markdown() const { return m_markdown; } [[nodiscard]] QVariantList markdown() const { return m_markdown; }
// Starts the segment's clock; a no-op while already running.
void begin(); void begin();
// Stops the segment's clock; a no-op when not running.
void close(); void close();
void appendText(const QString& piece); void appendText(const QString& piece);
void setText(const QString& value); void setText(const QString& value);
@@ -74,9 +55,7 @@ class LlmSegment : public QObject {
void appendArguments(const QString& piece); void appendArguments(const QString& piece);
void setResult(const QString& value); void setResult(const QString& value);
void setStatus(Status value); void setStatus(Status value);
// Completes a tool call with the model-facing result text.
void finishTool(const QString& resultText, bool success); void finishTool(const QString& resultText, bool success);
// Restores persisted timing without a live clock.
void restore(qint64 elapsedMs); void restore(qint64 elapsedMs);
Q_SIGNALS: Q_SIGNALS:
@@ -92,8 +71,6 @@ class LlmSegment : public QObject {
private: private:
void scheduleMarkdown(); 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(); bool parseMarkdown();
Type m_type; Type m_type;
+3 -6
View File
@@ -145,10 +145,10 @@ void ChatSession::startGeneration(ChatMessage* target) {
if (isLoaded()) { if (isLoaded()) {
clientObject->startGeneration(this, generation); clientObject->startGeneration(this, generation);
} else { } else {
// The store load is still in flight; the request needs the
// full history, so start once it lands.
connect( connect(
this, &ChatSession::loaded, clientObject, this,
&ChatSession::loaded,
clientObject,
[this, generation, clientObject]() { [this, generation, clientObject]() {
clientObject->startGeneration(this, generation); clientObject->startGeneration(this, generation);
}); });
@@ -192,8 +192,6 @@ void ChatSession::retry(ChatMessage* target) {
const int row = m_model->rowOf(target); const int row = m_model->rowOf(target);
if (row < 0) return; 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); m_model->removeRange(0, row - 1);
if (m_model->rowCount() < 2) return; if (m_model->rowCount() < 2) return;
@@ -233,7 +231,6 @@ void ChatSession::clear() {
} }
} }
if (!isLoaded()) { if (!isLoaded()) {
// The load lands shortly; drop everything once it does.
m_clearPending = true; m_clearPending = true;
return; return;
} }
-7
View File
@@ -44,14 +44,9 @@ class ChatSession : public QObject {
[[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; } [[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; }
[[nodiscard]] bool pinned() const { return m_pinned; } [[nodiscard]] bool pinned() const { return m_pinned; }
[[nodiscard]] int messageCount() const { return m_messageCount; } [[nodiscard]] int messageCount() const { return m_messageCount; }
// The messages model; the first access starts the (async) load
// from the store.
[[nodiscard]] ChatMessageModel* messagesModel(); [[nodiscard]] ChatMessageModel* messagesModel();
void ensureLoaded(); void ensureLoaded();
// True once the async load from the store has finished.
[[nodiscard]] bool isLoaded() const { return m_loaded; } [[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; } [[nodiscard]] ChatMessageModel* model() const { return m_model; }
void markLoaded(); void markLoaded();
[[nodiscard]] bool takeClearPending(); [[nodiscard]] bool takeClearPending();
@@ -71,7 +66,6 @@ class ChatSession : public QObject {
qint64 updatedAt, qint64 updatedAt,
int messageCount); int messageCount);
// Replaces the model's rows with `messages` (most recent first).
void adoptMessages(QList<ChatMessage*> messages); void adoptMessages(QList<ChatMessage*> messages);
void persist(); void persist();
void removeMessage(ChatMessage* message); void removeMessage(ChatMessage* message);
@@ -88,7 +82,6 @@ class ChatSession : public QObject {
void updatedAtChanged(); void updatedAtChanged();
void pinnedChanged(); void pinnedChanged();
void messageCountChanged(); void messageCountChanged();
// The messages finished loading from the store.
void loaded(); void loaded();
private: private:
+4 -8
View File
@@ -22,29 +22,25 @@ QJsonObject LlmTool::specification() const {
ToolRegistry::ToolRegistry(QObject* parent) : QObject(parent) {} ToolRegistry::ToolRegistry(QObject* parent) : QObject(parent) {}
void ToolRegistry::setEnabled(bool value) { void ToolRegistry::setEnabled(bool value) {
if (m_enabled == value) if (m_enabled == value) return;
return;
m_enabled = value; m_enabled = value;
Q_EMIT enabledChanged(); Q_EMIT enabledChanged();
} }
void ToolRegistry::registerTool(LlmTool* tool) { void ToolRegistry::registerTool(LlmTool* tool) {
if (!tool || m_tools.contains(tool)) if (!tool || m_tools.contains(tool)) return;
return;
tool->setParent(this); tool->setParent(this);
m_tools.append(tool); m_tools.append(tool);
} }
LlmTool* ToolRegistry::tool(const QString& name) const { LlmTool* ToolRegistry::tool(const QString& name) const {
for (const auto* tool : m_tools) for (const auto* tool : m_tools)
if (tool->name() == name) if (tool->name() == name) return const_cast<LlmTool*>(tool);
return const_cast<LlmTool*>(tool);
return nullptr; return nullptr;
} }
QJsonArray ToolRegistry::specifications() const { QJsonArray ToolRegistry::specifications() const {
if (!m_enabled) if (!m_enabled) return {};
return {};
QJsonArray specs; QJsonArray specs;
for (const auto* tool : m_tools) for (const auto* tool : m_tools)
specs.append(tool->specification()); specs.append(tool->specification());
-14
View File
@@ -10,9 +10,6 @@
namespace ZShell::llm { 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 { class LlmTool : public QObject {
Q_OBJECT Q_OBJECT
@@ -22,24 +19,16 @@ class LlmTool : public QObject {
[[nodiscard]] virtual QString name() const = 0; [[nodiscard]] virtual QString name() const = 0;
[[nodiscard]] virtual QString description() const = 0; [[nodiscard]] virtual QString description() const = 0;
// JSON Schema describing the tool's `arguments` object.
[[nodiscard]] virtual QJsonObject parameters() const = 0; [[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( virtual void execute(
const QJsonObject& args, const QJsonObject& args,
std::function<void(const QJsonObject& result)> done) = 0; std::function<void(const QJsonObject& result)> done) = 0;
// Abandons in-flight work, if any.
virtual void cancel(); virtual void cancel();
// The OpenAI-compatible `tools` entry for this tool.
[[nodiscard]] QJsonObject specification() const; [[nodiscard]] QJsonObject specification() const;
}; };
// Owns the set of tools available to the model.
class ToolRegistry : public QObject { class ToolRegistry : public QObject {
Q_OBJECT Q_OBJECT
@@ -51,12 +40,9 @@ class ToolRegistry : public QObject {
[[nodiscard]] bool enabled() const { return m_enabled; } [[nodiscard]] bool enabled() const { return m_enabled; }
void setEnabled(bool value); void setEnabled(bool value);
// Takes ownership; tools become children of the registry.
void registerTool(LlmTool* tool); void registerTool(LlmTool* tool);
[[nodiscard]] LlmTool* tool(const QString& name) const; [[nodiscard]] LlmTool* tool(const QString& name) const;
// The request body's `tools` array; empty while disabled.
[[nodiscard]] QJsonArray specifications() const; [[nodiscard]] QJsonArray specifications() const;
// Abandons in-flight work in every tool.
void cancelAll(); void cancelAll();
Q_SIGNALS: Q_SIGNALS:
+69 -91
View File
@@ -32,12 +32,9 @@ WebFetchTool::WebFetchTool(QObject* parent) : LlmTool(parent) {}
WebFetchTool::~WebFetchTool() { WebFetchTool::~WebFetchTool() {
for (auto* job : m_jobs) { for (auto* job : m_jobs) {
if (job->timer) if (job->timer) job->timer->stop();
job->timer->stop(); if (job->reply) job->reply->abort();
if (job->reply)
job->reply->abort();
} }
// Pending result callbacks are dropped; the client is going away.
qDeleteAll(m_jobs); qDeleteAll(m_jobs);
} }
@@ -64,9 +61,9 @@ QJsonObject WebFetchTool::parameters() const {
QJsonObject format; QJsonObject format;
format[QStringLiteral("type")] = QStringLiteral("string"); format[QStringLiteral("type")] = QStringLiteral("string");
format[QStringLiteral("enum")] = formats; format[QStringLiteral("enum")] = formats;
format[QStringLiteral("description")] = format[QStringLiteral("description")] = QStringLiteral(
QStringLiteral("The format to return the content in. Defaults to " "The format to return the content in. Defaults to "
"text."); "text.");
QJsonObject timeout; QJsonObject timeout;
timeout[QStringLiteral("type")] = QStringLiteral("integer"); timeout[QStringLiteral("type")] = QStringLiteral("integer");
@@ -90,18 +87,14 @@ QJsonObject WebFetchTool::parameters() const {
} }
void WebFetchTool::completeJob(Job* job, QJsonObject result) { 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( QMetaObject::invokeMethod(
this, this,
[this, job, result = std::move(result)]() mutable { [this, job, result = std::move(result)]() mutable {
if (!m_jobs.contains(job)) if (!m_jobs.contains(job)) return;
return;
auto done = std::move(job->done); auto done = std::move(job->done);
m_jobs.removeAll(job); m_jobs.removeAll(job);
job->timer->deleteLater(); job->timer->deleteLater();
if (job->reply) if (job->reply) job->reply->deleteLater();
job->reply->deleteLater();
delete job; delete job;
done(std::move(result)); done(std::move(result));
}, },
@@ -126,7 +119,7 @@ void WebFetchTool::execute(
return; return;
} }
if (url.scheme() != QLatin1String("http") && if (url.scheme() != QLatin1String("http") &&
url.scheme() != QLatin1String("https")) { url.scheme() != QLatin1String("https")) {
fail(QStringLiteral("URL must use http:// or https://")); fail(QStringLiteral("URL must use http:// or https://"));
return; return;
} }
@@ -136,20 +129,18 @@ void WebFetchTool::execute(
if (job->format != QLatin1String("html")) if (job->format != QLatin1String("html"))
job->format = QStringLiteral("text"); job->format = QStringLiteral("text");
const int timeoutMs = qBound( const int timeoutMs =
1, qBound(
args[QStringLiteral("timeout")].toInt( 1,
DefaultTimeoutSeconds), args[QStringLiteral("timeout")].toInt(DefaultTimeoutSeconds),
MaxTimeoutSeconds) * MaxTimeoutSeconds) *
1000; 1000;
job->timer = new QTimer(this); job->timer = new QTimer(this);
job->timer->setSingleShot(true); job->timer->setSingleShot(true);
connect( connect(job->timer, &QTimer::timeout, this, [this, job]() {
job->timer, &QTimer::timeout, this, [this, job]() { if (job->reply) job->reply->abort();
if (job->reply) });
job->reply->abort();
});
job->timer->start(timeoutMs); job->timer->start(timeoutMs);
QNetworkRequest request(url); QNetworkRequest request(url);
@@ -159,13 +150,12 @@ void WebFetchTool::execute(
job->format == QLatin1String("html") job->format == QLatin1String("html")
? "text/html;q=1.0, application/xhtml+xml;q=0.9, */*;q=0.1" ? "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, " : "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"); request.setRawHeader("Accept-Language", "en-US,en;q=0.9");
job->reply = m_manager.get(request); job->reply = m_manager.get(request);
connect(job->reply, &QNetworkReply::readyRead, this, [this, job]() { connect(job->reply, &QNetworkReply::readyRead, this, [this, job]() {
if (!job->reply) if (!job->reply) return;
return;
job->body += job->reply->readAll(); job->body += job->reply->readAll();
if (job->body.size() > MaxResponseBytes) { if (job->body.size() > MaxResponseBytes) {
job->tooLarge = true; job->tooLarge = true;
@@ -176,8 +166,7 @@ void WebFetchTool::execute(
QNetworkReply* reply = job->reply; QNetworkReply* reply = job->reply;
job->reply = nullptr; job->reply = nullptr;
job->timer->stop(); job->timer->stop();
if (!reply) if (!reply) return;
return;
const QNetworkReply::NetworkError error = reply->error(); const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString(); const QString errorString = reply->errorString();
@@ -187,13 +176,14 @@ void WebFetchTool::execute(
const QByteArray contentType = const QByteArray contentType =
reply->rawHeader("Content-Type").toLower(); reply->rawHeader("Content-Type").toLower();
const int status = const int status =
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute) reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
.toInt();
if (job->tooLarge) { if (job->tooLarge) {
completeJob(job, makeError( completeJob(
QStringLiteral("Response too large (exceeds the 5 MB " job,
"limit"))); makeError(QStringLiteral(
"Response too large (exceeds the 5 MB "
"limit")));
return; return;
} }
if (error != QNetworkReply::NoError) { if (error != QNetworkReply::NoError) {
@@ -207,25 +197,23 @@ void WebFetchTool::execute(
completeJob( completeJob(
job, job,
makeError( makeError(
QStringLiteral("Server returned status %1") QStringLiteral("Server returned status %1").arg(status)));
.arg(status)));
return; return;
} }
const QString mime = const QString mime = QString::fromLatin1(contentType)
QString::fromLatin1(contentType).section(QLatin1Char(';'), 0, 0) .section(QLatin1Char(';'), 0, 0)
.trimmed(); .trimmed();
if (mime.startsWith(QLatin1String("image/"))) { if (mime.startsWith(QLatin1String("image/"))) {
completeJob( completeJob(
job, job,
makeError( makeError(
QStringLiteral( QStringLiteral("Unsupported fetched image content type: %1")
"Unsupported fetched image content type: %1")
.arg(mime))); .arg(mime)));
return; return;
} }
const bool textual = mime.isEmpty() || const bool textual =
mime.startsWith(QLatin1String("text/")) || mime.isEmpty() || mime.startsWith(QLatin1String("text/")) ||
mime == QLatin1String("application/json") || mime == QLatin1String("application/json") ||
mime.endsWith(QLatin1String("+json")) || mime.endsWith(QLatin1String("+json")) ||
mime == QLatin1String("application/xml") || mime == QLatin1String("application/xml") ||
@@ -236,19 +224,18 @@ void WebFetchTool::execute(
completeJob( completeJob(
job, job,
makeError( makeError(
QStringLiteral( QStringLiteral("Unsupported fetched file content type: %1")
"Unsupported fetched file content type: %1")
.arg(mime))); .arg(mime)));
return; return;
} }
QString content = QString::fromUtf8(body); QString content = QString::fromUtf8(body);
if (mime.contains(QLatin1String("text/html")) && if (mime.contains(QLatin1String("text/html")) &&
job->format == QLatin1String("text")) job->format == QLatin1String("text"))
content = extractTextFromHtml(content); content = extractTextFromHtml(content);
if (content.size() > MaxOutputChars) if (content.size() > MaxOutputChars)
content = content.left(MaxOutputChars) + content = content.left(MaxOutputChars) +
QStringLiteral("\n[... truncated ...]"); QStringLiteral("\n[... truncated ...]");
completeJob(job, makeOutput(content)); completeJob(job, makeOutput(content));
}); });
} }
@@ -256,14 +243,12 @@ void WebFetchTool::execute(
void WebFetchTool::cancel() { void WebFetchTool::cancel() {
for (auto* job : m_jobs) { for (auto* job : m_jobs) {
job->timer->stop(); job->timer->stop();
if (job->reply) if (job->reply) job->reply->abort();
job->reply->abort();
} }
} }
QString WebFetchTool::decodeEntities(const QString& text) { QString WebFetchTool::decodeEntities(const QString& text) {
if (!text.contains(QLatin1Char('&'))) if (!text.contains(QLatin1Char('&'))) return text;
return text;
QString out; QString out;
out.reserve(text.size()); out.reserve(text.size());
for (qsizetype i = 0; i < text.size(); ++i) { for (qsizetype i = 0; i < text.size(); ++i) {
@@ -292,10 +277,11 @@ QString WebFetchTool::decodeEntities(const QString& text) {
replacement = QLatin1Char(' '); replacement = QLatin1Char(' ');
else { else {
bool ok = false; bool ok = false;
const quint32 codePoint = entity.startsWith(QLatin1String("#x")) || const quint32 codePoint =
entity.startsWith(QLatin1String("#X")) entity.startsWith(QLatin1String("#x")) ||
? entity.mid(2).toUInt(&ok, 16) entity.startsWith(QLatin1String("#X"))
: entity.toUInt(&ok); ? entity.mid(2).toUInt(&ok, 16)
: entity.toUInt(&ok);
if (ok && codePoint != 0) { if (ok && codePoint != 0) {
const char32_t ucs4[2] = { const char32_t ucs4[2] = {
static_cast<char32_t>(codePoint), static_cast<char32_t>(codePoint),
@@ -326,12 +312,18 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) {
QStringLiteral("style"), QStringLiteral("style"),
}; };
static const QSet<QString> kVoidTags = { static const QSet<QString> kVoidTags = {
QStringLiteral("area"), QStringLiteral("base"), QStringLiteral("area"),
QStringLiteral("br"), QStringLiteral("col"), QStringLiteral("base"),
QStringLiteral("embed"), QStringLiteral("hr"), QStringLiteral("br"),
QStringLiteral("img"), QStringLiteral("input"), QStringLiteral("col"),
QStringLiteral("link"), QStringLiteral("meta"), QStringLiteral("embed"),
QStringLiteral("source"), QStringLiteral("track"), QStringLiteral("hr"),
QStringLiteral("img"),
QStringLiteral("input"),
QStringLiteral("link"),
QStringLiteral("meta"),
QStringLiteral("source"),
QStringLiteral("track"),
QStringLiteral("wbr"), QStringLiteral("wbr"),
}; };
@@ -342,55 +334,43 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) {
while (i < html.size()) { while (i < html.size()) {
const qsizetype open = html.indexOf(QLatin1Char('<'), i); const qsizetype open = html.indexOf(QLatin1Char('<'), i);
if (open < 0) { if (open < 0) {
if (skipDepth == 0) if (skipDepth == 0) text += html.mid(i);
text += html.mid(i);
break; break;
} }
if (skipDepth == 0) if (skipDepth == 0) text += html.mid(i, open - i);
text += html.mid(i, open - i);
const qsizetype close = html.indexOf(QLatin1Char('>'), open); const qsizetype close = html.indexOf(QLatin1Char('>'), open);
if (close < 0) if (close < 0) break;
break;
const QString tag = const QString tag =
html.mid(open + 1, close - open - 1).trimmed().toLower(); html.mid(open + 1, close - open - 1).trimmed().toLower();
i = close + 1; i = close + 1;
if (tag.startsWith(QLatin1Char('!')) || if (tag.startsWith(QLatin1Char('!')) ||
tag.startsWith(QLatin1Char('?'))) tag.startsWith(QLatin1Char('?')))
continue; continue;
QString name = tag; QString name = tag;
if (name.startsWith(QLatin1Char('/'))) { if (name.startsWith(QLatin1Char('/'))) {
if (skipDepth > 0) if (skipDepth > 0) --skipDepth;
--skipDepth;
continue; continue;
} }
qsizetype j = 0; qsizetype j = 0;
while (j < name.size() && while (j < name.size() && (name.at(j).isLetterOrNumber() ||
(name.at(j).isLetterOrNumber() || name.at(j) == QLatin1Char(':') ||
name.at(j) == QLatin1Char(':') || name.at(j) == QLatin1Char('-')))
name.at(j) == QLatin1Char('-')))
++j; ++j;
name = name.left(j); name = name.left(j);
if (kRawTags.contains(name)) { if (kRawTags.contains(name)) {
// Raw-text element: swallow everything up to its close tag. const qsizetype rawEnd = html.indexOf(
const qsizetype rawEnd = QStringLiteral("</") + name, i, Qt::CaseInsensitive);
html.indexOf(QStringLiteral("</") + name, i, if (rawEnd < 0) break;
Qt::CaseInsensitive);
if (rawEnd < 0)
break;
const qsizetype rawClose = html.indexOf(QLatin1Char('>'), rawEnd); const qsizetype rawClose = html.indexOf(QLatin1Char('>'), rawEnd);
if (rawClose < 0) if (rawClose < 0) break;
break;
i = rawClose + 1; i = rawClose + 1;
continue; continue;
} }
if (kVoidTags.contains(name)) if (kVoidTags.contains(name)) continue;
continue;
if (skipDepth > 0) { if (skipDepth > 0) {
// Browsers implicitly close <head> at <body>; malformed pages
// without a </head> would otherwise swallow the whole page.
if (name == QLatin1String("body")) { if (name == QLatin1String("body")) {
skipDepth = 0; skipDepth = 0;
continue; continue;
@@ -402,7 +382,6 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) {
++skipDepth; ++skipDepth;
continue; continue;
} }
// Normal tag: replace with a space so words do not merge.
text += QLatin1Char(' '); text += QLatin1Char(' ');
} }
@@ -411,8 +390,7 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) {
for (const QString& line : out.split(QLatin1Char('\n'))) { for (const QString& line : out.split(QLatin1Char('\n'))) {
const QString flat = line.simplified(); const QString flat = line.simplified();
if (flat.isEmpty()) { if (flat.isEmpty()) {
if (!lines.isEmpty() && lines.last().isEmpty()) if (!lines.isEmpty() && lines.last().isEmpty()) continue;
continue;
lines.append(QString()); lines.append(QString());
} else { } else {
lines.append(flat); lines.append(flat);
-9
View File
@@ -13,11 +13,6 @@ class QTimer;
namespace ZShell::llm { 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 { class WebFetchTool : public LlmTool {
Q_OBJECT Q_OBJECT
@@ -25,8 +20,6 @@ class WebFetchTool : public LlmTool {
static constexpr int MaxResponseBytes = 5 * 1024 * 1024; static constexpr int MaxResponseBytes = 5 * 1024 * 1024;
static constexpr int DefaultTimeoutSeconds = 30; static constexpr int DefaultTimeoutSeconds = 30;
static constexpr int MaxTimeoutSeconds = 120; 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; static constexpr int MaxOutputChars = 64 * 1024;
explicit WebFetchTool(QObject* parent = nullptr); explicit WebFetchTool(QObject* parent = nullptr);
@@ -40,8 +33,6 @@ class WebFetchTool : public LlmTool {
std::function<void(const QJsonObject& result)> done) override; std::function<void(const QJsonObject& result)> done) override;
void cancel() 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 extractTextFromHtml(const QString& html);
static QString decodeEntities(const QString& text); static QString decodeEntities(const QString& text);
+2 -2
View File
@@ -9,7 +9,7 @@ qml_module(ZShell-services
cavaprovider.hpp cavaprovider.cpp cavaprovider.hpp cavaprovider.cpp
desktopmodel.hpp desktopmodel.cpp desktopmodel.hpp desktopmodel.cpp
desktopstatemanager.hpp desktopstatemanager.cpp desktopstatemanager.hpp desktopstatemanager.cpp
hyprsunsetmanager.hpp hyprsunsetmanager.cpp nightlightmanager.hpp nightlightmanager.cpp
tickingservice.hpp tickingservice.cpp tickingservice.hpp tickingservice.cpp
sensorslib.hpp sensorslib.cpp sensorslib.hpp sensorslib.cpp
usagefmt.hpp usagefmt.cpp usagefmt.hpp usagefmt.cpp
@@ -25,4 +25,4 @@ qml_module(ZShell-services
PkgConfig::Aubio PkgConfig::Aubio
PkgConfig::Cava PkgConfig::Cava
Sensors::Sensors Sensors::Sensors
) )
@@ -1,148 +0,0 @@
#include "hyprsunsetmanager.hpp"
#include <qlogging.h>
#include <qobject.h>
#include <qprocess.h>
#include <qtimer.h>
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
@@ -1,71 +0,0 @@
#pragma once
#include <QObject>
#include <QTime>
#include <QProcess>
#include <QTimer>
#include <qqmlintegration.h>
#include <qtmetamacros.h>
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
@@ -0,0 +1,442 @@
#include "nightlightmanager.hpp"
#include <qfileinfo.h>
#include <qjsondocument.h>
#include <qjsonobject.h>
#include <qlogging.h>
#include <qtenvironmentvariables.h>
namespace ZShell::services {
namespace {
constexpr const char* NL_EVENT = "zshell-nightlight";
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 <prefix>/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;
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
@@ -0,0 +1,122 @@
#pragma once
#include <QByteArray>
#include <QLocalSocket>
#include <QObject>
#include <QProcess>
#include <QTime>
#include <QTimer>
#include <qqmlintegration.h>
#include <qtmetamacros.h>
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
+5 -9
View File
@@ -13,21 +13,17 @@ import qs.Paths
Singleton { Singleton {
id: root id: root
readonly property M3Palette current: M3Palette { readonly property M3Palette current: M3Palette {}
}
property bool currentLight property bool currentLight
property string flavour property string flavour
readonly property bool light: showPreview ? previewLight : currentLight readonly property bool light: showPreview ? previewLight : currentLight
readonly property M3Palette palette: showPreview ? preview : current readonly property M3Palette palette: showPreview ? preview : current
readonly property M3Palette preview: M3Palette { readonly property M3Palette preview: M3Palette {}
}
property bool previewLight property bool previewLight
property string scheme property string scheme
property bool showPreview property bool showPreview
readonly property M3TPalette tPalette: M3TPalette { readonly property M3TPalette tPalette: M3TPalette {}
} readonly property Transparency transparency: Transparency {}
readonly property Transparency transparency: Transparency {
}
readonly property alias wallLuminance: analyser.luminance readonly property alias wallLuminance: analyser.luminance
function alterColor(c: color, a: real, layer: int): color { function alterColor(c: color, a: real, layer: int): color {
@@ -112,7 +108,7 @@ Singleton {
function setMode(mode: string): void { function setMode(mode: string): void {
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]); Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]);
Config.general.color.mode = mode; Config.colors.mode = mode;
} }
function swapRG(c: color): color { function swapRG(c: color): color {
+4 -1
View File
@@ -1,6 +1,6 @@
FROM archlinux:latest FROM archlinux:latest
RUN pacman -Syu --noconfirm RUN pacman -Syyu --noconfirm
# Build toolchain # Build toolchain
RUN pacman -S --noconfirm \ RUN pacman -S --noconfirm \
@@ -10,6 +10,9 @@ RUN pacman -S --noconfirm \
clang \ clang \
lld \ lld \
nodejs \ nodejs \
tree-sitter \
jkqtplotter \
cmark-gfm \
git git
# Qt6 # Qt6
+5 -6
View File
@@ -94,9 +94,8 @@ def list_presets(
if v.accents: if v.accents:
entry["accents"] = sorted(v.accents) entry["accents"] = sorted(v.accents)
entry["default_accent"] = sorted(v.accents)[0] entry["default_accent"] = sorted(v.accents)[0]
variants[v.id] = entry variants[v.id.lower()] = entry
out[meta.name] = { out[sid.lower()] = {
"id": sid,
"variants": variants, "variants": variants,
} }
print(json.dumps({"presets": out}, indent=2)) print(json.dumps({"presets": out}, indent=2))
@@ -626,8 +625,8 @@ def generate(
scheme_type = config["colors"].get("schemeType", "fruit-salad") scheme_type = config["colors"].get("schemeType", "fruit-salad")
scheme = scheme or scheme_type scheme = scheme or scheme_type
assert isinstance(scheme, str) assert isinstance(scheme, str)
config_mode = config["general"]["color"]["mode"] config_mode = config["colors"]["mode"]
smart = bool(config["general"]["color"].get("smart", False)) smart = bool(config["colors"].get("smart", False))
scheme_class = get_scheme_class(scheme) scheme_class = get_scheme_class(scheme)
p_variant = "default" p_variant = "default"
@@ -670,7 +669,7 @@ def generate(
name = palette_obj.scheme name = palette_obj.scheme
flavor = palette_obj.variant flavor = palette_obj.variant
display_name = schemes[p_scheme].name display_name = schemes[p_scheme].id
config["colors"]["presets"] = { config["colors"]["presets"] = {
"name": display_name, "name": display_name,
"variant": p_variant, "variant": p_variant,
+374 -444
View File
@@ -8,452 +8,392 @@ from urllib.error import HTTPError
from urllib.request import urlopen from urllib.request import urlopen
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DEVICON_VERSION = "v2.17.0" DEVICON_VERSION = "v2.17.0"
ICONS = { ICONS = {
"c": "c", "c": "c",
"cpp": "cplusplus", "cpp": "cplusplus",
"csharp": "csharp", "csharp": "csharp",
"python": "python", "python": "python",
"rust": "rust", "rust": "rust",
"javascript": "javascript", "javascript": "javascript",
"typescript": "typescript", "typescript": "typescript",
"bash": "bash", "bash": "bash",
"zsh": "zsh", "zsh": "zsh",
"powershell": "powershell", "powershell": "powershell",
"cmake": "cmake", "cmake": "cmake",
"lua": "lua", "lua": "lua",
"java": "java", "java": "java",
"kotlin": "kotlin", "kotlin": "kotlin",
"swift": "swift", "swift": "swift",
"go": "go", "go": "go",
"dart": "dart", "dart": "dart",
"php": "php", "php": "php",
"ruby": "ruby", "ruby": "ruby",
"scala": "scala", "scala": "scala",
"haskell": "haskell", "haskell": "haskell",
"elixir": "elixir", "elixir": "elixir",
"erlang": "erlang", "erlang": "erlang",
"clojure": "clojure", "clojure": "clojure",
"r": "r", "r": "r",
"perl": "perl", "perl": "perl",
"zig": "zig", "zig": "zig",
"nim": "nim", "nim": "nim",
"ocaml": "ocaml", "ocaml": "ocaml",
"fsharp": "fsharp", "fsharp": "fsharp",
"visualbasic": "visualbasic", "visualbasic": "visualbasic",
"fortran": "fortran", "fortran": "fortran",
"crystal": "crystal", "crystal": "crystal",
"gleam": "gleam", "gleam": "gleam",
"julia": "julia", "julia": "julia",
"objectivec": "objectivec", "objectivec": "objectivec",
"vala": "vala", "vala": "vala",
"groovy": "groovy", "groovy": "groovy",
"racket": "racket", "racket": "racket",
"haxe": "haxe", "haxe": "haxe",
"purescript": "purescript", "purescript": "purescript",
"delphi": "delphi", "delphi": "delphi",
"coffeescript": "coffeescript", "coffeescript": "coffeescript",
"elm": "elm", "elm": "elm",
"awk": "awk", "awk": "awk",
"matlab": "matlab", "matlab": "matlab",
"solidity": "solidity", "solidity": "solidity",
"wasm": "wasm", "wasm": "wasm",
"vim": "vim", "vim": "vim",
"sql": "sqlite", "sql": "sqlite",
"json": "json", "json": "json",
"yaml": "yaml", "yaml": "yaml",
"xml": "xml", "xml": "xml",
"html": "html5", "html": "html5",
"css": "css3", "css": "css3",
"sass": "sass", "sass": "sass",
"markdown": "markdown", "markdown": "markdown",
"docker": "docker", "docker": "docker",
"latex": "latex", "latex": "latex",
"graphql": "graphql", "graphql": "graphql",
} }
# Markdown / syntax-highlighter aliases -> Devicon icon name.
ALIASES = { ALIASES = {
"cpp": "cpp", "cpp": "cpp",
"cc": "cpp", "cc": "cpp",
"cxx": "cpp", "cxx": "cpp",
"cs": "csharp",
"cs": "csharp", "js": "javascript",
"jsx": "javascript",
"js": "javascript", "ts": "typescript",
"jsx": "javascript", "tsx": "typescript",
"py": "python",
"ts": "typescript", "sh": "bash",
"tsx": "typescript", "shell": "bash",
"ps1": "powershell",
"py": "python", "vb": "visualbasic",
"objective-c": "objectivec",
"sh": "bash", "obj-c": "objectivec",
"shell": "bash", "groovyscript": "groovy",
"pascal": "delphi",
"ps1": "powershell", "coffee": "coffeescript",
"mysql": "sql",
"vb": "visualbasic", "postgres": "sql",
"postgresql": "sql",
"objective-c": "objectivec", "sqlite": "sql",
"obj-c": "objectivec", "yml": "yaml",
"htm": "html",
"groovyscript": "groovy", "scss": "sass",
"md": "markdown",
"pascal": "delphi", "dockerfile": "docker",
"docker-compose": "docker",
"coffee": "coffeescript", "tex": "latex",
"gql": "graphql",
"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 = ( ICON_VARIANTS = (
"plain", "plain",
"original", "original",
) )
def fetch_svg(devicon_name: str) -> str: def fetch_svg(devicon_name: str) -> str:
for variant in ICON_VARIANTS: for variant in ICON_VARIANTS:
url = ( url = (
f"https://raw.githubusercontent.com/devicons/devicon/" f"https://raw.githubusercontent.com/devicons/devicon/"
f"{DEVICON_VERSION}/icons/{devicon_name}/" f"{DEVICON_VERSION}/icons/{devicon_name}/"
f"{devicon_name}-{variant}.svg" f"{devicon_name}-{variant}.svg"
) )
print(f"Fetching {devicon_name} ({variant}): {url}") print(f"Fetching {devicon_name} ({variant}): {url}")
try: try:
with urlopen(url) as response: with urlopen(url) as response:
return response.read().decode("utf-8") return response.read().decode("utf-8")
except HTTPError as error: except HTTPError as error:
if error.code != 404: if error.code != 404:
raise raise
raise RuntimeError( raise RuntimeError(f"No usable SVG variant found for '{devicon_name}'.")
f"No usable SVG variant found for '{devicon_name}'."
)
def element_to_path(element) -> str: def element_to_path(element) -> str:
"""Convert a basic SVG shape element to equivalent path data.""" """Convert a basic SVG shape element to equivalent path data."""
tag = element.tag.rsplit("}", 1)[-1] tag = element.tag.rsplit("}", 1)[-1]
attrib = element.attrib attrib = element.attrib
if tag == "path": if tag == "path":
return attrib.get("d", "") return attrib.get("d", "")
if tag == "circle": if tag == "circle":
cx = float(attrib["cx"]) cx = float(attrib["cx"])
cy = float(attrib["cy"]) cy = float(attrib["cy"])
r = float(attrib["r"]) r = float(attrib["r"])
return ( return (
f"M {cx - r},{cy} " 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 "
f"a {r},{r} 0 1,0 {-2 * r},0 Z" f"a {r},{r} 0 1,0 {-2 * r},0 Z"
) )
if tag == "ellipse": if tag == "ellipse":
cx = float(attrib["cx"]) cx = float(attrib["cx"])
cy = float(attrib["cy"]) cy = float(attrib["cy"])
rx = float(attrib["rx"]) rx = float(attrib["rx"])
ry = float(attrib["ry"]) ry = float(attrib["ry"])
return ( return (
f"M {cx - rx},{cy} " 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 "
f"a {rx},{ry} 0 1,0 {-2 * rx},0 Z" f"a {rx},{ry} 0 1,0 {-2 * rx},0 Z"
) )
if tag == "rect": if tag == "rect":
x = float(attrib["x"]) x = float(attrib["x"])
y = float(attrib["y"]) y = float(attrib["y"])
width = float(attrib["width"]) width = float(attrib["width"])
height = float(attrib["height"]) height = float(attrib["height"])
return f"M {x},{y} h {width} v {height} h {-width} Z" return f"M {x},{y} h {width} v {height} h {-width} Z"
if tag in ("polygon", "polyline"): if tag in ("polygon", "polyline"):
points = attrib["points"].split() points = attrib["points"].split()
commands = [ commands = [
f"{float(points[i])},{float(points[i + 1])}" f"{float(points[i])},{float(points[i + 1])}"
for i in range(0, len(points) - 1, 2) for i in range(0, len(points) - 1, 2)
] ]
data = "M " + " L ".join(commands) data = "M " + " L ".join(commands)
if tag == "polygon": if tag == "polygon":
data += " Z" data += " Z"
return data return data
if tag == "line": if tag == "line":
return ( return (
f"M {float(attrib['x1'])},{float(attrib['y1'])} " f"M {float(attrib['x1'])},{float(attrib['y1'])} "
f"L {float(attrib['x2'])},{float(attrib['y2'])}" 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" PATH_COMMANDS = "MmLlHhVvCcSsQqTtAaZz"
_NUMBER = re.compile(r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?") _NUMBER = re.compile(r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?")
def _skip_separators(data: str, i: int) -> int: def _skip_separators(data: str, i: int) -> int:
while i < len(data) and data[i] in " \t\r\n,": while i < len(data) and data[i] in " \t\r\n,":
i += 1 i += 1
return i return i
def _read_number(data: str, i: int) -> tuple[str, int]: def _read_number(data: str, i: int) -> tuple[str, int]:
i = _skip_separators(data, i) i = _skip_separators(data, i)
match = _NUMBER.match(data, i) match = _NUMBER.match(data, i)
if not match: if not match:
raise ValueError( raise ValueError(
f"Expected number in path data at {i}: {data[i:i + 24]!r}" f"Expected number in path data at {i}: {data[i : i + 24]!r}"
) )
return match.group(0), match.end() return match.group(0), match.end()
def parse_path(data: str) -> list[tuple[str, list[str]]]: def parse_path(data: str) -> list[tuple[str, list[str]]]:
"""Parse SVG path data into (command, parameters) tuples.""" """Parse SVG path data into (command, parameters) tuples."""
commands: list[tuple[str, list[str]]] = [] commands: list[tuple[str, list[str]]] = []
i, length = 0, len(data) i, length = 0, len(data)
while i < length: while i < length:
i = _skip_separators(data, i) i = _skip_separators(data, i)
if i >= length: if i >= length:
break break
command = data[i] command = data[i]
if command not in PATH_COMMANDS: if command not in PATH_COMMANDS:
raise ValueError( raise ValueError(
f"Unexpected character {command!r} at {i} in {data!r}" f"Unexpected character {command!r} at {i} in {data!r}"
) )
i += 1 i += 1
if command in "Zz": if command in "Zz":
commands.append((command, [])) commands.append((command, []))
continue continue
implicit = command implicit = command
while i < length: while i < length:
i = _skip_separators(data, i) i = _skip_separators(data, i)
if i >= length or data[i] in PATH_COMMANDS: if i >= length or data[i] in PATH_COMMANDS:
break break
if implicit in "Mm": if implicit in "Mm":
x, i = _read_number(data, i) x, i = _read_number(data, i)
y, i = _read_number(data, i) y, i = _read_number(data, i)
commands.append((implicit, [x, y])) commands.append((implicit, [x, y]))
# Subsequent coordinate pairs after a moveto are linetos. implicit = "L" if implicit == "M" else "l"
implicit = "L" if implicit == "M" else "l" elif implicit in "LlTt":
elif implicit in "LlTt": x, i = _read_number(data, i)
x, i = _read_number(data, i) y, i = _read_number(data, i)
y, i = _read_number(data, i) commands.append((implicit, [x, y]))
commands.append((implicit, [x, y])) elif implicit in "Hh":
elif implicit in "Hh": x, i = _read_number(data, i)
x, i = _read_number(data, i) commands.append((implicit, [x]))
commands.append((implicit, [x])) elif implicit in "Vv":
elif implicit in "Vv": y, i = _read_number(data, i)
y, i = _read_number(data, i) commands.append((implicit, [y]))
commands.append((implicit, [y])) elif implicit in "Cc":
elif implicit in "Cc": p = []
# Cubic bezier: x1 y1 x2 y2 x y for _ in range(6):
p = [] value, i = _read_number(data, i)
for _ in range(6): p.append(value)
value, i = _read_number(data, i) commands.append((implicit, p))
p.append(value) elif implicit in "SsQq":
commands.append((implicit, p)) p = []
elif implicit in "SsQq": for _ in range(4):
# Smooth cubic / quadratic: x1 y1 x y value, i = _read_number(data, i)
p = [] p.append(value)
for _ in range(4): commands.append((implicit, p))
value, i = _read_number(data, i) elif implicit in "Aa":
p.append(value) rx, i = _read_number(data, i)
commands.append((implicit, p)) ry, i = _read_number(data, i)
elif implicit in "Aa": rotation, i = _read_number(data, i)
rx, i = _read_number(data, i) i = _skip_separators(data, i)
ry, i = _read_number(data, i) large_arc = data[i]
rotation, i = _read_number(data, i) i += 1
# Arc flags are single digits, possibly crammed against i = _skip_separators(data, i)
# the following coordinate, so read them positionally. sweep = data[i]
i = _skip_separators(data, i) i += 1
large_arc = data[i] if large_arc not in "01" or sweep not in "01":
i += 1 raise ValueError(
i = _skip_separators(data, i) f"Invalid arc flags near {i - 2} in {data!r}"
sweep = data[i] )
i += 1 x, i = _read_number(data, i)
if large_arc not in "01" or sweep not in "01": y, i = _read_number(data, i)
raise ValueError( commands.append(
f"Invalid arc flags near {i - 2} in {data!r}" (implicit, [rx, ry, rotation, large_arc, sweep, x, y])
) )
x, i = _read_number(data, i) else:
y, i = _read_number(data, i) raise ValueError(f"Unhandled path command {implicit!r}")
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: def normalize_path(data: str) -> str:
"""Re-emit path data with canonical spacing (Qt PathSvg friendly).""" """Re-emit path data with canonical spacing (Qt PathSvg friendly)."""
return " ".join( return " ".join(
command if not params else command + " " + " ".join(params) command if not params else command + " " + " ".join(params)
for command, params in parse_path(data) for command, params in parse_path(data)
) )
def extract_paths(svg: str) -> str: def extract_paths(svg: str) -> str:
root = ET.fromstring(svg) root = ET.fromstring(svg)
paths = [] paths = []
for element in root.iter(): for element in root.iter():
path = element_to_path(element) path = element_to_path(element)
if not path: if not path:
continue 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):
if parse_path(normalized) != parse_path(path): raise RuntimeError(
raise RuntimeError( "Path normalization round-trip mismatch:\n"
"Path normalization round-trip mismatch:\n" f" in : {path[:80]}\n out: {normalized[:80]}"
f" in : {path[:80]}\n out: {normalized[:80]}" )
)
paths.append(normalized) paths.append(normalized)
if not paths: if not paths:
raise RuntimeError("SVG contains no drawable shape elements") 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: 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: 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(): if name and name[0].isdigit():
name = "_" + name name = "_" + name
return name return name
# ---------------------------------------------------------------------------
# Generation
# ---------------------------------------------------------------------------
def generate(output: Path) -> None: def generate(output: Path) -> None:
icons = {} icons = {}
for name, devicon_name in ICONS.items(): for name, devicon_name in ICONS.items():
svg = fetch_svg(devicon_name) svg = fetch_svg(devicon_name)
icons[name] = extract_paths(svg) 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: with output.open("w", encoding="utf-8") as file:
file.write( file.write(
"""pragma Singleton """pragma Singleton
import Quickshell import Quickshell
import QtQuick import QtQuick
Singleton { Singleton {
""" """
) )
# ------------------------------------------------------------------- for icon, path in icons.items():
# Icon path properties identifier = qml_identifier(icon)
# -------------------------------------------------------------------
for icon, path in icons.items(): file.write(
identifier = qml_identifier(icon) f" readonly property string {identifier}: "
f"{qml_string(path)}\n"
)
file.write( file.write("\n")
f" readonly property string {identifier}: "
f"{qml_string(path)}\n"
)
file.write("\n") file.write(
""" function path(language) {
# -------------------------------------------------------------------
# Language -> icon lookup
# -------------------------------------------------------------------
file.write(
""" function path(language) {
const key = language.toLowerCase() const key = language.toLowerCase()
switch (key) { switch (key) {
""" """
) )
for icon in icons: for icon in icons:
identifier = qml_identifier(icon) identifier = qml_identifier(icon)
file.write( file.write(
f' case "{icon}":\n' f' case "{icon}":\n return {identifier}\n'
f" return {identifier}\n" )
)
for alias, icon in ALIASES.items(): for alias, icon in ALIASES.items():
if icon not in icons: if icon not in icons:
raise RuntimeError( raise RuntimeError(
f"Alias '{alias}' points to unavailable icon '{icon}'" f"Alias '{alias}' points to unavailable icon '{icon}'"
) )
identifier = qml_identifier(icon) identifier = qml_identifier(icon)
file.write( file.write(
f' case "{alias}":\n' f' case "{alias}":\n return {identifier}\n'
f" return {identifier}\n" )
)
file.write( file.write(
""" default: """ default:
return "" return ""
} }
} }
@@ -461,112 +401,102 @@ Singleton {
function name(language) { function name(language) {
switch (language.toLowerCase()) { switch (language.toLowerCase()) {
""" """
) )
# Human-readable names can initially be handled here. names = {
# Add/remove these as your codeblock UI evolves. "c": "C",
names = { "cpp": "C++",
"c": "C", "csharp": "C#",
"cpp": "C++", "python": "Python",
"csharp": "C#", "rust": "Rust",
"python": "Python", "javascript": "JavaScript",
"rust": "Rust", "typescript": "TypeScript",
"javascript": "JavaScript", "qml": "QML",
"typescript": "TypeScript", "bash": "Bash",
"qml": "QML", "zsh": "Zsh",
"bash": "Bash", "powershell": "PowerShell",
"zsh": "Zsh", "cmake": "CMake",
"powershell": "PowerShell", "lua": "Lua",
"cmake": "CMake", "java": "Java",
"lua": "Lua", "kotlin": "Kotlin",
"java": "Java", "swift": "Swift",
"kotlin": "Kotlin", "go": "Go",
"swift": "Swift", "dart": "Dart",
"go": "Go", "php": "PHP",
"dart": "Dart", "ruby": "Ruby",
"php": "PHP", "scala": "Scala",
"ruby": "Ruby", "haskell": "Haskell",
"scala": "Scala", "elixir": "Elixir",
"haskell": "Haskell", "erlang": "Erlang",
"elixir": "Elixir", "clojure": "Clojure",
"erlang": "Erlang", "r": "R",
"clojure": "Clojure", "perl": "Perl",
"r": "R", "zig": "Zig",
"perl": "Perl", "nim": "Nim",
"zig": "Zig", "ocaml": "OCaml",
"nim": "Nim", "fsharp": "F#",
"ocaml": "OCaml", "visualbasic": "Visual Basic",
"fsharp": "F#", "fortran": "Fortran",
"visualbasic": "Visual Basic", "crystal": "Crystal",
"fortran": "Fortran", "gleam": "Gleam",
"crystal": "Crystal", "julia": "Julia",
"gleam": "Gleam", "objectivec": "Objective-C",
"julia": "Julia", "vala": "Vala",
"objectivec": "Objective-C", "groovy": "Groovy",
"vala": "Vala", "racket": "Racket",
"groovy": "Groovy", "haxe": "Haxe",
"racket": "Racket", "purescript": "PureScript",
"haxe": "Haxe", "delphi": "Delphi",
"purescript": "PureScript", "coffeescript": "CoffeeScript",
"delphi": "Delphi", "elm": "Elm",
"coffeescript": "CoffeeScript", "awk": "AWK",
"elm": "Elm", "matlab": "MATLAB",
"awk": "AWK", "solidity": "Solidity",
"matlab": "MATLAB", "wasm": "Wasm",
"solidity": "Solidity", "vim": "Vim",
"wasm": "Wasm", "sql": "SQL",
"vim": "Vim", "json": "JSON",
"sql": "SQL", "yaml": "YAML",
"json": "JSON", "xml": "XML",
"yaml": "YAML", "html": "HTML",
"xml": "XML", "css": "CSS",
"html": "HTML", "sass": "Sass",
"css": "CSS", "markdown": "Markdown",
"sass": "Sass", "docker": "Docker",
"markdown": "Markdown", "latex": "LaTeX",
"docker": "Docker", "graphql": "GraphQL",
"latex": "LaTeX", }
"graphql": "GraphQL",
}
for icon in icons: for icon in icons:
if icon not in names: if icon not in names:
continue continue
name = names[icon] name = names[icon]
file.write( file.write(f' case "{icon}":\n return "{name}"\n')
f' case "{icon}":\n'
f' return "{name}"\n'
)
for alias, icon in ALIASES.items(): for alias, icon in ALIASES.items():
if icon not in names: if icon not in names:
continue continue
file.write( file.write(
f' case "{alias}":\n' f' case "{alias}":\n return "{names[icon]}"\n'
f' return "{names[icon]}"\n' )
)
file.write( file.write(
""" default: """ default:
return language return language
} }
} }
} }
""" """
) )
print(f"Generated {output}") print(f"Generated {output}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if len(sys.argv) != 2: if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} OUTPUT.qml", file=sys.stderr) print(f"Usage: {sys.argv[0]} OUTPUT.qml", file=sys.stderr)
sys.exit(1) sys.exit(1)
generate(Path(sys.argv[1])) generate(Path(sys.argv[1]))