Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9f7856982
|
||
|
|
51dc0fc026 | ||
|
|
cef91caebc | ||
|
|
c5a089adaf | ||
|
|
52feb6006a | ||
|
|
dbb51ceadd | ||
|
|
487615f482 | ||
|
|
d4e936a7e2 | ||
|
|
703d804746 | ||
|
|
327270f3fa | ||
|
|
8a66c3b064 |
@@ -30,7 +30,6 @@ Flickable {
|
|||||||
|
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
root.doneFakeFlick = false;
|
root.doneFakeFlick = false;
|
||||||
root.returnToBounds();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-5
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ QtObject {
|
|||||||
Component {
|
Component {
|
||||||
WallpaperSelect {}
|
WallpaperSelect {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
ColorsFonts {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Component {
|
Component {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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]))
|
||||||
|
|||||||
Reference in New Issue
Block a user