22 Commits
Author SHA1 Message Date
zach fcb848b6d2 fix: truncate webfetch content to fit context
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 2m18s
C++ / clang-tidy (pull_request) Failing after 2m7s
JS/TS / fmt (pull_request) Failing after 9s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 29s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Successful in 1m38s
Rust / fmt (pull_request) Successful in 26s
Rust / clippy (pull_request) Successful in 47s
2026-09-03 22:46:46 +02:00
zach ae5c1fa148 use blobgroup for window chat interface + slim sidebar
C++ / fmt (pull_request) Successful in 4s
C++ / build (pull_request) Failing after 2m1s
C++ / clang-tidy (pull_request) Failing after 1m54s
JS/TS / fmt (pull_request) Failing after 7s
JS/TS / lint (pull_request) Successful in 7s
Python / static (pull_request) Successful in 29s
Rust / build (pull_request) Successful in 47s
Python / verify (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 29s
Rust / clippy (pull_request) Successful in 51s
2026-09-01 23:14:21 +02:00
zach b254000737 feat: implement findChild* methods
C++ / fmt (pull_request) Canceled after 0s
C++ / build (pull_request) Canceled after 0s
C++ / clang-tidy (pull_request) Canceled after 0s
JS/TS / lint (pull_request) Canceled after 0s
JS/TS / fmt (pull_request) Canceled after 2s
Python / static (pull_request) Canceled after 0s
Python / verify (pull_request) Canceled after 0s
Rust / build (pull_request) Canceled after 0s
Rust / fmt (pull_request) Canceled after 0s
Rust / clippy (pull_request) Canceled after 0s
2026-09-01 15:51:10 +02:00
zach 04fe8b1e43 feat: implement findChild* methods
C++ / fmt (pull_request) Canceled after 0s
C++ / build (pull_request) Canceled after 0s
C++ / clang-tidy (pull_request) Canceled after 0s
JS/TS / fmt (pull_request) Canceled after 0s
JS/TS / lint (pull_request) Canceled after 0s
Python / static (pull_request) Canceled after 0s
Python / verify (pull_request) Canceled after 0s
Rust / build (pull_request) Canceled after 0s
Rust / fmt (pull_request) Canceled after 0s
Rust / clippy (pull_request) Canceled after 0s
2026-09-01 15:49:21 +02:00
zach dfa6b010e6 fix: property link modules
C++ / fmt (pull_request) Canceled after 0s
C++ / build (pull_request) Canceled after 0s
C++ / clang-tidy (pull_request) Canceled after 0s
JS/TS / lint (pull_request) Canceled after 0s
JS/TS / fmt (pull_request) Canceled after 1s
Python / static (pull_request) Canceled after 0s
Python / verify (pull_request) Canceled after 0s
Rust / build (pull_request) Canceled after 0s
Rust / fmt (pull_request) Canceled after 0s
Rust / clippy (pull_request) Canceled after 0s
2026-09-01 15:42:27 +02:00
zach 8a4e9bac31 fix: add gnuinstalldirs include
C++ / fmt (pull_request) Successful in 6s
C++ / build (pull_request) Canceled after 15s
C++ / clang-tidy (pull_request) Canceled after 9s
JS/TS / fmt (pull_request) Canceled after 0s
JS/TS / lint (pull_request) Canceled after 0s
Python / verify (pull_request) Canceled after 0s
Python / static (pull_request) Canceled after 1s
Rust / fmt (pull_request) Canceled after 0s
Rust / clippy (pull_request) Canceled after 0s
Rust / build (pull_request) Canceled after 6s
2026-09-01 15:28:25 +02:00
zach f20fa45b29 fix: cmake install dir
Rust / build (pull_request) Canceled after 0s
Rust / fmt (pull_request) Canceled after 0s
Rust / clippy (pull_request) Canceled after 0s
Python / static (pull_request) Canceled after 0s
Python / verify (pull_request) Canceled after 0s
JS/TS / fmt (pull_request) Canceled after 0s
JS/TS / lint (pull_request) Canceled after 0s
C++ / fmt (pull_request) Canceled after 0s
C++ / build (pull_request) Canceled after 0s
C++ / clang-tidy (pull_request) Canceled after 0s
2026-09-01 15:15:15 +02:00
zach 53a3c76dc0 fix: cmake install dir
C++ / fmt (pull_request) Successful in 7s
JS/TS / fmt (pull_request) Failing after 14s
JS/TS / lint (pull_request) Successful in 18s
Python / static (pull_request) Successful in 57s
C++ / build (pull_request) Failing after 3m50s
C++ / clang-tidy (pull_request) Failing after 3m32s
Python / verify (pull_request) Canceled after 0s
Rust / build (pull_request) Canceled after 0s
Rust / fmt (pull_request) Canceled after 0s
Rust / clippy (pull_request) Canceled after 0s
2026-09-01 15:03:42 +02:00
zach 617e87d64f fix: uncomment settings index cache path
C++ / fmt (pull_request) Successful in 10s
C++ / clang-tidy (pull_request) Failing after 15m10s
C++ / build (pull_request) Failing after 16m4s
JS/TS / fmt (pull_request) Failing after 10s
JS/TS / lint (pull_request) Successful in 11s
Python / static (pull_request) Successful in 53s
Rust / fmt (pull_request) Successful in 1m33s
Rust / build (pull_request) Successful in 3m3s
Rust / clippy (pull_request) Successful in 2m59s
Python / verify (pull_request) Successful in 3m58s
2026-09-01 14:31:44 +02:00
zach ef2fb95fab chore: split cmake into multiple files and fix settings searching regex + add enabled option to llm
C++ / fmt (pull_request) Successful in 4s
JS/TS / fmt (pull_request) Failing after 17s
JS/TS / lint (pull_request) Successful in 19s
Python / static (pull_request) Successful in 1m11s
Rust / fmt (pull_request) Successful in 1m3s
Rust / build (pull_request) Successful in 2m26s
Rust / clippy (pull_request) Failing after 16m40s
Python / verify (pull_request) Failing after 17m52s
C++ / clang-tidy (pull_request) Failing after 18m0s
C++ / build (pull_request) Failing after 18m2s
2026-09-01 14:30:04 +02:00
zach 2150859847 fix: font family
JS/TS / fmt (pull_request) Successful in 19s
JS/TS / lint (pull_request) Successful in 18s
Rust / fmt (pull_request) Canceled after 0s
Rust / clippy (pull_request) Canceled after 0s
Rust / build (pull_request) Canceled after 5m56s
Python / static (pull_request) Canceled after 6m44s
Python / verify (pull_request) Canceled after 6m40s
C++ / fmt (pull_request) Successful in 8s
C++ / clang-tidy (pull_request) Canceled after 7m8s
C++ / build (pull_request) Failing after 1m24s
2026-08-31 22:54:52 +02:00
AramJonghu c9f7856982 fix(Docker): add required deps to dockerfile
JS/TS / fmt (pull_request) Successful in 11s
JS/TS / lint (pull_request) Successful in 11s
Python / static (pull_request) Successful in 45s
Rust / fmt (pull_request) Successful in 2m0s
Rust / build (pull_request) Successful in 2m24s
Rust / clippy (pull_request) Successful in 2m24s
Python / verify (pull_request) Successful in 2m48s
C++ / fmt (pull_request) Successful in 4s
C++ / clang-tidy (pull_request) Canceled after 5m34s
C++ / build (pull_request) Canceled after 0s
2026-08-31 22:46:42 +02:00
zachandAramJonghu 51dc0fc026 chore: format python codeicons 2026-08-31 22:44:57 +02:00
zach cef91caebc chore: format llm c files
C++ / fmt (pull_request) Successful in 16s
C++ / build (pull_request) Failing after 19s
JS/TS / fmt (pull_request) Successful in 10s
C++ / clang-tidy (pull_request) Failing after 29s
JS/TS / lint (pull_request) Successful in 23s
Python / static (pull_request) Failing after 48s
Rust / fmt (pull_request) Successful in 1m15s
Rust / build (pull_request) Successful in 1m46s
Rust / clippy (pull_request) Successful in 1m33s
Python / verify (pull_request) Successful in 2m16s
2026-08-31 19:06:49 +02:00
zach c5a089adaf chore: format llm c files
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 22s
JS/TS / lint (pull_request) Successful in 16s
Python / static (pull_request) Failing after 37s
C++ / clang-tidy (pull_request) Failing after 1m19s
Rust / fmt (pull_request) Successful in 1m24s
Rust / clippy (pull_request) Successful in 1m59s
Rust / build (pull_request) Successful in 2m12s
Python / verify (pull_request) Successful in 2m48s
2026-08-31 19:05:22 +02:00
zach 52feb6006a chore: format llm c files
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 9s
JS/TS / lint (pull_request) Successful in 9s
JS/TS / fmt (pull_request) Successful in 16s
C++ / clang-tidy (pull_request) Failing after 43s
Python / static (pull_request) Failing after 57s
Rust / fmt (pull_request) Successful in 1m30s
Rust / build (pull_request) Successful in 2m5s
Rust / clippy (pull_request) Successful in 1m55s
Python / verify (pull_request) Successful in 2m23s
2026-08-31 19:02:26 +02:00
zach dbb51ceadd feat: config object migration path and add new options to settings
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 12s
JS/TS / fmt (pull_request) Successful in 15s
JS/TS / lint (pull_request) Successful in 15s
C++ / clang-tidy (pull_request) Failing after 33s
Python / static (pull_request) Failing after 36s
Rust / fmt (pull_request) Successful in 1m14s
Rust / build (pull_request) Successful in 1m49s
Rust / clippy (pull_request) Successful in 1m33s
Python / verify (pull_request) Successful in 2m10s
2026-08-31 18:43:41 +02:00
zach 487615f482 fix: shader fade animation linear curve 2026-08-31 14:09:54 +02:00
zach d4e936a7e2 add nightlight hyprland plugin + rename hyprsunsetmanager -> nightlightmanager to control both 2026-08-31 13:53:37 +02:00
zach 703d804746 fab menu and model selector 2026-08-29 18:56:59 +02:00
zach 327270f3fa better support for opening chat in floating window 2026-08-28 14:15:57 +02:00
zach 8a66c3b064 expandable dialogs for color scheme, fonts and scheduling modes 2026-08-27 16:57:02 +02:00
118 changed files with 6927 additions and 3516 deletions
+20 -20
View File
@@ -26,22 +26,36 @@ if(NOT DEFINED VERSION)
endif()
endif()
if(NOT DEFINED GIT_REVISION)
execute_process(COMMAND git rev-parse HEAD
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
OUTPUT_VARIABLE GIT_REVISION
OUTPUT_STRIP_TRAILING_WHITESPACE
)
if("${GIT_REVISION}" STREQUAL "")
message(FATAL_ERROR "GIT_REVISION is not set and failed to get from git")
endif()
endif()
set(VERSION "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}")
project(ZShell VERSION ${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH})
message(STATUS "ZShell version: ${VERSION}")
include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(ENABLE_MODULES "plugin;shell;m3shapes" CACHE STRING "Modules to build/install")
set(ENABLE_MODULES "plugin;shell;cli;m3shapes" CACHE STRING "Modules to build/install")
set(INSTALL_LIBDIR "usr/lib/ZShell" CACHE STRING "Library install dir")
set(INSTALL_QMLDIR "usr/lib/qt6/qml" CACHE STRING "QML install dir")
set(INSTALL_QSCONFDIR "etc/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir")
set(INSTALL_GREETERCONFDIR "etc/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir")
set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/ZShell" CACHE STRING "Library install dir")
set(INSTALL_QMLDIR "${CMAKE_INSTALL_LIBDIR}/qt6/qml" CACHE STRING "QML install dir")
set(INSTALL_QSCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir")
set(INSTALL_GREETERCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir")
set(CMAKE_INSTALL_MESSAGE NEVER)
@@ -54,21 +68,7 @@ add_compile_options(
)
if("shell" IN_LIST ENABLE_MODULES)
# Build settings index
find_package(Python3 COMPONENTS Interpreter REQUIRED)
set(SETTINGS_INDEX_JSON "${CMAKE_BINARY_DIR}/settings-index.json")
execute_process(
COMMAND ${Python3_EXECUTABLE}
"${CMAKE_SOURCE_DIR}/scripts/build-settings-index.py"
"${CMAKE_SOURCE_DIR}/Modules/Settings"
"${SETTINGS_INDEX_JSON}"
RESULT_VARIABLE SETTINGS_INDEX_RESULT
)
if(NOT SETTINGS_INDEX_RESULT EQUAL 0)
message(FATAL_ERROR "Failed to build settings search index")
endif()
if("cli" IN_LIST ENABLE_MODULES)
# Nuitka compilation
set(ZSHELL_CLI_BUILD_DIR "${CMAKE_BINARY_DIR}/zshell-cli")
set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist")
-1
View File
@@ -30,7 +30,6 @@ Flickable {
onTriggered: {
root.doneFakeFlick = false;
root.returnToBounds();
}
}
}
+1 -2
View File
@@ -6,7 +6,6 @@ Rectangle {
color: "transparent"
Behavior on color {
CAnim {
}
CAnim {}
}
}
+3 -3
View File
@@ -12,7 +12,7 @@ ScrollBar {
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
readonly property real axisContentPos: isHorizontal ? flickable.contentX : flickable.contentY
readonly property real axisContentPos: isHorizontal ? (reversed ? flickable.contentX : (flickable.contentX - flickable.originX)) : (reversed ? flickable.contentY : (flickable.contentY - flickable.originY))
readonly property real axisLength: isHorizontal ? root.width : root.height
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
@@ -102,9 +102,9 @@ ScrollBar {
var newPos = contentPosFromThumbStart(thumbStart);
if (root.isHorizontal)
root.flickable.contentX = newPos;
root.flickable.contentX = newPos + (root.flickable.originX + (root.reversed ? root.flickable.contentWidth : 0));
else
root.flickable.contentY = newPos;
root.flickable.contentY = newPos + (root.flickable.originY + (root.reversed ? root.flickable.contentHeight : 0));
}
function visualThumbStart() {
+1
View File
@@ -12,6 +12,7 @@ TextEdit {
color: Colors.palette.m3onSurface
cursorVisible: !readOnly
font.pointSize: Tokens.font.size.small
font.family: Config.appearance.font.family.sans
renderType: TextField.NativeRendering
selectedTextColor: color
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
+5 -10
View File
@@ -13,21 +13,17 @@ import qs.Paths
Singleton {
id: root
readonly property M3Palette current: M3Palette {
}
readonly property M3Palette current: M3Palette {}
property bool currentLight
property string flavour
readonly property bool light: showPreview ? previewLight : currentLight
readonly property M3Palette palette: showPreview ? preview : current
readonly property M3Palette preview: M3Palette {
}
readonly property M3Palette preview: M3Palette {}
property bool previewLight
property string scheme
property bool showPreview
readonly property M3TPalette tPalette: M3TPalette {
}
readonly property Transparency transparency: Transparency {
}
readonly property M3TPalette tPalette: M3TPalette {}
readonly property Transparency transparency: Transparency {}
readonly property alias wallLuminance: analyser.luminance
function alterColor(c: color, a: real, layer: int): color {
@@ -112,8 +108,7 @@ Singleton {
function setMode(mode: string): void {
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]);
Config.general.color.mode = mode;
Config.save();
Config.colors.mode = mode;
}
function swapRG(c: color): color {
+8 -6
View File
@@ -9,13 +9,13 @@ Singleton {
id: root
readonly property bool enabled: service.enabled
readonly property int end: Config.general.color.scheduleHyprsunsetEnd
readonly property int end: Config.display.nightlight.scheduleEnd
property bool manualToggle: false
readonly property int start: Config.general.color.scheduleHyprsunsetStart
readonly property int temp: Config.general.color.hyprsunsetTemp
readonly property int start: Config.display.nightlight.scheduleStart
readonly property int temp: Config.display.nightlight.temp
function checkStartup(): void {
if (!Config.general.color.scheduleHyprsunset)
if (!Config.display.nightlight.schedule)
return;
service.apply();
@@ -26,12 +26,14 @@ Singleton {
service.toggle();
}
HyprsunsetManager {
NightlightManager {
id: service
activeAuto: Config.general.color.scheduleHyprsunset
activeAuto: Config.display.nightlight.schedule
endTime: root.end
startTime: root.start
temp: root.temp
fadeDuration: Config.display.nightlight.fadeDuration / 1000
useNativeNightlight: Config.display.nightlight.useNative
}
}
+6 -6
View File
@@ -11,14 +11,14 @@ import qs.Services
Singleton {
id: root
readonly property int darkEnd: Config.general.color.scheduleDarkEnd
readonly property int darkStart: Config.general.color.scheduleDarkStart
readonly property bool enabled: Config.general.color.scheduleDark && Config.general.color.schemeGeneration
readonly property int darkEnd: Config.colors.scheduleDarkEnd
readonly property int darkStart: Config.colors.scheduleDarkStart
readonly property bool enabled: Config.colors.scheduleDark && Config.colors.schemeGen
function applyDarkMode() {
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "dark"]);
Config.general.color.mode = "dark";
Config.colors.mode = "dark";
Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-dark'"]);
@@ -30,13 +30,13 @@ Singleton {
function applyLightMode() {
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "light"]);
Config.general.color.mode = "light";
Config.colors.mode = "light";
Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-light'"]);
Quickshell.execDetached(["sh", "-c", `sed -i 's/color_scheme_path=\\(.*\\)Dark.colors/color_scheme_path=\\1Light.colors/' ${Paths.home}/.config/qt6ct/qt6ct.conf`]);
if (Config.general.color.neovimColors)
if (Config.colors.neovimColors)
Quickshell.execDetached(["sed", "-i", "'s/\\(vim.cmd.colorscheme \\).*/\\1\"onelight\"/'", "~/.config/nvim/lua/config/load-colorscheme.lua"]);
}
+3 -3
View File
@@ -38,7 +38,7 @@ Searcher {
previewPath = path;
showPreview = true;
if (Config.general.color.schemeGeneration)
if (Config.colors.schemeGen)
previewColorsProc.running = true;
}
@@ -62,8 +62,8 @@ Searcher {
WallpaperPath.currentWallpaperPath = path;
Quickshell.screens.forEach(n => setCrop(n.name, Qt.rect(0, 0, 1, 1), 1.0));
Quickshell.execDetached(["zshell-cli", "wallpaper", "lockscreen", "--input-image", `${root.actualCurrent}`, "--output-path", `${Paths.state}/lockscreen_bg.png`, "--blur-amount", `${Config.lock.blurAmount}`]);
if (Config.general.color.schemeGeneration)
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.general.color.mode}`]);
if (Config.colors.schemeGen)
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.colors.mode}`]);
}
function stopPreview(): void {
+18 -1
View File
@@ -25,6 +25,8 @@ CustomListView {
return [0];
case "variant":
return SchemeVariants.query(text);
case "files":
return Files.query(text);
default:
return Apps.search(text);
}
@@ -33,7 +35,7 @@ CustomListView {
function stateForText(text: string): string {
const prefix = Config.launcher.actionPrefix;
if (text.startsWith(prefix)) {
for (const action of ["calc", "scheme", "variant"])
for (const action of ["calc", "scheme", "variant", "files"])
if (text.startsWith(`${prefix}${action} `))
return action;
@@ -163,6 +165,13 @@ CustomListView {
PropertyChanges {
root.delegate: variantItem
}
},
State {
name: "files"
PropertyChanges {
root.delegate: filesItem
}
}
]
transitions: Transition {
@@ -264,6 +273,14 @@ CustomListView {
}
}
Component {
id: filesItem
FilesItem {
list: root
}
}
Connections {
function onTextChanged() {
root.syncDisplayText();
+2
View File
@@ -85,6 +85,8 @@ Item {
} else if (text.startsWith(Config.launcher.actionPrefix)) {
if (text.startsWith(`${Config.launcher.actionPrefix}calc `))
currentItem.onClicked();
else if (text.startsWith(`${Config.launcher.actionPrefix}files `))
currentItem.onClicked();
else
currentItem.modelData.onClicked(list.currentList);
} else {
+1 -1
View File
@@ -16,7 +16,7 @@ Item {
implicitHeight: Config.launcher.sizes.itemHeight
StateLayer {
radius: Tokens.rounding.small
radius: Tokens.rounding.medium
onClicked: {
root.modelData?.onClicked(root.list);
+1 -1
View File
@@ -19,7 +19,7 @@ Item {
implicitHeight: Config.launcher.sizes.itemHeight
StateLayer {
radius: Tokens.rounding.small
radius: Tokens.rounding.medium
onClicked: {
Apps.launch(root.modelData);
+2 -3
View File
@@ -24,7 +24,7 @@ Item {
implicitHeight: Config.launcher.sizes.itemHeight
StateLayer {
radius: Tokens.rounding.small
radius: Tokens.rounding.medium
onClicked: {
root.onClicked();
@@ -97,8 +97,7 @@ Item {
text: qsTr("Open in calculator")
Behavior on opacity {
Anim {
}
Anim {}
}
}
+141
View File
@@ -0,0 +1,141 @@
import QtQuick
import Quickshell
import Quickshell.Widgets
import ZShell.Config
import qs.Components
import qs.Helpers
import qs.Modules.Launcher.Services
import qs.Services
Item {
id: root
required property var list
required property var modelData
anchors.left: parent?.left
anchors.right: parent?.right
implicitHeight: Config.launcher.sizes.itemHeight
function onClicked(): void {
root.list.visibilities.launcher = false;
if (root.modelData?.path)
Files.launch(["xdg-open", root.modelData.path]);
}
StateLayer {
id: sLayer
radius: Tokens.rounding.medium
manualHoverOverride: openLocation.hovered
onClicked: {
root.onClicked();
}
}
IconButton {
id: openLocation
anchors.right: parent.right
anchors.rightMargin: Tokens.padding.medium
anchors.verticalCenter: parent.verticalCenter
icon: "open_in_new"
isRound: true
scale: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0.4
opacity: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on scale {
Anim {
type: Anim.DefaultEffects
}
}
onClicked: {
root.list.visibilities.launcher = false;
if (root.modelData?.path)
Files.showInFolder(root.modelData.path);
}
}
Item {
anchors.fill: parent
anchors.leftMargin: Tokens.padding.small
anchors.margins: Tokens.padding.small
anchors.rightMargin: Tokens.padding.small
IconImage {
id: icon
anchors.verticalCenter: parent.verticalCenter
implicitSize: Tokens.font.size.extraLarge
source: Quickshell.iconPath(root.modelData?.icon) ?? ""
visible: !root.modelData?.isImage
}
Loader {
active: opacity > 0
anchors.verticalCenter: parent.verticalCenter
asynchronous: true
opacity: root.modelData?.isImage && image.status === Image.Loading ? 1 : 0
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
sourceComponent: LoadingIndicator {
color: Colors.palette.m3primaryContainer
implicitSize: Tokens.font.size.extraLarge
}
}
FadeImage {
id: image
property int implicitSize: Tokens.font.size.extraLarge
width: implicitSize
fillMode: Image.PreserveAspectFit
anchors.verticalCenter: parent.verticalCenter
source: root.modelData?.isImage ? root.modelData?.path ?? "" : ""
visible: root.modelData?.isImage
}
Item {
anchors.left: icon.right
anchors.leftMargin: Tokens.spacing.medium
anchors.verticalCenter: icon.verticalCenter
implicitHeight: name.implicitHeight + desc.implicitHeight
implicitWidth: parent.width - icon.width
CustomText {
id: name
font.pointSize: Tokens.font.size.normal
text: root.modelData?.name ?? ""
elide: Text.ElideRight
width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2
}
CustomText {
id: desc
anchors.top: name.bottom
color: Colors.palette.m3outline
elide: Text.ElideRight
font.pointSize: Tokens.font.size.small
text: root.modelData?.path ?? ""
width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2
}
}
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ Item {
implicitHeight: Config.launcher.sizes.itemHeight
StateLayer {
radius: Tokens.rounding.small
radius: Tokens.rounding.medium
onClicked: {
root.modelData?.onClicked(root.list);
+194
View File
@@ -0,0 +1,194 @@
// qs/Services/Files.qml
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import ZShell.Config
Singleton {
id: root
property list<var> results: []
property string _pendingQuery: ""
property int _generation: 0
readonly property var _imageExts: ({
"png": true,
"jpg": true,
"jpeg": true,
"gif": true,
"bmp": true,
"webp": true,
"svg": true,
"tiff": true,
"tif": true,
"ico": true,
"heic": true,
"avif": true
})
// freedesktop mimetype-icon names; extend as needed
readonly property var _extIcons: ({
// images
"png": "image-x-generic",
"jpg": "image-x-generic",
"jpeg": "image-x-generic",
"gif": "image-x-generic",
"bmp": "image-x-generic",
"webp": "image-x-generic",
"svg": "image-x-generic",
"tiff": "image-x-generic",
"tif": "image-x-generic",
"ico": "image-x-generic",
"heic": "image-x-generic",
"avif": "image-x-generic",
// documents
"pdf": "application-pdf",
"doc": "x-office-document",
"docx": "x-office-document",
"odt": "x-office-document",
"txt": "text-x-generic",
"md": "text-x-generic",
"xls": "x-office-spreadsheet",
"xlsx": "x-office-spreadsheet",
"ods": "x-office-spreadsheet",
"ppt": "x-office-presentation",
"pptx": "x-office-presentation",
"odp": "x-office-presentation",
// archives
"zip": "package-x-generic",
"tar": "package-x-generic",
"gz": "package-x-generic",
"xz": "package-x-generic",
"7z": "package-x-generic",
"rar": "package-x-generic",
// audio/video
"mp3": "audio-x-generic",
"flac": "audio-x-generic",
"wav": "audio-x-generic",
"ogg": "audio-x-generic",
"mp4": "video-x-generic",
"mkv": "video-x-generic",
"webm": "video-x-generic",
"avi": "video-x-generic",
"mov": "video-x-generic",
// code
"js": "text-x-script",
"ts": "text-x-script",
"py": "text-x-script",
"sh": "text-x-script",
"qml": "text-x-script",
"cpp": "text-x-c++src",
"c": "text-x-csrc",
"h": "text-x-chdr",
"json": "application-json",
"html": "text-html",
"css": "text-css"
})
function showInFolder(path: string): void {
const explorer = Config.general.apps.explorer;
let args = [];
if (explorer.includes("dolphin"))
args = [...explorer, "--select", path];
else
args = [...explorer, path];
if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "--", ...args]);
else
Quickshell.execDetached([...args]);
}
function launch(command: list<string>): void {
if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "--"].concat(command));
else
Quickshell.execDetached(command);
}
function transformSearch(search: string): string {
return search.slice(Config.launcher.actionPrefix.length + "files ".length);
}
function query(search: string): list<var> {
search = transformSearch(search);
if (search !== _pendingQuery) {
_pendingQuery = search;
debounceTimer.restart();
}
return results;
}
function _extOf(name: string): string {
const i = name.lastIndexOf(".");
// no dot, or dotfile with no real extension (".bashrc" -> treat as no ext)
if (i <= 0)
return "";
return name.slice(i + 1).toLowerCase();
}
function _iconFor(name: string, isDir: bool): string {
if (isDir)
return "folder";
const ext = _extOf(name);
return _extIcons[ext] ?? "text-x-generic";
}
function _buildEntry(path: string): var {
const name = path.split("/").pop();
const ext = _extOf(name);
// trailing slash from plocate's directory output (if -d/dir results ever appear)
const isDir = path.endsWith("/");
return {
path: path,
name: name,
icon: _iconFor(name, isDir),
isImage: !isDir && (_imageExts[ext] ?? false)
};
}
Timer {
id: debounceTimer
interval: 120
onTriggered: root._runQuery(root._pendingQuery)
}
function _runQuery(search: string): void {
_generation++;
if (!search) {
proc.running = false;
results = [];
return;
}
if (proc.running)
proc.running = false;
proc.buffer = [];
proc.myGeneration = _generation;
proc.command = ["plocate", "-i", "-b", "--limit", "50", search];
proc.running = true;
}
Process {
id: proc
property var buffer: []
property int myGeneration: 0
stdout: SplitParser {
onRead: line => proc.buffer.push(root._buildEntry(line))
}
onRunningChanged: {
if (!running && myGeneration === root._generation)
root.results = buffer;
}
}
}
@@ -87,10 +87,6 @@ Item {
signal requestClose
function scrollToBottom(): void {
Qt.callLater(() => listLoader.item?.positionViewAtBeginning());
}
function send(text: string): void {
if (text.trim() === "")
return;
@@ -129,11 +125,15 @@ Item {
isRound: true
type: IconButton.Tonal
enabled: !ChatState.isWindow
visible: enabled
onClicked: root.requestClose()
}
CustomText {
Layout.fillWidth: true
Layout.fillWidth: ChatState.isWindow ? false : true
Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter
elide: Text.ElideRight
font.pointSize: Tokens.font.size.larger
text: qsTr(root.chatData.title)
@@ -151,21 +151,20 @@ Item {
anchors.topMargin: Tokens.spacing.medium
active: root.chatData && root.chatData.loaded
sourceComponent: ListView {
sourceComponent: VerticalFadeListView {
id: list
property bool userScrolledUp: false
property real lastCHeight: 0.0
function scrollToBottom(): void {
scrollAnim.to = 0;
scrollAnim.start();
}
cacheBuffer: height * 20
cacheBuffer: Math.max(height * 20, 0)
clip: true
// fadeAmount: 0.05
// fadeThreshold: Tokens.padding.medium
fadeAmount: 0.05
fadeThreshold: Tokens.padding.medium
model: root.chatData.messagesModel
spacing: 0
rotation: 180
@@ -238,6 +237,7 @@ Item {
property: "contentY"
target: list
type: Anim.DefaultEffects
to: list.originY
}
WheelInverter {
@@ -255,7 +255,7 @@ Item {
anchors.fill: parent
spacing: Tokens.spacing.small
visible: !root.chatData
visible: root.chatData.messageCount < 1
}
Item {
@@ -2,6 +2,7 @@ import QtQuick
import ZShell.Components
import ZShell.Config
import ZShell.Llm
import qs.Effects
import qs.Components
import qs.Services
@@ -9,15 +10,20 @@ CustomClippingRect {
id: root
property bool expanded: false
property bool highlighted: false
property bool slim: false
required property ChatSession modelData
signal clicked(content: ChatSession)
signal remove(content: ChatSession)
color: Colors.tPalette.m3surfaceContainer
color: highlighted ? Qt.alpha(Colors.palette.m3primaryContainer, 0.3) : Qt.alpha(Colors.tPalette.m3surfaceContainer, 0.2)
implicitHeight: {
let h = 0;
if (slim)
return chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2;
h += infoContainer.implicitHeight;
if (expanded)
@@ -33,6 +39,20 @@ CustomClippingRect {
return h;
}
radius: Tokens.rounding.large
clip: true
CustomRect {
id: borderHighlight
anchors.fill: parent
border.width: root.highlighted ? 2 : 0
border.color: Colors.palette.m3secondary
radius: root.radius - border.width + 1
Behavior on border.width {
Anim {}
}
}
Behavior on implicitHeight {
enabled: titleText.readOnly
@@ -49,6 +69,7 @@ CustomClippingRect {
anchors.topMargin: Tokens.padding.large
font.pointSize: Tokens.font.size.extraLarge
text: root.modelData.icon || "check"
color: Colors.palette.m3onSurface
}
Item {
@@ -68,6 +89,14 @@ CustomClippingRect {
return h;
}
opacity: root.slim ? 0 : 1
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
CustomRect {
id: title
@@ -85,31 +114,73 @@ CustomClippingRect {
}
}
TextEditBase {
id: titleText
Item {
id: titleWrapper
anchors.left: parent.left
anchors.leftMargin: readOnly ? 0 : Tokens.padding.large
anchors.top: parent.top
anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2
font.pointSize: Tokens.font.size.normal
text: root.modelData.title
readOnly: true
anchors.right: parent.right
implicitHeight: titleText.implicitHeight + Tokens.padding.small * 2
Behavior on anchors.leftMargin {
Anim {}
}
Behavior on anchors.topMargin {
Anim {}
layer.enabled: true
layer.effect: OpacityMask {
maskSource: fadeMask
}
onEditingFinished: root.modelData.title = text
onReadOnlyChanged: {
if (!readOnly) {
this.forceActiveFocus();
cursorPosition = text.length;
} else {
root.forceActiveFocus();
TextFieldBase {
id: titleText
anchors.left: parent.left
anchors.leftMargin: readOnly ? 0 : Tokens.padding.large
anchors.top: parent.top
anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2
font.pointSize: Tokens.font.size.normal
text: root.modelData.title || qsTr("New chat")
readOnly: true
color: Colors.palette.m3onSurface
Behavior on anchors.leftMargin {
Anim {}
}
Behavior on anchors.topMargin {
Anim {}
}
onEditingFinished: root.modelData.title = text
onReadOnlyChanged: {
if (!readOnly) {
this.forceActiveFocus();
cursorPosition = text.length;
} else {
root.forceActiveFocus();
}
}
}
}
CustomRect {
id: fadeMask
// anchors.top: titleText.top
// anchors.bottom: titleText.bottom
// anchors.left: titleText.left
// anchors.right: parent.right
anchors.fill: titleWrapper
layer.enabled: true
visible: false
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
color: Qt.rgba(1, 1, 1, 1.0)
position: 0.85
}
GradientStop {
color: Qt.rgba(1, 1, 1, 0)
position: 1.0
}
}
}
@@ -151,7 +222,7 @@ CustomClippingRect {
id: timestamp
anchors.left: parent.left
anchors.top: titleText.bottom
anchors.top: title.bottom
anchors.topMargin: Tokens.spacing.extraSmall
color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small
@@ -187,8 +258,8 @@ CustomClippingRect {
color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small
maximumLineCount: 1
opacity: !root.expanded ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? ""
opacity: !root.expanded && !root.slim ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet")
Behavior on y {
Anim {}
@@ -213,7 +284,7 @@ CustomClippingRect {
color: Colors.palette.m3onSurfaceVariant
maximumLineCount: 5
opacity: root.expanded ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? ""
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? qsTr("No messages yet")
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Behavior on y {
@@ -286,7 +357,17 @@ CustomClippingRect {
implicitHeight: expandIcon.implicitHeight
implicitWidth: expandIcon.implicitHeight
radius: Tokens.rounding.full
color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 3)
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
opacity: root.slim ? 0 : 1
visible: opacity > 0
enabled: visible
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
StateLayer {
onClicked: root.expanded = !root.expanded
@@ -298,6 +379,7 @@ CustomClippingRect {
anchors.centerIn: parent
anchors.verticalCenterOffset: root.expanded ? -1 : 1
rotation: root.expanded ? 180 : 0
color: Colors.palette.m3onSurface
text: "expand_more"
Behavior on anchors.verticalCenterOffset {
+374 -12
View File
@@ -13,9 +13,12 @@ Item {
property alias list: list
property alias model: list.model
property bool highlight: false
property bool slim: false
signal requestExpand
signal deleteChatRequest(content: ChatSession)
signal loadChatRequest(content: ChatSession)
signal loadChatRequest(content: ChatSession, index: int)
signal newChatRequest
CustomText {
@@ -35,45 +38,404 @@ Item {
CustomListView {
id: list
anchors.bottom: parent.bottom
anchors.bottom: modelsContainer.top
anchors.left: parent.left
anchors.right: parent.right
anchors.top: header.bottom
anchors.topMargin: Tokens.spacing.medium
cacheBuffer: height * 2
clip: true
spacing: Tokens.spacing.medium
add: Transition {
Anim {
from: list.width
property: "x"
}
}
remove: Transition {
Anim {
to: list.width
property: "x"
}
Anim {
to: 0
property: "opacity"
}
}
displaced: Transition {
Anim {
property: "y"
}
}
move: Transition {
Anim {
property: "y"
}
}
delegate: ChatDelegate {
id: chat
implicitWidth: ListView.view.width
required property int index
onClicked: content => root.loadChatRequest(content)
implicitWidth: ListView.view.width
highlighted: root.highlight && ChatState.chatSession === modelData
slim: root.slim
onHighlightedChanged: if (highlighted)
list.currentIndex = index
onClicked: content => root.loadChatRequest(content, index)
onRemove: content => root.deleteChatRequest(content)
}
TapHandler {
onTapped: {
root.focus = true;
ChatState.fabExpanded = false;
modelsContainer.expanded = false;
}
}
}
CustomClippingRect {
id: modelsContainer
property bool expanded: false
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: fabRoot.left
anchors.margins: Tokens.padding.small
anchors.rightMargin: Tokens.spacing.small
implicitHeight: expanded ? 400 : fabRoot.implicitHeight
radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2
function prettyModelName(): string {
if (modelsList.count < 1)
return qsTr("No models found");
const pathList = Chat.model.split("\/");
const name = pathList[pathList.length - 1];
if (!name)
return qsTr("No model selected");
return name;
}
Behavior on implicitHeight {
Anim {}
}
Behavior on radius {
Anim {}
}
Item {
id: modelName
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
implicitHeight: fabRoot.implicitHeight
CustomText {
id: label
text: modelsContainer.prettyModelName()
color: Colors.palette.m3onSurfaceVariant
anchors.left: parent.left
anchors.leftMargin: Tokens.padding.large
anchors.verticalCenter: parent.verticalCenter
}
MaterialIcon {
color: Colors.palette.m3onSurfaceVariant
anchors.right: parent.right
anchors.rightMargin: Tokens.padding.large
anchors.verticalCenter: parent.verticalCenter
text: modelsContainer.expanded ? "unfold_less" : "unfold_more"
animate: true
font.pointSize: Tokens.font.size.large
visible: modelsList.count > 0
}
StateLayer {
id: modelLayer
enabled: modelsList.count > 0
onClicked: {
ChatState.fabExpanded = false;
modelsContainer.expanded = !modelsContainer.expanded;
}
radius: modelsContainer.radius
}
}
VerticalFadeListView {
id: modelsList
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.top: modelName.bottom
anchors.margins: Tokens.padding.medium
anchors.topMargin: Tokens.spacing.medium
model: Chat.availableModels
opacity: modelsContainer.expanded ? 1 : 0
currentIndex: Chat.availableModels.indexOf(Chat.model)
visible: opacity > 0
Behavior on opacity {
Anim {}
}
CustomScrollBar.vertical: CustomScrollBar {
flickable: modelsList
}
delegate: CustomRect {
id: model
required property var modelData
required property int index
readonly property bool selected: model.ListView.view.currentIndex === model.index
implicitWidth: ListView.view.width
implicitHeight: layout.implicitHeight + Tokens.padding.small * 2
radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium
color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0)
Behavior on radius {
Anim {
type: Anim.SlowEffects
}
}
RowLayout {
id: layout
anchors.fill: parent
anchors.margins: Tokens.padding.small
anchors.leftMargin: Tokens.padding.large
anchors.rightMargin: Tokens.padding.large
CustomText {
text: model.modelData
color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
}
MaterialIcon {
Layout.alignment: Qt.AlignRight
visible: model.selected
color: Colors.palette.m3onTertiaryContainer
font.pointSize: Tokens.font.size.normal
opacity: model.selected ? 1 : 0
text: "check"
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
}
}
StateLayer {
id: stateLayer
onClicked: {
Chat.selectModel(model.modelData);
}
}
}
}
}
IconButton {
id: newChatBtn
id: fabRoot
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.small
anchors.right: parent.right
font.pointSize: Math.round(18 * 1.2)
icon: "add"
anchors.margins: Tokens.padding.small
padding: 8
radius: Tokens.rounding.full
font.pointSize: Math.round(18 * 1.2)
icon: root.slim ? "left_panel_open" : "add"
isRound: ChatState.fabExpanded
// opacity: root.slim ? 0 : 1
// visible: opacity > 0
//
// Behavior on opacity {
// Anim {
// type: Anim.DefaultEffects
// }
// }
label.transform: Rotation {
origin.y: fabRoot.label.height / 2
origin.x: fabRoot.label.width / 2
angle: ChatState.fabExpanded ? 135 : 0
Behavior on angle {
Anim {}
}
}
onClicked: {
root.newChatRequest();
if (root.slim && !ChatState.narrowSidebarExpanded) {
root.requestExpand();
return;
}
modelsContainer.expanded = false;
ChatState.fabExpanded = !ChatState.fabExpanded;
}
Elevation {
anchors.fill: parent
level: newChatBtn.stateLayer.containsMouse ? 4 : 3
level: fabRoot.stateLayer.containsMouse ? 4 : 3
radius: parent.radius
z: -1
}
}
ColumnLayout {
id: fabMenu
anchors.bottom: fabRoot.top
anchors.right: fabRoot.right
anchors.bottomMargin: Tokens.padding.medium
Repeater {
id: fabRep
model: ListModel {
ListElement {
name: "new chat"
icon: "add"
}
ListElement {
name: "pop out in window"
icon: "open_in_new"
}
}
CustomClippingRect {
id: fabMenuItem
required property var modelData
required property int index
Layout.alignment: Qt.AlignRight
implicitHeight: fabMenuItemInner.implicitHeight + Tokens.padding.medium * 2
radius: sLayer.pressed ? Tokens.rounding.small : implicitHeight / 2
color: Colors.palette.m3primaryContainer
visible: !(modelData.name === "pop out in window" && ChatState.isWindow)
Behavior on radius {
Anim {
type: Anim.DefaultEffects
}
}
opacity: 0
states: State {
name: "visible"
when: ChatState.fabExpanded
PropertyChanges {
fabMenuItem.implicitWidth: fabMenuItemInner.implicitWidth + Tokens.padding.large * 2
fabMenuItem.opacity: 1
fabMenuItemInner.opacity: 1
}
}
transitions: [
Transition {
to: "visible"
SequentialAnimation {
PauseAnimation {
duration: (fabRep.count - 1 - fabMenuItem.index) * 50
}
ParallelAnimation {
Anim {
property: "implicitWidth"
type: Anim.FastSpatial
}
Anim {
property: "opacity"
duration: Tokens.anim.durations.small
easing: Tokens.anim.standard
}
}
}
},
Transition {
from: "visible"
SequentialAnimation {
PauseAnimation {
duration: fabMenuItem.index * Tokens.anim.durations.small / 8
}
ParallelAnimation {
Anim {
property: "implicitWidth"
type: Anim.FastSpatial
}
Anim {
property: "opacity"
duration: Tokens.anim.durations.small
easing: Tokens.anim.standard
}
}
}
}
]
RowLayout {
id: fabMenuItemInner
anchors.centerIn: parent
spacing: Tokens.spacing.medium
opacity: 0
MaterialIcon {
text: fabMenuItem.modelData.icon
color: Colors.palette.m3onPrimaryContainer
font.pointSize: Tokens.font.size.large
fill: 1
}
CustomText {
animate: true
text: fabMenuItem.modelData.name
font.capitalization: Font.Capitalize
color: Colors.palette.m3onPrimaryContainer
Layout.preferredWidth: implicitWidth
Behavior on Layout.preferredWidth {
Anim {}
}
}
}
StateLayer {
id: sLayer
onClicked: {
const name = fabMenuItem.modelData.name;
if (name === "new chat")
root.newChatRequest();
else if (name === "pop out in window")
Detach.create();
ChatState.fabExpanded = false;
}
}
}
}
}
}
@@ -18,45 +18,19 @@ Item {
Component.onCompleted: {
if (ChatState.inChat) {
stackLoader.item?.push(chatList);
stackLoader.item?.push(chatContent, {
stack.push(chatList);
stack.push(chatContent, {
"chatData": ChatState.chatSession
});
} else
stackLoader.item?.push(chatList);
stack.push(chatList);
}
Loader {
id: stackLoader
active: !ChatState.isWindow || root.width <= (ChatState.screen.width / 4)
StackView {
id: stack
anchors.fill: parent
sourceComponent: StackView {
id: stack
}
}
Loader {
id: sidebarViewLoader
active: ChatState.isWindow && root.width > (ChatState.screen.width / 4)
anchors.fill: parent
sourceComponent: SidebarView {}
}
// IconButton {
// anchors.right: parent.right
// icon: "close"
// visible: !ChatState.isWindow
//
// onClicked: {
// Detach.create();
// Visibilities.getForActive().sidebar = false;
// }
// }
Component {
id: chatList
@@ -68,8 +42,8 @@ Item {
onDeleteChatRequest: chat => {
Chat.chats.remove(chat);
}
onLoadChatRequest: chat => {
stackLoader.item?.push(chatContent, {
onLoadChatRequest: (chat, index) => {
stack.push(chatContent, {
"chatData": chat
});
ChatState.inChat = true;
@@ -77,7 +51,7 @@ Item {
}
onNewChatRequest: {
const data = Chat.chats.insert();
stackLoader.item?.push(chatContent, {
stack.push(chatContent, {
"chatData": data
});
ChatState.inChat = true;
@@ -91,7 +65,7 @@ Item {
ChatContent {
onRequestClose: {
stackLoader.item?.pop();
stack.pop();
ChatState.inChat = false;
}
}
@@ -3,12 +3,25 @@ pragma Singleton
import QtQuick
import Quickshell
import ZShell.Llm
import qs.Helpers
Singleton {
id: root
property ChatSession chatSession
readonly property string title: chatSession ? chatSession.title : "Chat"
property int currentIdx: -1
property bool inChat: false
property bool fabExpanded: false
property bool isWindow: false
property bool isWide: false
property bool settledSlim: false
property bool settledWide: false
property bool narrowSidebarExpanded: false
property bool windowIsWide: false
property ShellScreen screen
property bool sidebarVisible: Visibilities.getForActive().sidebar
onSidebarVisibleChanged: if (!sidebarVisible)
fabExpanded = false
}
@@ -9,7 +9,6 @@ TextEditBase {
color: Colors.palette.m3onSurface
readOnly: true
anchors.margins: Tokens.padding.medium
textFormat: Text.MarkdownText
font.pointSize: Tokens.font.size.smaller
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
@@ -0,0 +1,143 @@
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 noAnim: false
property bool loading: false
property int lastIdx: -1
readonly property Component conversationComp: ChatContent {}
signal requestClose
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;
incubator.object.requestClose.connect(root.requestClose);
currentItem = incubator.object;
root.loading = false;
if (!noAnim)
enterAnim.start();
};
if (incubator.status === Component.Ready)
attach();
else
incubator.onStatusChanged = status => {
if (status === Component.Ready)
attach();
};
}
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();
if (root.noAnim) {
root.loadConversation(ChatState.chatSession);
return;
}
root.animOff = Tokens.padding.small * (ChatState.currentIdx > root.lastIdx ? 1 : -1);
root.lastIdx = ChatState.currentIdx;
exitAnim.start();
}
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
}
}
}
}
@@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Layouts
import ZShell.Llm
import ZShell.Config
import qs.Modules.Notifications.Sidebar.Chat
import qs.Components
import qs.Services
@@ -16,32 +17,60 @@ Item {
required property ChatMessage message
required property ChatGeneration current
// Widest the bubble may grow to; assistant bubbles always use it so
// the markdown blocks have a deterministic width to wrap against.
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
signal edit(text: string)
implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
implicitHeight: bubble.implicitHeight + (actionsRow.shouldBeActive ? actionsRow.implicitHeight + actionsRow.anchors.topMargin : 0)
CustomClippingRect {
id: bubble
property real liveTargetWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
property real frozenWidth: 0.0
property bool layoutFrozen: false
property real layoutWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
radius: Tokens.rounding.medium
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
implicitWidth: layoutFrozen ? frozenWidth : layoutWidth
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2
anchors.right: root.isUser ? parent.right : undefined
layer.enabled: false
layer.smooth: true
// Behavior on implicitHeight {
// enabled: !root.segment.running
//
// Anim {
// type: Anim.DefaultEffects
// }
// }
transform: Scale {
id: stretchScale
origin.x: root.isUser ? bubble.width : 0
xScale: bubble.layoutFrozen && bubble.frozenWidth > 0 ? bubble.liveTargetWidth / bubble.frozenWidth : 1.0
}
states: [
State {
name: "toSlim"
when: ChatState.isWindow && !ChatState.isWide && !ChatState.settledSlim
PropertyChanges {
bubble.layer.enabled: true
bubble.layoutFrozen: true
}
},
State {
name: "toWide"
when: ChatState.isWindow && ChatState.isWide && !ChatState.settledWide
PropertyChanges {
bubble.layer.enabled: true
bubble.layoutFrozen: true
}
}
]
onLayoutFrozenChanged: {
if (layoutFrozen)
frozenWidth = width;
}
// User messages stay a plain editable text field.
TextEditBase {
id: msgText
@@ -5,28 +5,28 @@ import qs.Components
import qs.Services
ColumnLayout {
id: root
id: root
spacing: Tokens.spacing.small
spacing: Tokens.spacing.small
Item {
Layout.fillHeight: true
}
Item {
Layout.fillHeight: true
}
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3outlineVariant
font.pointSize: 36
text: "chat"
}
MaterialIcon {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3outlineVariant
font.pointSize: 36
text: "chat"
}
CustomText {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant
text: qsTr("No messages yet")
}
CustomText {
Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant
text: qsTr("Send a message to get started")
}
Item {
Layout.fillHeight: true
}
Item {
Layout.fillHeight: true
}
}
@@ -11,7 +11,6 @@ Item {
property color mathColor: Colors.palette.m3onSurface
property real fontSize: Tokens.font.size.large
implicitWidth: Math.max(fallbackText.implicitWidth, root.width)
implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2
LlmMathText {
@@ -17,7 +17,8 @@ MouseArea {
required property ChatMessage modelData
property bool reasoningExpanded: false
readonly property var blocks: blockify(current.segments)
property real savedContentY: -1
property real savedOffset: -1
property bool stickActive: false
function handleReasoningToggle(expanded: bool): void {
const view = root.ListView.view;
@@ -25,14 +26,16 @@ MouseArea {
return;
if (expanded) {
root.savedContentY = view.contentY;
root.savedOffset = view.contentY - root.y;
root.reasoningExpanded = true;
} else {
root.reasoningExpanded = false;
if (root.savedContentY !== -1) {
restoreAnim.to = root.savedContentY;
root.stickActive = false;
settleTimer.stop();
if (!isNaN(root.savedOffset)) {
restoreAnim.to = root.y + root.savedOffset;
restoreAnim.restart();
root.savedContentY = -1;
root.savedOffset = NaN;
}
}
}
@@ -69,6 +72,30 @@ MouseArea {
preventStealing: false
implicitHeight: layout.implicitHeight + Tokens.padding.medium
Timer {
id: settleTimer
interval: 16
onTriggered: {
root.stickActive = false;
}
}
onImplicitHeightChanged: {
if (root.reasoningExpanded) {
root.stickActive = true;
settleTimer.restart();
const view = root.ListView.view;
if (!view)
return;
const bottomEdge = root.y + root.height + Tokens.padding.large * 2;
const viewportBottom = view.contentY + view.height;
if (bottomEdge > viewportBottom)
view.contentY = bottomEdge - view.height;
}
}
Behavior on current {
id: changeAnim
@@ -119,14 +146,6 @@ MouseArea {
}
}
Binding {
property: "contentY"
restoreMode: Binding.RestoreNone
target: root.ListView.view
value: root.y + root.height + Tokens.padding.large * 2 - root.ListView.view.height
when: root.reasoningExpanded && root.ListView.view && (root.y + root.height + Tokens.padding.large * 2 > root.ListView.view.contentY + root.ListView.view.height)
}
Anim {
id: restoreAnim
@@ -141,6 +160,7 @@ MouseArea {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
spacing: Tokens.spacing.medium
Repeater {
id: segmentRep
@@ -158,6 +178,7 @@ MouseArea {
delegate: ProcessBlock {
width: root.width
blocks: root.blocks
current: root.current
onExpandedChanged: root.handleReasoningToggle(expanded)
}
@@ -1,23 +1,39 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Blobs
import ZShell.Config
import ZShell.Components
import ZShell.Llm
import qs.Components
import qs.Services
CustomClippingRect {
Item {
id: root
required property int index
required property var modelData
required property var blocks
readonly property var segments: modelData.segments
required property ChatGeneration current
readonly property bool isActive: index === blocks.length - 1
property bool expanded: false
readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1]
readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).every(s => current.pendingToolCalls.includes(s))
implicitHeight: expanded ? expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin * 2 : layout.implicitHeight + Tokens.spacing.small
clip: true
implicitHeight: {
const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin;
const collapsed = layout.implicitHeight;
const approval = layout.implicitHeight + approvalPrompt.implicitHeight + approvalPrompt.anchors.topMargin;
if (expanded)
return expand;
else if (pendingApproval)
return approval;
else
return collapsed;
}
Behavior on implicitHeight {
Anim {
@@ -57,6 +73,7 @@ CustomClippingRect {
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
anchors.centerIn: parent
opacity: root.isActive ? 0 : 1
enabled: opacity > 0
rotation: root.expanded ? 180 : 0
type: IconButton.Text
@@ -77,6 +94,9 @@ CustomClippingRect {
Layout.leftMargin: Tokens.spacing.medium
text: {
if (root.isActive) {
if (root.pendingApproval)
return qsTr("Waiting for approval...");
if (root.lastSegment.type === LlmSegment.Type.ToolCall)
return qsTr("Using %1...").arg(root.lastSegment.name);
return qsTr("Thinking...");
@@ -90,6 +110,163 @@ CustomClippingRect {
}
}
Item {
id: approvalPrompt
anchors.top: layout.bottom
anchors.topMargin: Tokens.spacing.small
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2
visible: root.pendingApproval
CustomRect {
id: approvalBg
anchors.fill: parent
radius: Tokens.rounding.medium
color: Colors.palette.m3surface
border.width: 1
border.color: Colors.palette.m3outlineVariant
ColumnLayout {
id: approvalLayout
anchors.fill: parent
anchors.margins: Tokens.padding.medium
spacing: Tokens.spacing.small
CustomText {
id: requestHeader
text: qsTr("Tool call request")
font.pointSize: Tokens.font.size.large
color: Colors.palette.m3onSurface
}
Item {
id: toolsBg
property bool open: false
implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2
Layout.fillWidth: true
BlobGroup {
id: blobGroup
color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer
Behavior on color {
CAnim {}
}
}
Item {
id: toolWrapper
width: toolsBg.width
height: toolList.implicitHeight + toolList.anchors.margins * 2
BlobRect {
id: dialogBg
anchors.fill: parent
bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
deformScale: 0
group: blobGroup
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
}
CustomRect {
id: toolBox
anchors.fill: parent
anchors.margins: Tokens.padding.medium
ColumnLayout {
id: toolList
Repeater {
model: root.current.pendingToolCalls ?? []
delegate: RowLayout {
id: toolText
required property LlmSegment modelData
required property int index
spacing: Tokens.spacing.medium
function toolTarget(seg: LlmSegment): string {
var a = null;
try {
a = JSON.parse(seg.arguments);
} catch (e) {}
if (a && typeof a === "object") {
const v = a.url ?? a.path;
if (v !== undefined && v !== null)
return String(v);
}
return String(seg.arguments);
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: String(toolText.index + 1)
font.pointSize: Tokens.font.size.large
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("%1").arg(toolText.modelData.name)
}
MaterialIcon {
color: Colors.palette.m3onPrimaryContainer
text: "arrow_right_alt"
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("%1").arg(toolText.toolTarget(toolText.modelData))
}
}
}
}
}
}
}
ButtonRow {
Layout.alignment: Qt.AlignRight
spacing: Tokens.spacing.medium
IconTextButton {
icon: "check_circle"
text: qsTr("Allow")
horizontalPadding: Tokens.padding.large
verticalPadding: Tokens.padding.small
isRound: true
shapeMorph: true
onClicked: root.current.approveTools()
}
IconTextButton {
icon: "cancel"
text: qsTr("Deny")
isRound: true
shapeMorph: true
inactiveColor: Colors.palette.m3error
inactiveOnColor: Colors.palette.m3onError
horizontalPadding: Tokens.padding.large
verticalPadding: Tokens.padding.small
onClicked: root.current.denyTools()
}
}
}
}
}
CustomRect {
id: expandedRect
@@ -98,7 +275,6 @@ CustomClippingRect {
anchors.top: layout.bottom
anchors.bottom: parent.bottom
anchors.topMargin: Tokens.spacing.small
anchors.bottomMargin: Tokens.spacing.small
color: Colors.palette.m3surfaceContainerLow
implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2
opacity: root.expanded ? 1 : 0
@@ -147,6 +323,49 @@ CustomClippingRect {
CustomText {
id: content
function toolTarget(seg: LlmSegment): string {
var a = null;
try {
a = JSON.parse(seg.arguments);
} catch (e) {}
if (a && typeof a === "object") {
const v = a.url ?? a.path;
if (v !== undefined && v !== null)
return String(v);
}
return String(seg.arguments);
}
function prettyTool(seg: LlmSegment): string {
if (seg.name === "webfetch") {
switch (seg.status) {
case LlmSegment.Status.Running:
return qsTr("Fetching");
case LlmSegment.Status.Pending:
return qsTr("Pending fetch");
case LlmSegment.Status.Success:
return qsTr("Fetched");
case LlmSegment.Status.Error:
return qsTr("Failed to fetch");
default:
return qsTr("Fetched website");
}
} else if (seg.name === "readfile") {
switch (seg.status) {
case LlmSegment.Status.Running:
return qsTr("Reading");
case LlmSegment.Status.Pending:
return qsTr("Pending read");
case LlmSegment.Status.Success:
return qsTr("Read");
case LlmSegment.Status.Error:
return qsTr("Failed to read");
default:
return qsTr("Read file");
}
}
}
anchors.left: parent.left
anchors.leftMargin: Tokens.padding.small
anchors.top: header.bottom
@@ -155,7 +374,7 @@ CustomClippingRect {
color: Colors.palette.m3outline
font.pointSize: Tokens.font.size.small
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("Fetched %1").arg(JSON.parse(segment.modelData.arguments)?.url ?? "website")
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("%1 %2").arg(content.prettyTool(segment.modelData)).arg(content.toolTarget(segment.modelData))
}
}
}
@@ -3,6 +3,7 @@ pragma Singleton
import Quickshell
import QtQuick
import ZShell.Config
import qs.Helpers
import qs.Services
Singleton {
@@ -10,6 +11,7 @@ Singleton {
function create(parent: Item, props: var): void {
chatComp.createObject(parent ?? dummy, props);
Visibilities.getForActive().sidebar = false;
ChatState.isWindow = true;
}
@@ -31,7 +33,7 @@ Singleton {
minimumSize.height: Config.sidebar.sizes.width
minimumSize.width: Config.sidebar.sizes.width
surfaceFormat.opaque: false
title: qsTr("ZShell - Chat")
title: qsTr("ZShell - %1").arg(ChatState.title)
Component.onCompleted: ChatState.screen = screen
onVisibleChanged: {
@@ -41,7 +43,7 @@ Singleton {
}
}
ChatPanel {
SidebarView {
id: chat
anchors.fill: parent
@@ -2,90 +2,238 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import ZShell.Blobs
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Modules.Notifications.Sidebar.Chat.Content
import qs.Services
Item {
id: root
property int breakpoint: 700
property int breakpoint: Math.round(Config.sidebar.sizes.width * 2) + Tokens.spacing.medium * 2 + Tokens.padding.medium * 3
property color blobColor: Colors.tPalette.m3surfaceContainerLow
readonly property int iconSize: Math.round(Tokens.font.size.extraLarge * (96 / 72)) + Tokens.padding.large * 2
property alias conversationModel: sidebar.model
property var currentConversation: null // whatever model item is "open"
property bool chatOpen: ChatState.chatSession !== null
readonly property bool isWide: root.width >= root.breakpoint
property bool narrowShowsSidebar: true
function closeConversation() {
narrowShowsSidebar = true;
readonly property bool showList: isWide || !chatOpen
readonly property bool showContent: !isWide && chatOpen
function openConversation(conv: ChatSession, index: int): void {
}
function openConversation(conv) {
currentConversation = conv;
narrowShowsSidebar = false; // in narrow mode, jump to the chat
BlobGroup {
id: blobGroup
color: root.blobColor
smoothing: Tokens.rounding.medium
}
BlobInvertedRect {
id: invertedRect
anchors.fill: parent
borderBottom: Tokens.padding.small
borderLeft: sidebar.implicitWidth + sidebar.anchors.margins + Tokens.spacing.medium
borderRight: Tokens.padding.small
borderTop: Tokens.padding.small
group: blobGroup
opacity: root.blobColor.a
radius: Tokens.rounding.largeIncreased
}
ChatList {
id: sidebar
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: root.isWide ? undefined : parent.right
anchors.top: parent.top
implicitWidth: 20
list.currentIndex: -1
list.highlightFollowsCurrentItem: false
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
implicitWidth: root.isWide || ChatState.narrowSidebarExpanded ? Config.sidebar.sizes.width : root.iconSize
highlight: true
slim: !root.isWide && !ChatState.narrowSidebarExpanded
z: 1
Behavior on anchors.right {
AnchorAnim {}
}
Behavior on implicitWidth {
Anim {}
}
list.highlight: CustomRect {
color: Colors.palette.m3primary
implicitHeight: sidebar.list.currentItem?.implicitHeight ?? 0
implicitWidth: sidebar.list.width
radius: Tokens.rounding.medium
x: sidebar.list.currentItem?.chat.x ?? 0
y: sidebar.list.currentItem?.y ?? 0
Behavior on y {
Anim {
duration: Tokens.anim.durations.small
easing: Tokens.anim.expressiveDefaultEffects
}
Anim {
type: Anim.DefaultEffects
}
}
model: ScriptModel {
values: Chat.chats.values
}
anchors.onRightChanged: {
if (anchors.right === undefined)
implicitWidth = 20;
}
onLoadChatRequest: (chat, index) => {
sidebar.list.currentIndex = index;
root.openConversation(chat);
ChatState.currentIdx = index;
ChatState.chatSession = chat;
}
onDeleteChatRequest: chat => {
if (chat === ChatState.chatSession) {
ChatState.chatSession = null;
ChatState.currentIdx = -1;
}
Chat.chats.remove(chat);
}
onNewChatRequest: {
const data = Chat.chats.insert();
ChatState.currentIdx = 0;
ChatState.chatSession = data;
}
onRequestExpand: {
ChatState.narrowSidebarExpanded = true;
}
}
CustomClippingWrapperRect {
anchors.bottom: parent.bottom
anchors.left: sidebar.right
anchors.right: parent.right
anchors.top: parent.top
child: conversationView
CustomClippingRect {
anchors.fill: parent
anchors.leftMargin: invertedRect.borderLeft
anchors.topMargin: invertedRect.borderTop
anchors.bottomMargin: invertedRect.borderBottom
anchors.rightMargin: invertedRect.borderRight
radius: invertedRect.radius
children: [contentArea, dim]
}
ChatContent {
CustomRect {
id: dim
anchors.fill: parent
color: Colors.palette.m3shadow
opacity: ChatState.narrowSidebarExpanded && !root.isWide ? 0.3 : 0
z: 1
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
Item {
id: contentArea
property int leftMargin: sidebar.implicitWidth
z: 0
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 - anchors.margins * 2
states: State {
name: "wide"
when: !root.isWide
PropertyChanges {
contentArea.leftMargin: root.iconSize
}
}
transitions: Transition {
to: "wide"
Anim {
type: Anim.DefaultEffects
property: "leftMargin"
}
}
ChatHost {
id: convHost
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
implicitWidth: Math.max(Math.min(parent.width, 800), Config.sidebar.sizes.width)
clip: true
onLoadingChanged: if (!loading)
root.chatOpen = true
}
ColumnLayout {
id: noChatLayout
anchors.centerIn: parent
opacity: ChatState.chatSession ? 0 : 1
visible: opacity > 0
Behavior on opacity {
Anim {}
}
MaterialIcon {
Layout.alignment: Qt.AlignCenter
text: "chat_bubble_off"
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
}
}
MouseArea {
id: dimArea
anchors.fill: parent
preventStealing: true
enabled: !root.isWide && ChatState.narrowSidebarExpanded
cursorShape: undefined
onClicked: {
const x = mouseX;
const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2;
if (x > invertedRect.borderLeft - margin)
ChatState.narrowSidebarExpanded = false;
}
HoverHandler {
cursorShape: dimArea.enabled ? Qt.ArrowCursor : undefined
enabled: dimArea.enabled
}
}
}
Binding {
target: ChatState
property: "windowIsWide"
value: root.showList
}
Binding {
target: ChatState
property: "isWide"
value: root.isWide
}
Binding {
target: ChatState
value: contentArea.leftMargin === root.iconSize
property: "settledSlim"
}
Binding {
target: ChatState
value: contentArea.leftMargin === sidebar.implicitWidth
property: "settledWide"
}
Component {
id: conversationView
anchors.fill: root
anchors.leftMargin: root.isWide ? Config.sidebar.sizes.width / 2 : 0
chatData: root.currentConversation
ChatContent {}
}
}
+27 -25
View File
@@ -1,7 +1,6 @@
pragma ComponentBehavior: Bound
import ZShell.Config
import ZShell.Llm
import QtQuick
import QtQuick.Layouts
import qs.Components
@@ -31,7 +30,7 @@ Item {
Tabs {
Layout.fillWidth: true
Layout.preferredHeight: ChatState.isWindow ? 0 : implicitHeight
Layout.preferredHeight: ChatState.isWindow || !Config.llm.enabled ? 0 : implicitHeight
dashState: root.props
nonAnimWidth: layout.width
visible: height > 0
@@ -53,12 +52,10 @@ Item {
x: root.props.currentTab === 0 ? 0 : -root.width
Behavior on opacity {
Anim {
}
Anim {}
}
Behavior on x {
Anim {
}
Anim {}
}
CustomRect {
@@ -73,33 +70,38 @@ Item {
}
}
Item {
id: chatPage
Loader {
id: chatLoader
anchors.bottom: parent.bottom
anchors.top: parent.top
implicitWidth: parent.width
opacity: root.props.currentTab === 1 ? 1 : 0
visible: opacity > 0
x: root.props.currentTab === 0 ? root.width : 0
z: 1
active: Config.llm.enabled
width: parent.width
Behavior on opacity {
Anim {
sourceComponent: Item {
id: chatPage
opacity: root.props.currentTab === 1 ? 1 : 0
visible: opacity > 0
x: root.props.currentTab === 0 ? root.width : 0
z: 1
Behavior on opacity {
Anim {}
}
}
Behavior on x {
Anim {
Behavior on x {
Anim {}
}
}
CustomRect {
anchors.fill: parent
color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small
ChatPanel {
CustomRect {
anchors.fill: parent
color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small
ChatPanel {
anchors.fill: parent
}
}
}
}
@@ -110,7 +110,10 @@ ColumnLayout {
onClicked: {
root.visibilities.sidebar = false;
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]);
if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]);
else
Quickshell.execDetached([...Config.general.apps.playback, recording.modelData.path]);
}
}
@@ -120,7 +123,10 @@ ColumnLayout {
onClicked: {
root.visibilities.sidebar = false;
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]);
if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]);
else
Quickshell.execDetached([...Config.general.apps.explorer, recording.modelData.path]);
}
}
}
@@ -135,8 +141,7 @@ ColumnLayout {
}
}
Behavior on implicitHeight {
Anim {
}
Anim {}
}
model: FileSystemModel {
nameFilters: ["recording_*.mp4"]
@@ -162,8 +167,7 @@ ColumnLayout {
opacity: list.count === 0 ? 1 : 0
Behavior on opacity {
Anim {
}
Anim {}
}
sourceComponent: ColumnLayout {
spacing: Tokens.spacing.small
@@ -178,16 +182,13 @@ ColumnLayout {
text: "scan_delete"
Behavior on Layout.preferredHeight {
Anim {
}
Anim {}
}
Behavior on opacity {
Anim {
}
Anim {}
}
Behavior on scale {
Anim {
}
Anim {}
}
}
@@ -203,16 +204,13 @@ ColumnLayout {
text: "scan_delete"
Behavior on Layout.preferredWidth {
Anim {
}
Anim {}
}
Behavior on opacity {
Anim {
}
Anim {}
}
Behavior on scale {
Anim {
}
Anim {}
}
}
+11 -3
View File
@@ -15,14 +15,17 @@ Item {
required property Component content
required property string header
property int horizontalContentMargin
required property string icon
property string icon
required property string label
property alias iconLabel: openButton.iconLabel
property alias rowButton: openButton
property string subtext: ""
property bool open
property real openHeight: Math.min(rootParent.height * 0.8, 600)
property real openWidth: Math.min(rootParent.width * 0.8, 400)
required property Item rootParent
property bool separateContent
required property string settingAnchor
property bool first: false
property bool last: false
@@ -57,6 +60,7 @@ Item {
anchors.fill: parent
enabled: false
hoverEnabled: enabled
preventStealing: true
parent: root.open ? root.rootParent : root
onClicked: root.open = false
@@ -77,7 +81,8 @@ Item {
backdrop.enabled: true
dialogBg.bottomLeftRadius: 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
dialogWrapper.height: root.openHeight
dialogWrapper.width: root.openWidth
@@ -105,7 +110,7 @@ Item {
}
Anim {
properties: "opacity,radius,bottomLeftRadius,bottomRightRadius"
properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
type: Anim.DefaultEffects
}
@@ -172,6 +177,7 @@ Item {
sourceComponent: MouseArea {
onWheel: event => event.accepted = true
preventStealing: true
ColumnLayout {
anchors.bottomMargin: Tokens.padding.largeIncreased
@@ -224,6 +230,7 @@ Item {
text: qsTr("Cancel")
type: TextButton.Text
verticalPadding: Tokens.padding.small
stateLayer.preventStealing: true
onClicked: {
root.cancelled();
@@ -237,6 +244,7 @@ Item {
isRound: true
text: root.acceptLabel
type: TextButton.Text
stateLayer.preventStealing: true
verticalPadding: Tokens.padding.small
onClicked: {
@@ -93,10 +93,13 @@ DialogRowButton {
onOpenChanged: {
if (open) {
root.rootParent.interactive = false;
if (!initialSelect)
selectedItem = null;
else
selectedItem = initialSelect;
} else {
root.rootParent.interactive = true;
}
}
}
+16 -7
View File
@@ -30,7 +30,7 @@ ColumnLayout {
function findAnchor(item: Item, anchor: string): Item {
if (!item)
return null;
if (item.settingAnchor !== undefined && item.settingAnchor === anchor)
if (item.settingAnchor !== undefined && item.settingAnchor === anchor) // qmllint disable missing-property
return item;
const kids = item.children;
for (let i = 0; i < kids.length; i++) {
@@ -43,8 +43,8 @@ ColumnLayout {
function highlightAnchor(anchor: string): void {
const row = findAnchor(contentChild, anchor);
if (row && row.flashHighlight !== undefined)
row.flashHighlight();
if (row && row.flashHighlight !== undefined) // qmllint disable missing-property
row.flashHighlight(); // qmllint disable missing-property
}
function scrollToAnchor(anchor: string): bool {
@@ -61,8 +61,8 @@ ColumnLayout {
root.animateScroll = true;
flickable.contentY = target;
Qt.callLater(() => root.animateScroll = false);
if (row.flashHighlight !== undefined)
row.flashHighlight();
if (row.flashHighlight !== undefined) // qmllint disable missing-property
row.flashHighlight(); // qmllint disable missing-property
return true;
}
@@ -154,11 +154,20 @@ ColumnLayout {
Layout.fillHeight: true
Layout.fillWidth: true
Layout.topMargin: -topMargin
bottomMargin: Tokens.padding.extraLarge
topMargin: Tokens.padding.large
fadeAmount: 0.1
contentHeight: root.contentChild?.implicitHeight ?? 0
contentItem.children: [root.contentChild]
fadeAmount: 0.1
topMargin: Tokens.padding.large
rebound: Transition {
Anim {
properties: "x,y"
type: Anim.DefaultEffects
}
}
Behavior on contentY {
enabled: root.animateScroll
+24 -2
View File
@@ -17,6 +17,7 @@ ConnectedRect {
property alias subtext: subLabel.text
property alias text: label.text
property string trailingIcon
property string activeItem
signal clicked(event: MouseEvent)
@@ -40,15 +41,16 @@ ConnectedRect {
spacing: Tokens.spacing.medium
Behavior on opacity {
Anim {
}
Anim {}
}
MaterialIcon {
id: iconLabel
color: Colors.palette.m3onSurfaceVariant
visible: root.icon !== ""
fill: 1
text: root.icon
font.pointSize: Tokens.font.size.normal
}
@@ -79,6 +81,26 @@ ConnectedRect {
}
}
Loader {
active: root.activeItem && root.subtext
asynchronous: true
visible: active
Layout.fillHeight: true
sourceComponent: CustomRect {
color: Colors.palette.m3secondaryContainer
radius: Tokens.rounding.full
implicitWidth: text.implicitWidth + Tokens.padding.medium * 2
CustomText {
id: text
anchors.centerIn: parent
color: Colors.palette.m3onSecondaryContainer
text: root.activeItem
}
}
}
Loader {
active: root.trailingIcon
asynchronous: true
+128
View File
@@ -0,0 +1,128 @@
pragma ComponentBehavior: Bound
import Quickshell
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import qs.Components
import qs.Services
DialogRowButton {
id: root
required property var model
property var selectedItem
property var initialSelect
acceptAllowed: !!selectedItem
horizontalContentMargin: -Tokens.padding.extraSmall
separateContent: true
content: Component {
ColumnLayout {
VerticalFadeListView {
bottomMargin: Tokens.padding.large
Layout.fillHeight: true
Layout.fillWidth: true
model: ScriptModel {
values: {
const s = searchField.text;
var regex = new RegExp(s, "i");
return root.model.filter(n => regex.test(n));
}
}
spacing: 0
topMargin: Tokens.padding.large
delegate: CustomRect {
id: item
required property var modelData
readonly property bool selected: root.selectedItem === modelData
anchors.left: ListView.view.contentItem.left
anchors.margins: 1 // Gets cut off for some reason without this
anchors.right: ListView.view.contentItem.right
color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0)
implicitHeight: label.implicitHeight + Tokens.padding.small * 2
radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium
Behavior on radius {
Anim {
type: Anim.SlowEffects
}
}
StateLayer {
id: stateLayer
onClicked: root.selectedItem = item.modelData
}
CustomText {
id: label
anchors.left: parent.left
anchors.margins: Tokens.padding.large
anchors.right: item.selected ? checkIcon.left : parent.right
anchors.rightMargin: item.selected ? Tokens.spacing.medium : anchors.margins
anchors.verticalCenter: parent.verticalCenter
color: item.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
elide: Text.ElideRight
font.pointSize: Tokens.font.size.smaller
text: item.modelData
}
MaterialIcon {
id: checkIcon
anchors.margins: Tokens.padding.large
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
color: Colors.palette.m3onTertiaryContainer
font.pointSize: Tokens.font.size.normal
opacity: item.selected ? 1 : 0
text: "check"
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
}
}
}
SearchBar {
id: searchField
Layout.fillWidth: true
bg.border.color: Colors.palette.m3outlineVariant
bg.color: Colors.tPalette.m3surfaceContainerLowest
clearIcon.font.pointSize: Tokens.font.size.large
clearIcon.padding: Tokens.padding.extraSmall
font.pointSize: Tokens.font.size.small
placeholderText: qsTr("Search...")
searchIcon.anchors.leftMargin: Tokens.padding.large
searchIcon.font.pointSize: Tokens.font.size.large
Behavior on bg.border.color {
CAnim {}
}
}
}
}
onOpenChanged: {
if (open) {
root.rootParent.interactive = false;
if (!initialSelect)
selectedItem = null;
else
selectedItem = initialSelect;
} else {
root.rootParent.interactive = true;
}
}
}
+246
View File
@@ -0,0 +1,246 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import ZShell.Blobs
import qs.Components
import ZShell.Config
import qs.Services
Item {
id: root
property bool acceptAllowed: true
required property string acceptLabel
required property Component content
required property string header
property int horizontalContentMargin
property string icon
required property string label
property alias iconLabel: openButton.iconText
property alias rowButton: openButton
property alias contentItem: dialogContent.item
property string subtext: ""
property bool open
property real openHeight: Math.min(rootParent.height * 0.8, 400)
property real openWidth: Math.min(rootParent.width * 0.8, 600)
required property Item rootParent
property bool first: false
property bool last: false
required property string settingAnchor
property color openColor: Colors.palette.m3surfaceContainerHighest
signal accepted
signal cancelled
function reparentWrapper(): void {
const newParent = open ? rootParent : root;
const pos = dialogWrapper.mapToItem(newParent, 0, 0);
dialogWrapper.parent = newParent;
dialogWrapper.x = pos.x;
dialogWrapper.y = pos.y;
}
Layout.fillWidth: true
implicitHeight: openButton.implicitHeight
z: open || dialogTransition.running ? 2 : 0
BlobGroup {
id: blobGroup
color: root.open ? root.openColor : Colors.tPalette.m3surfaceContainer
Behavior on color {
CAnim {}
}
}
MouseArea {
id: backdrop
anchors.fill: parent
enabled: false
hoverEnabled: enabled
preventStealing: true
parent: root.open ? root.rootParent : root
onClicked: root.open = false
}
Item {
id: dialogWrapper
height: openButton.implicitHeight
width: root.width
z: 1
states: State {
name: "open"
when: root.open
PropertyChanges {
backdrop.enabled: true
dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased
dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased
dialogBg.topRightRadius: Tokens.rounding.largeIncreased
dialogBg.topLeftRadius: Tokens.rounding.largeIncreased
dialogContent.opacity: 1
dialogWrapper.height: root.openHeight
dialogWrapper.width: root.openWidth
dialogWrapper.x: (root.rootParent.width - root.openWidth) / 2
dialogWrapper.y: (root.rootParent.height - root.openHeight) / 2
elevation.opacity: 1
openButton.opacity: 0
}
}
transitions: Transition {
id: dialogTransition
SequentialAnimation {
ScriptAction {
script: root.reparentWrapper()
}
Anim {
properties: "x,y"
}
}
PropertyAction {
property: "enabled"
}
Anim {
properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
type: Anim.DefaultEffects
}
Anim {
properties: "width,height"
}
}
Elevation {
id: elevation
anchors.fill: parent
bottomLeftRadius: dialogBg.bottomLeftRadius
bottomRightRadius: dialogBg.bottomRightRadius
level: 4
opacity: 0
radius: dialogBg.radius
transform: Matrix4x4 {
matrix: dialogBg.deformMatrix
}
}
BlobRect {
id: dialogBg
anchors.fill: parent
bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
bottomRightRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
deformScale: 0
group: blobGroup
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
}
TimeDialogToggle {
id: openButton
anchors.left: parent.left
anchors.right: parent.right
color: "transparent"
height: Math.min(implicitHeight, parent.height)
subtext: root.subtext
last: root.last
first: root.first
text: root.label
transform: Matrix4x4 {
matrix: dialogBg.deformMatrix
}
onClicked: root.open = true
}
Loader {
id: dialogContent
active: opacity > 0
anchors.fill: parent
asynchronous: false
opacity: 0
sourceComponent: MouseArea {
onWheel: event => event.accepted = true
preventStealing: true
clip: true
implicitWidth: innerLayout.implicitWidth + innerLayout.anchors.margins * 2
implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins + innerLayout.anchors.bottomMargin
ColumnLayout {
id: innerLayout
anchors.bottomMargin: Tokens.padding.largeIncreased
anchors.fill: parent
anchors.margins: Tokens.padding.extraLarge
spacing: 0
CustomText {
font.pointSize: Tokens.font.size.large
text: root.header
}
Loader {
Layout.fillWidth: true
Layout.leftMargin: root.horizontalContentMargin
Layout.topMargin: Tokens.spacing.medium
Layout.bottomMargin: Tokens.spacing.medium
Layout.rightMargin: root.horizontalContentMargin
sourceComponent: root.content
}
RowLayout {
Layout.alignment: Qt.AlignRight
spacing: Tokens.spacing.extraSmall
TextButton {
horizontalPadding: Tokens.padding.largeIncreased
isRound: true
text: qsTr("Cancel")
type: TextButton.Text
verticalPadding: Tokens.padding.small
stateLayer.preventStealing: true
onClicked: {
root.cancelled();
root.open = false;
}
}
TextButton {
enabled: root.acceptAllowed
horizontalPadding: Tokens.padding.largeIncreased
isRound: true
text: root.acceptLabel
type: TextButton.Text
stateLayer.preventStealing: true
verticalPadding: Tokens.padding.small
onClicked: {
root.accepted();
root.open = false;
}
}
}
}
}
transform: Matrix4x4 {
matrix: dialogBg.deformMatrix
}
}
}
}
@@ -0,0 +1,50 @@
pragma ComponentBehavior: Bound
import QtQuick
import ZShell.Config
import qs.Helpers
import qs.Components
import qs.Services
TimeDialogRow {
id: root
required property int start
required property int end
property int endCurrent
property int startCurrent
openColor: Colors.palette.m3surfaceContainer
openHeight: Math.min(rootParent.height * 0.8, contentItem?.implicitHeight ?? 0)
openWidth: Math.min(rootParent.width * 0.8, contentItem?.implicitWidth ?? 0)
function keyFor(item: var): string {
return item.id;
}
function labelFor(item: var): string {
return item.label;
}
horizontalContentMargin: -Tokens.padding.extraSmall
content: Component {
TimeInput {
id: timeContent
start: root.start
end: root.end
onEndEdit: value => root.endCurrent = value
onStartEdit: value => root.startCurrent = value
}
}
onOpenChanged: {
if (open) {
root.rootParent.interactive = false;
root.z = 5;
} else {
root.rootParent.interactive = true;
root.z = 1;
}
}
}
@@ -10,12 +10,14 @@ ConnectedRect {
property alias checked: switchButton.checked
property int horizontalPadding: Tokens.padding.largeIncreased
required property Component popup
property alias subtext: subtext.text
property alias text: text.text
property alias icon: icon
property string iconText: "open_in_new"
property int verticalPadding: Tokens.padding.small
signal clicked(checked: bool)
signal clicked(event: MouseEvent)
signal toggled(checked: bool)
Layout.fillWidth: true
implicitHeight: layout.implicitHeight + verticalPadding * 2
@@ -50,11 +52,11 @@ ConnectedRect {
anchors.right: switchButton.left
anchors.rightMargin: Tokens.spacing.medium
anchors.verticalCenter: parent.verticalCenter
text: "open_in_new"
text: root.iconText
}
StateLayer {
onClicked: PopupManager.requestOpen(root.popup)
onClicked: e => root.clicked(e)
}
CustomSwitch {
@@ -64,6 +66,6 @@ ConnectedRect {
anchors.rightMargin: root.horizontalPadding
anchors.verticalCenter: parent.verticalCenter
onClicked: root.clicked(checked)
onClicked: root.toggled(checked)
}
}
+421 -519
View File
@@ -6,15 +6,13 @@ import qs.Components
import qs.Helpers
import qs.Services
CustomClippingRect {
ColumnLayout {
id: root
required property var object
required property list<string> settings
property bool shouldBeActive: true
signal applySettings(startTime: int, endTime: int)
signal close
required property int start
required property int end
signal endEdit(time: int)
signal startEdit(time: int)
function convertHour(timeValue: int): int {
return Math.floor(timeValue / 60);
@@ -28,555 +26,459 @@ CustomClippingRect {
return hour * 60 + minute;
}
color: Colors.palette.m3surfaceContainer
implicitHeight: column.implicitHeight + column.anchors.margins * 2 + buttonRow.implicitHeight + buttonRow.anchors.topMargin
implicitWidth: column.implicitWidth + column.anchors.margins * 2
radius: Tokens.rounding.largeIncreased
spacing: Tokens.spacing.medium
ColumnLayout {
id: column
RowLayout {
CustomRect {
id: startHourRect
anchors.left: parent.left
anchors.margins: Tokens.padding.largeIncreased
anchors.right: parent.right
anchors.top: parent.top
spacing: Tokens.spacing.medium
Layout.preferredHeight: 72
Layout.preferredWidth: 96
color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
implicitHeight: 72
implicitWidth: 96
radius: Tokens.rounding.small
CustomRect {
anchors.fill: parent
border.color: startHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: startHourField.focus ? 2 : 0
radius: parent.radius - border.width
Behavior on border.width {
Anim {}
}
}
TextFieldBase {
id: startHourField
function setConfigText(): string {
var val = root.convertHour(root.start);
if (val === 0) {
return "00";
}
return String(val);
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText()
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
if (startHourField.text.length >= 2) {
startHourField.text = "0" + startHourField.text[0];
} else if (startHourField.text.length === 1) {
startHourField.text = "0";
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
startHourField.text = setConfigText();
startHourField.focus = false;
} else if (event.key === Qt.Key_Return) {
startHourField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
startMinuteField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
endMinuteField.focus = true;
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
var digit = event.text;
var textLen = startHourField.text.length;
if (textLen >= 2 && startHourField.text[0] !== '0') {
return;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(startHourField.text + digit);
} else {
val = parseInt(startHourField.text[1] + digit);
}
val = Math.max(0, Math.min(23, val));
if (textLen >= 2 && val < 10) {
startHourField.text = "0" + val;
} else {
startHourField.text = val.toString();
}
}
event.accepted = true;
}
onCursorPositionChanged: cursorPosition = 2
onTextChanged: {
var mins = parseInt(startMinuteField.text);
const hours = root.convertToMinutes(parseInt(startHourField.text));
root.startEdit(hours + mins);
}
}
}
CustomText {
text: qsTr("Select time")
id: startSeparator
font.pointSize: Tokens.font.size.extraLarge
text: ":"
}
RowLayout {
CustomRect {
id: startHourRect
CustomRect {
id: startMinuteRect
Layout.preferredHeight: 72
Layout.preferredWidth: 96
color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
implicitHeight: 72
implicitWidth: 96
radius: Tokens.rounding.small
CustomRect {
anchors.fill: parent
border.color: startHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: startHourField.focus ? 2 : 0
radius: parent.radius - border.width
Behavior on border.width {
Anim {
}
}
}
TextFieldBase {
id: startHourField
function setConfigText(setting: string): string {
var val = root.convertHour(root.object[setting]);
if (val === 0) {
return "00";
}
return String(val);
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText(root.settings[1])
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
if (startHourField.text.length >= 2) {
startHourField.text = "0" + startHourField.text[0];
} else if (startHourField.text.length === 1) {
startHourField.text = "0";
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
startHourField.text = setConfigText(root.settings[1]);
startHourField.focus = false;
} else if (event.key === Qt.Key_Return) {
startHourField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
startMinuteField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
endMinuteField.focus = true;
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
var digit = event.text;
var textLen = startHourField.text.length;
if (textLen >= 2 && startHourField.text[0] !== '0') {
return;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(startHourField.text + digit);
} else {
val = parseInt(startHourField.text[1] + digit);
}
val = Math.max(0, Math.min(23, val));
if (textLen >= 2 && val < 10) {
startHourField.text = "0" + val;
} else {
startHourField.text = val.toString();
}
}
event.accepted = true;
}
onCursorPositionChanged: cursorPosition = 2
onTextEdited: {
if (startHourField.text === "")
return;
var val = parseInt(startHourField.text);
if (isNaN(val))
return;
val = Math.max(0, Math.min(23, val));
var newText = val.toString();
if (newText !== startHourField.text)
startHourField.text = newText;
}
}
}
CustomText {
id: startSeparator
font.pointSize: Tokens.font.size.extraLarge
text: ":"
}
Layout.preferredHeight: 72
Layout.preferredWidth: 96
color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
implicitHeight: 72
implicitWidth: 96
radius: Tokens.rounding.small
CustomRect {
id: startMinuteRect
anchors.fill: parent
border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: startMinuteField.focus ? 2 : 0
radius: parent.radius - border.width
Layout.preferredHeight: 72
Layout.preferredWidth: 96
color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
implicitHeight: 72
implicitWidth: 96
radius: Tokens.rounding.small
Behavior on border.width {
Anim {}
}
}
CustomRect {
anchors.fill: parent
border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: startMinuteField.focus ? 2 : 0
radius: parent.radius - border.width
TextFieldBase {
id: startMinuteField
Behavior on border.width {
Anim {
}
function setConfigText(): string {
var val = root.convertMinute(root.start);
if (val === 0) {
return "00";
}
return String(val);
}
TextFieldBase {
id: startMinuteField
function setConfigText(setting: string): string {
var val = root.convertMinute(root.object[setting]);
if (val === 0) {
return "00";
}
return String(val);
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText(root.settings[1])
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
if (startMinuteField.text.length >= 2) {
startMinuteField.text = "0" + startMinuteField.text[0];
} else if (startMinuteField.text.length === 1) {
startMinuteField.text = "0";
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
startMinuteField.text = setConfigText(root.settings[1]);
startMinuteField.focus = false;
} else if (event.key === Qt.Key_Return) {
startMinuteField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
endHourField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
startHourField.focus = true;
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
var digit = event.text;
var textLen = startMinuteField.text.length;
if (textLen >= 2 && startMinuteField.text[0] !== '0') {
return;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(startMinuteField.text + digit);
} else {
val = parseInt(startMinuteField.text[1] + digit);
}
val = Math.max(0, Math.min(59, val));
if (textLen >= 2 && val < 10) {
startMinuteField.text = "0" + val;
} else {
startMinuteField.text = val.toString();
}
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText()
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
}
onCursorPositionChanged: cursorPosition = 2
onTextEdited: {
if (startMinuteField.text === "")
return;
var val = parseInt(startMinuteField.text);
if (isNaN(val))
return;
val = Math.max(0, Math.min(23, val));
var newText = val.toString();
if (newText !== startMinuteField.text)
startMinuteField.text = newText;
}
}
}
Item {
id: spacer
Layout.preferredWidth: Tokens.spacing.largeIncreased
}
CustomRect {
id: endHourRect
Layout.preferredHeight: 72
Layout.preferredWidth: 96
color: endHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
implicitHeight: 72
implicitWidth: 96
radius: Tokens.rounding.small
CustomRect {
anchors.fill: parent
border.color: endHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: endHourField.focus ? 2 : 0
radius: parent.radius - border.width
Behavior on border.width {
Anim {
if (startMinuteField.text.length >= 2) {
startMinuteField.text = "0" + startMinuteField.text[0];
} else if (startMinuteField.text.length === 1) {
startMinuteField.text = "0";
}
}
}
TextFieldBase {
id: endHourField
function setConfigText(setting: string): string {
var val = root.convertHour(root.object[setting]);
if (val === 0) {
return "00";
}
return String(val);
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText(root.settings[2])
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
if (endHourField.text.length >= 2) {
endHourField.text = "0" + endHourField.text[0];
} else if (endHourField.text.length === 1) {
endHourField.text = "0";
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
endHourField.text = setConfigText(root.settings[2]);
endHourField.focus = false;
} else if (event.key === Qt.Key_Return) {
endHourField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
endMinuteField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
startMinuteField.focus = true;
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
var digit = event.text;
var textLen = endHourField.text.length;
if (textLen >= 2 && endHourField.text[0] !== '0') {
return;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(endHourField.text + digit);
} else {
val = parseInt(endHourField.text[1] + digit);
}
val = Math.max(0, Math.min(23, val));
if (textLen >= 2 && val < 10) {
endHourField.text = "0" + val;
} else {
endHourField.text = val.toString();
}
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
}
onCursorPositionChanged: cursorPosition = 2
onTextEdited: {
if (endHourField.text === "")
return;
var val = parseInt(endHourField.text);
if (isNaN(val))
return;
val = Math.max(0, Math.min(23, val));
var newText = val.toString();
if (newText !== endHourField.text)
endHourField.text = newText;
}
}
}
CustomText {
id: endSeparator
font.pointSize: Tokens.font.size.extraLarge
text: ":"
}
CustomRect {
id: endMinuteRect
Layout.preferredHeight: 72
Layout.preferredWidth: 96
color: endMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
implicitHeight: 72
implicitWidth: 96
radius: Tokens.rounding.small
CustomRect {
anchors.fill: parent
border.color: endMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: endMinuteField.focus ? 2 : 0
radius: parent.radius - border.width
Behavior on border.width {
Anim {
}
}
}
TextFieldBase {
id: endMinuteField
function setConfigText(setting: string): string {
var val = root.convertMinute(root.object[setting]);
if (val === 0) {
return "00";
}
return String(val);
startMinuteField.text = setConfigText();
startMinuteField.focus = false;
} else if (event.key === Qt.Key_Return) {
startMinuteField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
endHourField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
startHourField.focus = true;
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText(root.settings[2])
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
if (endMinuteField.text.length >= 2) {
endMinuteField.text = "0" + endMinuteField.text[0];
} else if (endMinuteField.text.length === 1) {
endMinuteField.text = "0";
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
endMinuteField.text = setConfigText(root.settings[2]);
endMinuteField.focus = false;
} else if (event.key === Qt.Key_Return) {
endMinuteField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
startHourField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
endHourField.focus = true;
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
var digit = event.text;
var textLen = endMinuteField.text.length;
if (textLen >= 2 && endMinuteField.text[0] !== '0') {
return;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(endMinuteField.text + digit);
} else {
val = parseInt(endMinuteField.text[1] + digit);
}
val = Math.max(0, Math.min(59, val));
if (textLen >= 2 && val < 10) {
endMinuteField.text = "0" + val;
} else {
endMinuteField.text = val.toString();
}
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
}
onCursorPositionChanged: cursorPosition = 2
onTextEdited: {
if (endMinuteField.text === "")
var digit = event.text;
var textLen = startMinuteField.text.length;
if (textLen >= 2 && startMinuteField.text[0] !== '0') {
return;
var val = parseInt(endMinuteField.text);
if (isNaN(val))
return;
val = Math.max(0, Math.min(23, val));
var newText = val.toString();
if (newText !== endMinuteField.text)
endMinuteField.text = newText;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(startMinuteField.text + digit);
} else {
val = parseInt(startMinuteField.text[1] + digit);
}
val = Math.max(0, Math.min(59, val));
if (textLen >= 2 && val < 10) {
startMinuteField.text = "0" + val;
} else {
startMinuteField.text = val.toString();
}
}
event.accepted = true;
}
onCursorPositionChanged: cursorPosition = 2
onTextChanged: {
var mins = parseInt(startMinuteField.text);
const hours = root.convertToMinutes(parseInt(startHourField.text));
root.startEdit(hours + mins);
}
}
}
RowLayout {
CustomText {
Layout.preferredWidth: spacer.x + spacer.width
text: qsTr("Start")
Item {
id: spacer
Layout.preferredWidth: Tokens.spacing.largeIncreased
}
CustomRect {
id: endHourRect
Layout.preferredHeight: 72
Layout.preferredWidth: 96
color: endHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
implicitHeight: 72
implicitWidth: 96
radius: Tokens.rounding.small
CustomRect {
anchors.fill: parent
border.color: endHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: endHourField.focus ? 2 : 0
radius: parent.radius - border.width
Behavior on border.width {
Anim {}
}
}
CustomText {
Layout.preferredWidth: endMinuteRect.width + endHourRect.width
text: qsTr("End")
TextFieldBase {
id: endHourField
function setConfigText(): string {
var val = root.convertHour(root.end);
if (val === 0) {
return "00";
}
return String(val);
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText()
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
if (endHourField.text.length >= 2) {
endHourField.text = "0" + endHourField.text[0];
} else if (endHourField.text.length === 1) {
endHourField.text = "0";
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
endHourField.text = setConfigText();
endHourField.focus = false;
} else if (event.key === Qt.Key_Return) {
endHourField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
endMinuteField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
startMinuteField.focus = true;
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
var digit = event.text;
var textLen = endHourField.text.length;
if (textLen >= 2 && endHourField.text[0] !== '0') {
return;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(endHourField.text + digit);
} else {
val = parseInt(endHourField.text[1] + digit);
}
val = Math.max(0, Math.min(23, val));
if (textLen >= 2 && val < 10) {
endHourField.text = "0" + val;
} else {
endHourField.text = val.toString();
}
}
event.accepted = true;
}
onCursorPositionChanged: cursorPosition = 2
onTextChanged: {
var mins = parseInt(endMinuteField.text);
const hours = root.convertToMinutes(parseInt(endHourField.text));
root.endEdit(hours + mins);
}
}
}
CustomText {
id: endSeparator
font.pointSize: Tokens.font.size.extraLarge
text: ":"
}
CustomRect {
id: endMinuteRect
Layout.preferredHeight: 72
Layout.preferredWidth: 96
color: endMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
implicitHeight: 72
implicitWidth: 96
radius: Tokens.rounding.small
CustomRect {
anchors.fill: parent
border.color: endMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: endMinuteField.focus ? 2 : 0
radius: parent.radius - border.width
Behavior on border.width {
Anim {}
}
}
TextFieldBase {
id: endMinuteField
function setConfigText(): string {
var val = root.convertMinute(root.end);
if (val === 0) {
return "00";
}
return String(val);
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText()
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
if (endMinuteField.text.length >= 2) {
endMinuteField.text = "0" + endMinuteField.text[0];
} else if (endMinuteField.text.length === 1) {
endMinuteField.text = "0";
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
endMinuteField.text = setConfigText();
endMinuteField.focus = false;
} else if (event.key === Qt.Key_Return) {
endMinuteField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
startHourField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
endHourField.focus = true;
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
var digit = event.text;
var textLen = endMinuteField.text.length;
if (textLen >= 2 && endMinuteField.text[0] !== '0') {
return;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(endMinuteField.text + digit);
} else {
val = parseInt(endMinuteField.text[1] + digit);
}
val = Math.max(0, Math.min(59, val));
if (textLen >= 2 && val < 10) {
endMinuteField.text = "0" + val;
} else {
endMinuteField.text = val.toString();
}
}
event.accepted = true;
}
onCursorPositionChanged: cursorPosition = 2
onTextChanged: {
var mins = parseInt(endMinuteField.text);
const hours = root.convertToMinutes(parseInt(endHourField.text));
root.endEdit(hours + mins);
}
}
}
}
RowLayout {
id: buttonRow
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.margins: Tokens.padding.largeIncreased
anchors.right: parent.right
anchors.top: column.bottom
anchors.topMargin: Tokens.spacing.medium
Item {
id: buttonSpacer
Layout.fillWidth: true
CustomText {
Layout.preferredWidth: spacer.x + spacer.width
text: qsTr("Start")
}
ButtonRow {
spacing: Tokens.spacing.medium
IconTextButton {
font.pointSize: Tokens.font.size.normal
icon: "close"
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2)
inactiveOnColor: Colors.palette.m3onSurfaceVariant
isRound: true
isToggle: false
shapeMorph: true
shapeMorphExpansion: pressed ? 12 : 0
text: "Cancel"
onClicked: root.close()
}
IconTextButton {
font.pointSize: Tokens.font.size.normal
icon: "check"
isRound: true
isToggle: false
shapeMorph: true
shapeMorphExpansion: pressed ? 12 : 0
text: "Apply"
onClicked: {
const start = root.convertToMinutes(parseInt(startHourField.text)) + parseInt(startMinuteField.text);
const end = root.convertToMinutes(parseInt(endHourField.text)) + parseInt(endMinuteField.text);
root.applySettings(start, end);
}
}
CustomText {
Layout.preferredWidth: endMinuteRect.width + endHourRect.width
text: qsTr("End")
}
}
}
+10 -4
View File
@@ -12,6 +12,7 @@ CustomSwitch {
property alias last: bg.last
property string settingAnchor
property string subtext
property string iconText
function flashHighlight(): void {
bg.flashHighlight();
@@ -63,8 +64,7 @@ CustomSwitch {
text: root.text
Behavior on opacity {
Anim {
}
Anim {}
}
}
@@ -81,10 +81,16 @@ CustomSwitch {
visible: root.subtext
Behavior on opacity {
Anim {
}
Anim {}
}
}
MaterialIcon {
id: icon
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.iconText ?? ""
}
}
}
+1 -6
View File
@@ -24,8 +24,7 @@ CustomClippingRect {
radius: Tokens.rounding.largeIncreased + Tokens.padding.small
Behavior on blobColor {
CAnim {
}
CAnim {}
}
TapHandler {
@@ -70,8 +69,4 @@ CustomClippingRect {
anchors.top: parent.top
sState: root.sState
}
PopupOverlay {
anchors.fill: parent
}
}
+12 -19
View File
@@ -81,14 +81,10 @@ VerticalFadeFlickable {
topLeftRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
topRightRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
RadiusBehavior on bottomLeftRadius {
}
RadiusBehavior on bottomRightRadius {
}
RadiusBehavior on topLeftRadius {
}
RadiusBehavior on topRightRadius {
}
RadiusBehavior on bottomLeftRadius {}
RadiusBehavior on bottomRightRadius {}
RadiusBehavior on topLeftRadius {}
RadiusBehavior on topRightRadius {}
StateLayer {
id: stateLayer
@@ -262,14 +258,10 @@ VerticalFadeFlickable {
topRightRadius: layer.pressed ? Tokens.rounding.largeIncreased : isFirst ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
width: cardList.width
RadiusBehavior on bottomLeftRadius {
}
RadiusBehavior on bottomRightRadius {
}
RadiusBehavior on topLeftRadius {
}
RadiusBehavior on topRightRadius {
}
RadiusBehavior on bottomLeftRadius {}
RadiusBehavior on bottomRightRadius {}
RadiusBehavior on topLeftRadius {}
RadiusBehavior on topRightRadius {}
ColumnLayout {
id: resultLayout
@@ -318,9 +310,10 @@ VerticalFadeFlickable {
z: 1
onClicked: {
root.sState.jumpToSetting(result.modelData.pageIdx, result.modelData.subPath, result.modelData.anchor);
}
onClicked: {
const target = result.modelData.targetSubPath;
root.sState.jumpToSetting(result.modelData.pageIdx, target.length > 0 ? target : result.modelData.subPath, target.length > 0 ? "" : result.modelData.anchor);
}
}
CustomSwitch {
+4
View File
@@ -30,6 +30,10 @@ QtObject {
Component {
WallpaperSelect {}
}
Component {
ColorsFonts {}
}
}
},
Component {
+13
View File
@@ -1,6 +1,7 @@
import QtQuick
import qs.Components
import ZShell.Config
import qs.Services
Item {
id: root
@@ -41,6 +42,18 @@ Item {
objectName: "PageContainer"
Component.onCompleted: root.loadPage(root.sState.currentPageIdx)
// CustomRect {
// anchors.fill: parent
// color: Qt.alpha(Colors.palette.m3shadow, 0.3)
// opacity: root.sState.dimmed ? 1 : 0
// visible: opacity > 0
// z: 0
//
// Behavior on opacity {
// Anim {}
// }
// }
}
Connections {
@@ -55,6 +55,10 @@ PageBase {
enabled: Object.keys(model).length > 0
header: qsTr("Add new entry")
icon: "add"
settingAnchor: "panels-bar-status-icons-add-item"
onOpenChanged: {
root.sState.dimmed = open;
}
last: true
label: qsTr("Add entry")
model: {
@@ -67,6 +67,20 @@ PageBase {
SectionHeader {
first: true
text: qsTr("General")
}
ToggleRow {
settingAnchor: "panels-sidebar-llm-enabled"
checked: Config.llm.enabled
text: qsTr("Enabled")
first: true
last: true
onToggled: Config.llm.enabled = checked
}
SectionHeader {
text: qsTr("Appearance")
}
@@ -75,10 +89,18 @@ PageBase {
acceptLabel: qsTr("Confirm")
enabled: Object.keys(model).length > 0
settingAnchor: "panels-sidebar-select-scheme"
separateContent: false
header: qsTr("Select scheme")
first: 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")
subtext: qsTr("Select the color scheme used for code blocks")
model: root.schemes
@@ -88,6 +88,10 @@ PageBase {
enabled: Object.keys(model).length > 0
header: qsTr("Add new entry")
icon: "add"
onOpenChanged: {
root.sState.dimmed = open;
}
settingAnchor: "panels-sidebar-quick-toggles-add-item"
label: qsTr("Add entry")
last: true
model: {
@@ -116,6 +120,7 @@ PageBase {
NavRow {
text: qsTr("AI chat")
settingAnchor: "panels-sidebar-llm"
icon: "robot_2"
first: true
last: true
+2 -2
View File
@@ -202,12 +202,12 @@ PageBase {
}
ToggleRow {
checked: Config.general.color.smart
checked: Config.colors.smart
settingAnchor: "services-smart-color-scheme"
subtext: qsTr("Derive theme mode from the wallpaper")
text: qsTr("Smart color scheme")
onToggled: Config.general.color.smart = checked
onToggled: Config.colors.smart = checked
}
SelectRow {
@@ -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;
}
}
}
}
+113 -59
View File
@@ -33,18 +33,41 @@ PageBase {
}
}
IconTextButton {
ButtonRow {
Layout.alignment: Qt.AlignHCenter
enabled: Config.background.enabled
horizontalPadding: Tokens.padding.extraLarge
icon: "wallpaper"
isRound: true
shapeMorph: true
text: qsTr("Wallpapers")
type: IconTextButton.Tonal
verticalPadding: Tokens.padding.small
spacing: Tokens.spacing.small
onClicked: root.sState.openSubPage(1) // Wallpaper page
IconTextButton {
property string settingAnchor: "style-wallpapers"
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 {
property string settingAnchor: "style-colors-fonts"
enabled: Config.background.enabled
horizontalPadding: Tokens.padding.extraLarge
icon: "palette"
isRound: true
shapeMorph: true
text: qsTr("Colors & font")
type: IconTextButton.Tonal
verticalPadding: Tokens.padding.small
onClicked: root.sState.openSubPage(2) // Colors & font page
}
}
ToggleRow {
@@ -76,85 +99,116 @@ PageBase {
onToggled: Colors.setMode(checked ? "dark" : "light")
}
OverlayRow {
id: darkMode
TimeDialogSelect {
readonly property string endTime: {
var d = new Date(0, 0, 0, 0, 0, 0, 0);
d.setMinutes(Config.general.color.scheduleDarkEnd);
d.setMinutes(Config.colors.scheduleDarkEnd);
return Qt.formatTime(d, "hh:mm AP");
}
readonly property string startTime: {
var d = new Date(0, 0, 0, 0, 0, 0, 0);
d.setMinutes(Config.general.color.scheduleDarkStart);
d.setMinutes(Config.colors.scheduleDarkStart);
return Qt.formatTime(d, "hh:mm AP");
}
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
last: true
settingAnchor: "style-schedule-dark-mode"
acceptLabel: qsTr("Apply")
rootParent: root.flickable
header: qsTr("Select time")
subtext: qsTr("Dark mode will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime)
text: qsTr("Schedule dark mode")
label: qsTr("Schedule dark mode")
popup: Component {
TimeInput {
object: Config.general.color
settings: ["scheduleDark", "scheduleDarkStart", "scheduleDarkEnd"]
onApplySettings: (start, end) => {
Config.general.color.scheduleDarkStart = start;
Config.general.color.scheduleDarkEnd = end;
ModeScheduler.checkStartup();
PopupManager.requestClose();
}
onClose: PopupManager.requestClose()
}
onOpenChanged: {
root.sState.dimmed = open;
}
onClicked: value => {
Config.general.color.scheduleDark = value;
onAccepted: {
Config.colors.scheduleDarkStart = startCurrent;
Config.colors.scheduleDarkEnd = endCurrent;
ModeScheduler.checkStartup();
}
}
OverlayRow {
id: hyprsunset
TimeDialogSelect {
readonly property string endTime: {
var d = new Date(0, 0, 0, 0, 0, 0, 0);
d.setMinutes(Config.general.color.scheduleHyprsunsetEnd);
d.setMinutes(Config.display.nightlight.scheduleEnd);
return Qt.formatTime(d, "hh:mm AP");
}
readonly property string startTime: {
var d = new Date(0, 0, 0, 0, 0, 0, 0);
d.setMinutes(Config.general.color.scheduleHyprsunsetStart);
d.setMinutes(Config.display.nightlight.scheduleStart);
return Qt.formatTime(d, "hh:mm AP");
}
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
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
settingAnchor: "style-schedule-hyprsunset"
subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime)
text: qsTr("Schedule hyprsunset")
stepSize: 1
subtext: qsTr("How long it takes for the gradual nightlight to finish (s)")
text: qsTr("Fade duration")
to: 60 * 5
value: Config.display.nightlight.fadeDuration / 1000
popup: Component {
TimeInput {
object: Config.general.color
settings: ["scheduleHyprsunset", "scheduleHyprsunsetStart", "scheduleHyprsunsetEnd"]
onApplySettings: (start, end) => {
Config.general.color.scheduleHyprsunsetStart = start;
Config.general.color.scheduleHyprsunsetEnd = end;
Hyprsunset.checkStartup();
PopupManager.requestClose();
}
onClose: PopupManager.requestClose()
}
}
onClicked: value => {
Config.general.color.scheduleHyprsunset = value;
}
onMoved: v => Config.display.nightlight.fadeDuration = v * 1000
}
}
}
-20
View File
@@ -1,20 +0,0 @@
pragma Singleton
import Quickshell
import QtQuick
Singleton {
id: root
property bool closed: true
property Component currentPopup: null
function requestClose(): void {
closed = true;
}
function requestOpen(component: Component): void {
currentPopup = component;
closed = false;
}
}
-47
View File
@@ -1,47 +0,0 @@
import QtQuick
import ZShell.Config
import qs.Components
import qs.Services
CustomRect {
id: root
readonly property bool closing: PopupManager.closed
readonly property var currentPopup: PopupManager.currentPopup
property bool shouldBeVisible: loader.status === Loader.Ready
color: Qt.alpha(Colors.palette.m3shadow, 0.3)
opacity: shouldBeVisible && !closing ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {
}
}
onVisibleChanged: if (!visible)
PopupManager.currentPopup = null
CustomMouseArea {
anchors.fill: parent
hoverEnabled: true
preventStealing: true
propagateComposedEvents: false
onClicked: {
const insideItemWidth = mouseX < loader.x + loader.item.width && mouseX > loader.x;
const insideItemHeight = mouseY < loader.y + loader.item.height && mouseY > loader.y;
if (insideItemHeight && insideItemWidth)
return;
PopupManager.requestClose();
}
}
Loader {
id: loader
anchors.centerIn: parent
sourceComponent: root.currentPopup
}
}
+84 -59
View File
@@ -1,66 +1,24 @@
pragma Singleton
import "../../scripts/fzf.js" as Fzf
import "../../scripts/settings-indexer.js" as SettingsIndexer
import QtQuick
import Quickshell
import ZShell
import ZShell.Config
import qs.Paths
Singleton {
id: root
property var fzfFinder: null
property var inverted: ({})
property var ranking: ({})
readonly property var highlightCache: ({
"search": "",
"pattern": null
})
property var inverted: ({})
property var ranking: ({})
function highlight(text: string, search: string, colour: color): string {
const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
if (search.length === 0)
return escaped;
const cache = root.highlightCache;
if (search !== cache.search) {
const tokens = tokenize(search);
cache.search = search;
if (tokens.length === 0)
cache.pattern = null;
else {
const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
cache.pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi");
}
}
const pattern = cache.pattern;
if (!pattern)
return escaped;
pattern.lastIndex = 0;
if (!pattern.test(escaped))
return escaped;
pattern.lastIndex = 0;
return escaped.replace(pattern, `<font color="${colour}">$1</font>`);
}
function lookup(token: string): var {
const result = ({});
const exact = root.inverted[token] !== undefined;
const keys = exact ? [token] : Object.keys(root.inverted).filter(k => k.startsWith(token));
for (const key of keys) {
const rank = root.ranking[key] ?? ({});
for (const id of root.inverted[key]) {
const w = rank[id] ?? 0.1;
if (result[id] === undefined || w > result[id])
result[id] = w;
}
}
return result;
}
property var fzfFinder: null
readonly property string cachePath: Paths.cache + "/settings-index.json"
function query(search: string): list<QtObject> {
const tokens = root.tokenize(search);
@@ -70,7 +28,7 @@ Singleton {
const scores = ({});
const hitCounts = ({});
for (const token of tokens) {
const matches = root.lookup(token);
const matches = root.lookup(token); // { id: weight }
for (const id in matches) {
scores[id] = (scores[id] ?? 0) + matches[id];
hitCounts[id] = (hitCounts[id] ?? 0) + 1;
@@ -103,13 +61,76 @@ Singleton {
return out;
}
function lookup(token: string): var {
const result = ({});
const exact = root.inverted[token] !== undefined;
const keys = exact ? [token] : Object.keys(root.inverted).filter(k => k.startsWith(token));
for (const key of keys) {
const rank = root.ranking[key] ?? ({});
for (const id of root.inverted[key]) {
const w = rank[id] ?? 0.1;
if (result[id] === undefined || w > result[id])
result[id] = w;
}
}
return result;
}
function tokenize(text: string): var {
return text.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length > 0);
}
function highlight(text: string, search: string, colour: color): string {
const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
if (search.length === 0)
return escaped;
const cache = root.highlightCache;
if (search !== cache.search) {
const tokens = root.tokenize(search);
cache.search = search;
if (tokens.length === 0) {
cache.pattern = null;
} else {
const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
cache.pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi");
}
}
const pattern = cache.pattern;
if (!pattern)
return escaped;
pattern.lastIndex = 0;
if (!pattern.test(escaped))
return escaped;
pattern.lastIndex = 0;
return escaped.replace(pattern, `<font color="${colour}">$1</font>`);
}
function loadIndex(): var {
const revision = ZUtils.gitRevision();
const cached = ZUtils.readTextFile(cachePath);
if (cached) {
try {
const parsed = JSON.parse(cached);
if (parsed.version === 3 && revision && parsed.revision === revision && parsed.locale === Qt.locale().name)
return parsed;
} catch (e) {}
}
const data = SettingsIndexer.buildIndex(`${Quickshell.shellDir}/Modules/Settings`, p => ZUtils.readTextFile(p), (d, s) => ZUtils.listFiles(d, s), (ctx, text) => qsTranslate(ctx, text));
data.revision = revision;
data.locale = Qt.locale().name;
ZUtils.writeTextFile(cachePath, JSON.stringify(data));
console.log(`SettingsSearcher: indexed ${data.entries.length} settings (revision ${revision || "unknown"})`);
return data;
}
Component.onCompleted: {
try {
const data = JSON.parse(ZUtils.settingsIndex());
const data = root.loadIndex();
entries.model = data.entries;
root.inverted = data.inverted ?? {};
root.ranking = data.ranking ?? {};
@@ -122,6 +143,7 @@ Singleton {
limit: 25
});
} catch (e) {
console.warn("SettingsSearcher: failed to build settings index:", e);
entries.model = [];
root.inverted = {};
root.ranking = {};
@@ -132,22 +154,25 @@ Singleton {
Variants {
id: entries
SettingEntry {
}
SettingEntry {}
}
component SettingEntry: QtObject {
readonly property string anchor: modelData.anchor ?? ""
required property var modelData
readonly property int pageIdx: modelData.pageIdx
readonly property var subPath: modelData.subPath
readonly property var targetSubPath: modelData.targetSubPath ?? []
readonly property var crumbIcons: modelData.crumbIcons
readonly property var crumbLabels: modelData.crumbLabels
readonly property bool isToggle: togglePath.length > 0
required property var modelData
readonly property int pageIdx: modelData.pageIdx
readonly property string section: modelData.section ?? ""
readonly property var subPath: modelData.subPath
readonly property string subtext: modelData.subtext ?? ""
readonly property string title: modelData.title
readonly property string section: modelData.section ?? ""
readonly property string subtext: modelData.subtext ?? ""
readonly property string anchor: modelData.anchor ?? ""
readonly property string icon: modelData.icon ?? ""
readonly property string togglePath: modelData.togglePath ?? ""
readonly property bool isToggle: togglePath.length > 0
readonly property bool toggleValue: {
if (!isToggle)
return false;
+1
View File
@@ -16,6 +16,7 @@ QtObject {
property string searchText
property DesktopEntry selectedApp
property BluetoothDevice selectedBtDevice
property bool dimmed
property string selectedWallpaperCategory
property list<int> subPageIdxStack
+23
View File
@@ -1 +1,24 @@
find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus CanvasPainter)
find_package(PkgConfig REQUIRED)
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED)
pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET)
pkg_check_modules(GLIB REQUIRED glib-2.0 gobject-2.0 gio-2.0)
if(NOT Cava_FOUND)
pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED)
endif()
include(cmake/sensorslib.cmake)
set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
qt_standard_project_setup(REQUIRES 6.9)
include(cmake/pch.cmake)
include(cmake/qml-module.cmake)
add_library(zshell-util INTERFACE)
target_include_directories(zshell-util INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include")
add_subdirectory(ZShell)
+13 -55
View File
@@ -1,55 +1,3 @@
find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus CanvasPainter)
find_package(PkgConfig REQUIRED)
find_library(SENSORS_LIBRARY NAMES sensors REQUIRED)
find_path(SENSORS_INCLUDE_DIR NAMES sensors/sensors.h REQUIRED)
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED)
pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET)
pkg_check_modules(GLIB REQUIRED glib-2.0 gobject-2.0 gio-2.0)
if(NOT Cava_FOUND)
pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED)
endif()
if(NOT TARGET Sensors::Sensors)
add_library(Sensors::Sensors UNKNOWN IMPORTED)
set_target_properties(Sensors::Sensors PROPERTIES
IMPORTED_LOCATION "${SENSORS_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${SENSORS_INCLUDE_DIR}"
)
endif()
set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
qt_standard_project_setup(REQUIRES 6.9)
function(qml_module arg_TARGET)
cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;LIBRARIES;RESOURCES")
qt_add_qml_module(${arg_TARGET}
URI ${arg_URI}
VERSION 1.0
SOURCES ${arg_SOURCES}
RESOURCES ${arg_RESOURCES}
)
qt_query_qml_module(${arg_TARGET}
URI module_uri
VERSION module_version
PLUGIN_TARGET module_plugin_target
TARGET_PATH module_target_path
QMLDIR module_qmldir
TYPEINFO module_typeinfo
)
set(module_dir "${INSTALL_QMLDIR}/${module_target_path}")
install(TARGETS ${arg_TARGET} LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}")
install(TARGETS "${module_plugin_target}" LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}")
install(FILES "${module_qmldir}" DESTINATION "${module_dir}")
install(FILES "${module_typeinfo}" DESTINATION "${module_dir}")
target_link_libraries(${arg_TARGET} PRIVATE Qt::Core Qt::Qml ${arg_LIBRARIES})
endfunction()
set_source_files_properties("${SETTINGS_INDEX_JSON}" PROPERTIES QT_RESOURCE_ALIAS "settings-index.json")
qml_module(ZShell
URI ZShell
SOURCES
@@ -60,8 +8,6 @@ qml_module(ZShell
toaster.hpp toaster.cpp
qalculator.hpp qalculator.cpp
zutils.hpp zutils.cpp
RESOURCES
"${SETTINGS_INDEX_JSON}"
LIBRARIES
Qt::Gui
Qt::Quick
@@ -69,9 +15,13 @@ qml_module(ZShell
Qt::Sql
Qt::DBus
PkgConfig::Qalculate
zshell-util
)
target_compile_definitions(ZShell PRIVATE ZSHELL_VERSION="${VERSION}")
target_compile_definitions(ZShell PRIVATE
ZSHELL_VERSION="${VERSION}"
GIT_REVISION="${GIT_REVISION}"
)
add_subdirectory(Models)
add_subdirectory(Internal)
@@ -80,3 +30,11 @@ add_subdirectory(Components)
add_subdirectory(Blobs)
add_subdirectory(Config)
add_subdirectory(Llm)
pkg_check_modules(HYPRLAND_PROBE hyprland)
if(HYPRLAND_PROBE_FOUND)
add_subdirectory(HyprPlugins)
else()
message(STATUS
"hyprland development headers not found; skipping zshell-nightlight plugin")
endif()
+2
View File
@@ -5,6 +5,7 @@ qml_module(ZShell-config
configobject.hpp configobject.cpp
configlist.hpp configlist.cpp
config.hpp config.cpp
migration.hpp migration.cpp
anim.hpp anim.cpp
tokens.hpp
appearance.hpp
@@ -13,6 +14,7 @@ qml_module(ZShell-config
clipboard.hpp
colors.hpp
dashboard.hpp
display.hpp
dock.hpp
general.hpp
launcher.hpp
+7
View File
@@ -22,6 +22,13 @@ class Colors : public ConfigObject {
CONFIG_SUBOBJECT(Presets, presets)
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:
explicit Colors(QObject* parent = nullptr)
+14 -7
View File
@@ -6,11 +6,13 @@
#include "clipboard.hpp"
#include "colors.hpp"
#include "dashboard.hpp"
#include "display.hpp"
#include "dock.hpp"
#include "general.hpp"
#include "launcher.hpp"
#include "llm.hpp"
#include "lock.hpp"
#include "migration.hpp"
#include "notifs.hpp"
#include "osd.hpp"
#include "screenshot.hpp"
@@ -44,6 +46,7 @@ Config::Config(QObject* parent)
, m_clipboard(new Clipboard(this))
, m_colors(new Colors(this))
, m_dashboard(new Dashboard(this))
, m_display(new Display(this))
, m_dock(new Dock(this))
, m_general(new General(this))
, m_launcher(new Launcher(this))
@@ -91,8 +94,7 @@ Config* Config::instance() {
}
Config* Config::create(QQmlEngine*, QJSEngine*) {
if (!s_instance)
s_instance = new Config();
if (!s_instance) s_instance = new Config();
return s_instance;
}
@@ -111,11 +113,14 @@ void Config::loadSync() {
QJsonObject before;
bool existed = false;
bool migrated = false;
if (f.open(QIODevice::ReadOnly)) {
const auto doc = QJsonDocument::fromJson(f.readAll());
if (doc.isObject()) {
before = doc.object();
const QJsonObject raw = doc.object();
before = ConfigMigrations::apply(raw);
migrated = before != raw;
existed = true;
} else {
qInfo() << "Config: existing config at" << filePath()
@@ -130,7 +135,7 @@ void Config::loadSync() {
m_loading = false;
const auto after = toJson().toObject();
if (!existed || after != before) saveNow();
if (!existed || migrated || after != before) saveNow();
}
void Config::updateWatch() {
@@ -176,16 +181,18 @@ void Config::loadAsync() {
if (f.open(QIODevice::ReadOnly)) {
const auto doc = QJsonDocument::fromJson(f.readAll());
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(
this,
[this, valid, before]() {
[this, valid, migrated, before]() {
loadFromJson(QJsonValue(before));
m_loading = false;
const auto after = toJson().toObject();
if (!valid || after != before) saveNow();
if (!valid || migrated || after != before) saveNow();
if (m_reloadPending) m_reloadTimer.start();
},
+3
View File
@@ -21,6 +21,7 @@ class Bar;
class Clipboard;
class Colors;
class Dashboard;
class Display;
class Dock;
class General;
class Launcher;
@@ -44,6 +45,7 @@ class Config : public ConfigObject {
Q_MOC_INCLUDE("clipboard.hpp")
Q_MOC_INCLUDE("colors.hpp")
Q_MOC_INCLUDE("dashboard.hpp")
Q_MOC_INCLUDE("display.hpp")
Q_MOC_INCLUDE("dock.hpp")
Q_MOC_INCLUDE("general.hpp")
Q_MOC_INCLUDE("launcher.hpp")
@@ -62,6 +64,7 @@ class Config : public ConfigObject {
CONFIG_SUBOBJECT(Clipboard, clipboard)
CONFIG_SUBOBJECT(Colors, colors)
CONFIG_SUBOBJECT(Dashboard, dashboard)
CONFIG_SUBOBJECT(Display, display)
CONFIG_SUBOBJECT(Dock, dock)
CONFIG_SUBOBJECT(General, general)
CONFIG_SUBOBJECT(Launcher, launcher)
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include "configobject.hpp"
#include <qqmlintegration.h>
namespace ZShell::config {
class Nightlight : public ConfigObject {
Q_OBJECT
QML_ANONYMOUS
CFG_PROPERTY(bool, schedule, true)
CFG_PROPERTY(int, scheduleStart, 1200)
CFG_PROPERTY(int, scheduleEnd, 570)
CFG_PROPERTY(bool, useNative, true)
CFG_PROPERTY(int, fadeDuration, 2000)
CFG_PROPERTY(int, temp, 2600)
public:
explicit Nightlight(QObject* parent = nullptr) : ConfigObject(parent) {}
};
class Display : public ConfigObject {
Q_OBJECT
QML_ANONYMOUS
CONFIG_SUBOBJECT(Nightlight, nightlight)
public:
explicit Display(QObject* parent = nullptr)
: ConfigObject(parent), m_nightlight(new Nightlight(this)) {}
};
} // namespace ZShell::config
-22
View File
@@ -91,26 +91,6 @@ class Battery : public ConfigObject {
explicit Battery(QObject* parent = nullptr) : ConfigObject(parent) {}
};
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 {
Q_OBJECT
QML_ANONYMOUS
@@ -135,7 +115,6 @@ class General : public ConfigObject {
CONFIG_SUBOBJECT(Apps, apps)
CONFIG_SUBOBJECT(Battery, battery)
CONFIG_SUBOBJECT(ColorSettings, color)
CFG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss"))
CFG_PROPERTY(bool, desktopIcons, true)
CONFIG_SUBOBJECT(Idle, idle)
@@ -151,7 +130,6 @@ class General : public ConfigObject {
: ConfigObject(parent)
, m_apps(new Apps(this))
, m_battery(new Battery(this))
, m_color(new ColorSettings(this))
, m_idle(new Idle(this)) {}
};
+1
View File
@@ -24,6 +24,7 @@ class Llm : public ConfigObject {
CFG_PROPERTY(QString, model, "")
CFG_PROPERTY(double, temperature, 0.7)
CFG_PROPERTY(bool, tools, true)
CFG_PROPERTY(bool, enabled, true)
CONFIG_SUBOBJECT(LlmAppearance, appearance)
public:
+123
View File
@@ -0,0 +1,123 @@
#include "migration.hpp"
#include <QDebug>
namespace ZShell::config {
namespace {
// A stored value is "missing" if the path does not resolve to an entry.
// Note: default-constructed QJsonValue is Null, not Undefined.
bool isMissing(const QJsonValue& v) {
return v.isUndefined() || v.isNull();
}
QJsonValue valueAt(const QJsonObject& obj, const QStringList& path) {
QJsonObject cur = obj;
for (int i = 0; i < path.size() - 1; ++i) {
if (!cur.value(path.at(i)).isObject()) return QJsonValue::Undefined;
cur = cur.value(path.at(i)).toObject();
}
const QString key = path.last();
if (!cur.contains(key)) return QJsonValue::Undefined;
return cur.value(key);
}
QJsonObject placeAt(QJsonObject obj, QStringList path, const QJsonValue& value) {
const QString head = path.takeFirst();
if (path.isEmpty()) {
obj[head] = value;
return obj;
}
QJsonObject child = obj.value(head).isObject() ? obj.value(head).toObject()
: QJsonObject();
obj[head] = placeAt(child, path, value);
return obj;
}
// Removes `path` from `obj`. Returns true if a value was removed.
// Parent objects left empty are removed as well.
bool removeAt(QJsonObject& obj, const QStringList& path) {
const QString head = path.first();
if (path.size() == 1) {
if (!obj.contains(head)) return false;
obj.remove(head);
return true;
}
if (!obj.value(head).isObject()) return false;
QJsonObject child = obj.value(head).toObject();
const bool removed = removeAt(child, path.mid(1));
if (child.isEmpty())
obj.remove(head);
else
obj[head] = child;
return removed;
}
} // namespace
const QList<ConfigMigrationRule>& ConfigMigrations::rules() {
static const QList<ConfigMigrationRule> s_rules = {
migrate(
"general.color.scheduleHyprsunset", "display.nightlight.schedule"),
migrate(
"general.color.scheduleHyprsunsetStart",
"display.nightlight.scheduleStart"),
migrate(
"general.color.scheduleHyprsunsetEnd",
"display.nightlight.scheduleEnd"),
migrate("general.color.scheduleDark", "colors.scheduleDark"),
migrate("general.color.scheduleDarkStart", "colors.scheduleDarkStart"),
migrate("general.color.scheduleDarkEnd", "colors.scheduleDarkEnd"),
migrate("general.color.schemeGeneration", "colors.schemeGen"),
migrate(
"general.color.useNativeNightlight",
"display.nightlight.useNative"),
migrate(
"general.color.nativeFadeDuration",
"display.nightlight.fadeDuration"),
migrate("general.color.hyprsunsetTemp", "display.nightlight.temp"),
migrate("general.color.neovimColors", "colors.neovimColors"),
migrate("general.color.mode", "colors.mode"),
migrate("general.color.smart", "colors.smart"),
};
return s_rules;
}
QJsonObject ConfigMigrations::apply(const QJsonObject& json) {
QJsonObject obj = json;
for (const auto& rule : rules()) {
const QStringList from =
rule.from.split(QLatin1Char('.'), Qt::SkipEmptyParts);
const QStringList to =
rule.to.split(QLatin1Char('.'), Qt::SkipEmptyParts);
if (from.isEmpty() || to.isEmpty()) continue;
const QJsonValue oldValue = valueAt(obj, from);
if (isMissing(oldValue)) continue;
const bool targetExists = !isMissing(valueAt(obj, to));
if (!targetExists) obj = placeAt(obj, to, oldValue);
if (removeAt(obj, from)) {
qInfo() << "Config migration:" << rule.from << "->" << rule.to
<< (targetExists ? "(target already set, old value dropped)"
: "(value moved)");
}
}
return obj;
}
} // namespace ZShell::config
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <QJsonObject>
#include <QStringList>
namespace ZShell::config {
// A single migration rule: move the value stored at the dotted `from` path
// to the dotted `to` path, then delete the old key (and any parent objects
// left empty).
//
// Rules are applied to the raw JSON file on every load, before the schema
// loads it. They are idempotent: once the old key is gone the rule never
// fires again, so no version stamping is needed. Old rules can stay in the
// table forever.
//
// WARNING: the target path must exist in the schema (i.e. a ConfigObject
// property somewhere in the tree). If it does not, the migrated value is
// loaded as an unknown key and silently dropped on the next save.
struct ConfigMigrationRule {
QString from;
QString to;
};
// Helper for writing rules: migrate("general.color.foo", "display.bar.foo")
inline ConfigMigrationRule migrate(const QString& from, const QString& to) {
return {from, to};
}
class ConfigMigrations {
public:
[[nodiscard]] static const QList<ConfigMigrationRule>& rules();
// Applies all rules to `json`, returning the migrated object.
[[nodiscard]] static QJsonObject apply(const QJsonObject& json);
};
} // namespace ZShell::config
+12
View File
@@ -0,0 +1,12 @@
pkg_check_modules(HYPRLAND REQUIRED hyprland)
add_library(zshell-nightlight SHARED main.cpp)
target_compile_features(zshell-nightlight PRIVATE cxx_std_23)
target_include_directories(zshell-nightlight PRIVATE ${HYPRLAND_INCLUDE_DIRS})
target_link_directories(zshell-nightlight PRIVATE ${HYPRLAND_LIBRARY_DIRS})
target_compile_options(zshell-nightlight PRIVATE ${HYPRLAND_CFLAGS_OTHER})
target_compile_options(zshell-nightlight PRIVATE -fno-gnu-unique)
install(TARGETS zshell-nightlight LIBRARY DESTINATION "${INSTALL_LIBDIR}/plugins")
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <array>
#include <cmath>
#include <algorithm>
namespace ZShell::services {
inline std::array<float, 3> kelvinToGain(int kelvin) {
const double t = std::clamp(kelvin, 1000, 40000) / 100.0;
double r, g, b;
r = t <= 66.0
? 1.0
: std::clamp(
1.29293618606 * std::pow(t - 60.0, -0.1332047592), 0.0, 1.0);
g = t <= 66.0
? std::clamp(0.39008157876 * std::log(t) - 0.63184144378, 0.0, 1.0)
: std::clamp(
1.12989086089 * std::pow(t - 60.0, -0.0755148492), 0.0, 1.0);
b = t >= 66.0
? 1.0
: (t <= 19.0
? 0.0
: std::clamp(
0.54320678911 * std::log(t - 10.0) - 1.19625408914,
0.0,
1.0));
return {static_cast<float>(r), static_cast<float>(g), static_cast<float>(b)};
}
} // namespace ZShell::services
+447
View File
@@ -0,0 +1,447 @@
#include <any>
#include <atomic>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <mutex>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#define private public
#include <hyprland/src/plugins/PluginAPI.hpp>
#include <hyprland/src/render/OpenGL.hpp>
#include <hyprland/src/render/Renderer.hpp>
#undef private
#include <hyprland/src/managers/EventManager.hpp>
extern "C" {
#include <lua.h>
#include <lauxlib.h>
}
#include "colortemp.hpp"
using ZShell::services::kelvinToGain;
using Render::IFramebuffer;
using Render::GL::CHyprOpenGLImpl;
using Render::GL::g_pHyprOpenGL;
inline HANDLE PHANDLE = nullptr;
static std::filesystem::path g_runtimeDir{"/tmp"};
static std::filesystem::path g_cacheBase{"/tmp/.cache"};
static void initEnvPaths() {
if (const char* env = getenv("XDG_RUNTIME_DIR")) g_runtimeDir = env;
if (const char* env = getenv("XDG_CACHE_HOME")) {
if (*env) g_cacheBase = env;
} else if (const char* home = getenv("HOME")) {
g_cacheBase = std::filesystem::path(home) / ".cache";
}
}
static std::ofstream& debugLog() {
static std::ofstream log(
g_runtimeDir / "zshell-nightlight-debug.log", std::ios::app);
return log;
}
static std::mutex g_logMutex;
static std::atomic<uint64_t> g_logSeq{0};
static void dlog(const std::string& msg) {
std::lock_guard<std::mutex> lk(g_logMutex);
auto& log = debugLog();
log << "[" << g_logSeq++ << "][tid=" << std::this_thread::get_id() << "] "
<< msg << std::endl;
}
static constexpr int BAKED_STEPS = 24;
static std::string glslFloat(float v) {
std::ostringstream ss;
ss << std::fixed << std::setprecision(6) << v;
return ss.str();
}
static std::filesystem::path cacheDir() {
return g_cacheBase / "zshell" / "night-light";
}
static std::filesystem::path shaderPath() {
return cacheDir() / "nightlight.frag";
}
static std::filesystem::path bakedShaderPath(const std::array<float, 3>& gain) {
return cacheDir() /
("nightlight-" + glslFloat(gain[0]) + "-" + glslFloat(gain[1]) +
"-" + glslFloat(gain[2]) + ".frag");
}
static std::string defaultShaderSource() {
return "#version 100\n"
"precision highp float;\n"
"varying vec2 v_texcoord;\n"
"uniform sampler2D tex;\n"
"uniform vec3 tint;\n"
"void main() {\n"
" vec4 pixColor = texture2D(tex, v_texcoord);\n"
" gl_FragColor = vec4(pixColor.rgb * tint, pixColor.a);\n"
"}\n";
}
static std::string bakedShaderSource(const std::array<float, 3>& gain) {
std::ostringstream src;
src << "#version 100\n"
"precision highp float;\n"
"varying vec2 v_texcoord;\n"
"uniform sampler2D tex;\n"
"const vec3 GAIN = vec3("
<< glslFloat(gain[0]) << "," << glslFloat(gain[1]) << ","
<< glslFloat(gain[2])
<< ");\n"
"void main() {\n"
" vec4 pixColor = texture2D(tex, v_texcoord);\n"
" gl_FragColor = vec4(pixColor.rgb * GAIN, pixColor.a);\n"
"}\n";
return src.str();
}
static void ensureShaderFile(
const std::filesystem::path& path, const std::string& source) {
std::error_code ec;
if (std::filesystem::is_regular_file(path, ec)) return;
std::filesystem::create_directories(path.parent_path(), ec);
std::ofstream out(path, std::ios::trunc);
out << source;
}
static bool g_active = false;
static bool g_uniformMode = false;
static bool g_modeDecided = false;
static std::array<float, 3> g_currentGain{1.f, 1.f, 1.f};
static int g_currentKelvin = 6500;
static int g_lastKelvin = 6500;
struct STransition {
bool active = false;
std::chrono::steady_clock::time_point start;
float durationSec = 0.4f;
std::array<float, 3> fromGain{1.f, 1.f, 1.f};
std::array<float, 3> toGain{1.f, 1.f, 1.f};
int lastBakedStep = -1;
};
static STransition g_transition;
static void postStateEvent(bool enabled, bool animating);
static bool gainIsNeutral(const std::array<float, 3>& gain) {
for (float v : gain)
if (std::abs(v - 1.f) > 0.001f) return false;
return true;
}
static CFunctionHook* g_pBeginHook = nullptr;
using origBegin = void (*)(
CHyprOpenGLImpl*,
PHLMONITOR,
const CRegion&,
SP<IFramebuffer>,
std::optional<CRegion>);
static CFunctionHook* g_pSaveMirrorHook = nullptr;
using origSaveMirror = bool (*)(CHyprOpenGLImpl*, const CBox&);
static bool hkSaveBufferForMirror(CHyprOpenGLImpl* thisptr, const CBox& box) {
const bool saved = thisptr->m_applyFinalShader;
thisptr->m_applyFinalShader = false;
const bool result = (*reinterpret_cast<origSaveMirror>(
g_pSaveMirrorHook->m_original))(thisptr, box);
thisptr->m_applyFinalShader = saved;
return result;
}
static void installShader() {
auto* gl = g_pHyprOpenGL.get();
if (!gl) return;
ensureShaderFile(shaderPath(), defaultShaderSource());
gl->applyScreenShader(shaderPath().string());
auto* shader = gl->m_finalScreenShader.get();
if (shader && shader->program() >= 1 &&
shader->getUniformLocation(SHADER_TINT) != -1) {
g_uniformMode = true;
} else {
g_uniformMode = false;
if (!g_modeDecided)
dlog(
"shader has no 'tint' uniform, falling back to "
"per-step recompiled shaders");
}
g_modeDecided = true;
}
static void setUniformGain(const std::array<float, 3>& gain) {
auto* gl = g_pHyprOpenGL.get();
auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr;
if (!gl || !shader) return;
gl->useShader(gl->m_finalScreenShader);
shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]);
}
static void hkBegin(
CHyprOpenGLImpl* thisptr,
PHLMONITOR mon,
const CRegion& damage,
SP<IFramebuffer> fb,
std::optional<CRegion> finalDamage) {
(*reinterpret_cast<origBegin>(
g_pBeginHook->m_original))(thisptr, mon, damage, fb, finalDamage);
if (fb) {
auto* renderer = g_pHyprRenderer.get();
if (renderer) renderer->m_renderData.blockScreenShader = true;
}
if (!g_active) return;
auto* gl = g_pHyprOpenGL.get();
if (!gl) return;
if (gl->m_finalScreenShader.get() && gl->m_finalScreenShader->program() < 1)
installShader();
if (!gl->m_finalScreenShader.get() ||
gl->m_finalScreenShader->program() < 1)
return;
if (g_transition.active) {
auto* monPtr = mon.get();
if (!monPtr) return;
const float elapsed =
std::chrono::duration<float>(
std::chrono::steady_clock::now() - g_transition.start)
.count();
const float t =
std::clamp(elapsed / g_transition.durationSec, 0.f, 1.f);
for (size_t i = 0; i < 3; ++i)
g_currentGain[i] =
g_transition.fromGain[i] +
(g_transition.toGain[i] - g_transition.fromGain[i]) * t;
if (g_uniformMode) {
setUniformGain(g_currentGain);
} else {
const int step = static_cast<int>(t * BAKED_STEPS);
if (step != g_transition.lastBakedStep) {
g_transition.lastBakedStep = step;
ensureShaderFile(
bakedShaderPath(g_currentGain),
bakedShaderSource(g_currentGain));
gl->applyScreenShader(bakedShaderPath(g_currentGain).string());
}
}
if (t >= 1.f) {
g_transition.active = false;
if (gainIsNeutral(g_currentGain)) {
g_active = false;
gl->applyScreenShader("");
}
postStateEvent(g_active, false);
} else {
monPtr->m_forceFullFrames = 2;
monPtr->scheduleFrame();
}
} else if (g_uniformMode) {
setUniformGain(g_currentGain);
}
}
static constexpr int KELVIN_MIN = 2000;
static constexpr int KELVIN_MAX = 6500;
static int luaNightlightSet(lua_State* L) {
const int rawKelvin = static_cast<int>(luaL_checknumber(L, 1));
const float durationSec =
lua_gettop(L) >= 2 ? static_cast<float>(luaL_checknumber(L, 2)) : 0.4f;
const int targetKelvin = std::clamp(rawKelvin, KELVIN_MIN, KELVIN_MAX);
if (targetKelvin != rawKelvin)
dlog(
"clamped " + std::to_string(rawKelvin) + "K to " +
std::to_string(targetKelvin) + "K");
g_transition.fromGain = g_currentGain;
g_transition.toGain = kelvinToGain(targetKelvin);
g_transition.active = true;
g_transition.start = std::chrono::steady_clock::now();
g_transition.durationSec = durationSec;
g_transition.lastBakedStep = -1;
g_active = true;
g_lastKelvin = g_currentKelvin;
g_currentKelvin = targetKelvin;
postStateEvent(true, true);
lua_pushinteger(L, targetKelvin);
return 1;
}
static int luaNightlightDisable(lua_State* L) {
if (!g_active) return 0;
const float durationSec =
lua_gettop(L) >= 1 ? static_cast<float>(luaL_checknumber(L, 1)) : 0.4f;
g_transition.fromGain = g_currentGain;
g_transition.toGain = {1.f, 1.f, 1.f};
g_transition.active = true;
g_transition.start = std::chrono::steady_clock::now();
g_transition.durationSec = durationSec;
g_transition.lastBakedStep = -1;
g_lastKelvin = g_currentKelvin;
g_currentKelvin = 6500;
postStateEvent(false, true);
return 0;
}
static int luaNightlightState(lua_State* L) {
lua_pushstring(
L,
std::format(
"{{\"enabled\":{},\"animating\":{},\"last\":{},\"current\":{}}}",
g_active ? "true" : "false",
g_transition.active ? "true" : "false",
g_lastKelvin,
g_currentKelvin)
.c_str());
return 1;
}
static void postStateEvent(bool enabled, bool animating) {
if (!g_pEventManager) return;
g_pEventManager->postEvent(
SHyprIPCEvent{
.event = "zshell-nightlight",
.data = std::format(
"{},{},{},{}",
enabled ? 1 : 0,
animating ? 1 : 0,
g_lastKelvin,
g_currentKelvin)});
}
static const SFunctionMatch* findExactMethod(
const std::vector<SFunctionMatch>& methods,
const std::string& classAndMethod) {
for (const auto& m : methods) {
if (m.signature.find(classAndMethod) != std::string::npos) return &m;
}
return nullptr;
}
static std::string manglePrefix(
const std::string& className, const std::string& methodName) {
return std::to_string(className.size()) + className +
std::to_string(methodName.size()) + methodName;
}
APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) {
PHANDLE = handle;
initEnvPaths();
dlog("=== PLUGIN_INIT, build timestamp " __DATE__ " " __TIME__ " ===");
const std::string serverHash = __hyprland_api_get_hash();
const std::string clientHash = __hyprland_api_get_client_hash();
if (serverHash != clientHash) {
HyprlandAPI::addNotification(
PHANDLE,
"[zshell-nightlight] Mismatched headers! Can't proceed.",
CHyprColor{1.0f, 0.2f, 0.2f, 1.0f},
5000);
throw std::runtime_error("[zshell-nightlight] version mismatch");
}
static const auto SAVEMIRROR_METHODS =
HyprlandAPI::findFunctionsByName(PHANDLE, "saveBufferForMirror");
const SFunctionMatch* saveMirrorTarget = findExactMethod(
SAVEMIRROR_METHODS,
manglePrefix("CHyprOpenGLImpl", "saveBufferForMirror"));
if (saveMirrorTarget) {
g_pSaveMirrorHook = HyprlandAPI::createFunctionHook(
PHANDLE,
saveMirrorTarget->address,
reinterpret_cast<void*>(&hkSaveBufferForMirror));
if (g_pSaveMirrorHook && g_pSaveMirrorHook->hook())
dlog(
"saveBufferForMirror hook installed: " +
saveMirrorTarget->signature);
}
if (!g_pSaveMirrorHook)
HyprlandAPI::addNotification(
PHANDLE,
"[zshell-nightlight] WARNING: couldn't hook saveBufferForMirror — "
"captures may still show the nightlight",
CHyprColor{1.f, 0.6f, 0.2f, 1.f},
8000);
static const auto METHODS =
HyprlandAPI::findFunctionsByName(PHANDLE, "begin");
const SFunctionMatch* target =
findExactMethod(METHODS, manglePrefix("CHyprOpenGLImpl", "begin"));
if (!target)
throw std::runtime_error(
"[zshell-nightlight] couldn't uniquely resolve "
"CHyprOpenGLImpl::begin");
g_pBeginHook = HyprlandAPI::createFunctionHook(
PHANDLE, target->address, reinterpret_cast<void*>(&hkBegin));
if (!g_pBeginHook || !g_pBeginHook->hook())
throw std::runtime_error("[zshell-nightlight] failed to hook begin()");
if (!HyprlandAPI::addLuaFunction(
PHANDLE, "zshell", "nlSet", luaNightlightSet))
throw std::runtime_error(
"[zshell-nightlight] failed to register Lua function");
if (!HyprlandAPI::addLuaFunction(
PHANDLE, "zshell", "nlDisable", luaNightlightDisable))
throw std::runtime_error(
"[zshell-nightlight] failed to register Lua function");
if (!HyprlandAPI::addLuaFunction(
PHANDLE, "zshell", "nlState", luaNightlightState))
throw std::runtime_error(
"[zshell-nightlight] failed to register Lua function");
return {
"zshell-nightlight",
"Animated nightlight, excluded from screencopy",
"ZShell",
"1.2"};
}
APICALL EXPORT std::string PLUGIN_API_VERSION() {
return HYPRLAND_API_VERSION;
}
APICALL EXPORT void PLUGIN_EXIT() {}
+1
View File
@@ -288,6 +288,7 @@ qml_module(ZShell-llm
chat.hpp chat.cpp
chatstore.hpp chatstore.cpp
codehighlighter.hpp codehighlighter.cpp
filetool.hpp filetool.cpp
generation.hpp generation.cpp
llmclient.hpp llmclient.cpp
markdownblock.hpp
+16 -18
View File
@@ -1,6 +1,7 @@
#include "chat.hpp"
#include "config.hpp"
#include "filetool.hpp"
#include "llm.hpp"
#include "llmclient.hpp"
#include "webfetchtool.hpp"
@@ -10,12 +11,14 @@
namespace ZShell::llm {
Chat::Chat(QObject* parent)
: QObject(parent), m_store(new ChatStore(this)), m_client(new LlmClient(this)) {
if (!config::Config::instance())
new config::Config();
: QObject(parent)
, m_store(new ChatStore(this))
, m_client(new LlmClient(this)) {
if (!config::Config::instance()) new config::Config();
m_store->setLlmClient(m_client);
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
m_client->tools()->registerTool(new FileReadTool(m_client->tools()));
const auto* llm = config::Config::instance()->llm();
m_client->setEndpoint(llm->endpoint());
@@ -44,10 +47,8 @@ Chat::Chat(QObject* parent)
}
Q_EMIT busyChanged();
});
connect(
m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged);
connect(
m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
connect(m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged);
connect(m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
connect(
m_client,
&LlmClient::availableModelsChanged,
@@ -88,7 +89,7 @@ Chat::Chat(QObject* parent)
this,
[this](ChatSession* session, const QString& title) {
qInfo() << "Chat: applying generated title" << session->id()
<< title << "(was" << session->title() << ")";
<< title << "(was" << session->title() << ")";
session->setTitle(title);
m_store->saveMeta(session);
});
@@ -97,8 +98,8 @@ Chat::Chat(QObject* parent)
&LlmClient::iconSuggested,
this,
[this](ChatSession* session, const QString& icon) {
qInfo() << "Chat: applying generated icon" << session->id()
<< icon << "(was" << session->icon() << ")";
qInfo() << "Chat: applying generated icon" << session->id() << icon
<< "(was" << session->icon() << ")";
session->setIcon(icon);
m_store->saveMeta(session);
});
@@ -138,11 +139,11 @@ QString Chat::streamingChatId() const {
return m_client->streamingChatId();
}
Chat* Chat::s_instance = nullptr;
Chat* Chat::create(QQmlEngine*, QJSEngine*) {
if (!s_instance)
s_instance = new Chat();
if (!s_instance) s_instance = new Chat();
return s_instance;
}
@@ -151,8 +152,7 @@ void Chat::stop() {
}
void Chat::dismissError() {
if (m_lastError.isEmpty())
return;
if (m_lastError.isEmpty()) return;
m_lastError.clear();
Q_EMIT lastErrorChanged();
}
@@ -162,11 +162,9 @@ void Chat::refreshModels() {
}
void Chat::selectModel(const QString& id) {
if (id.isEmpty())
return;
if (id.isEmpty()) return;
m_client->setModel(id);
if (auto* config = config::Config::instance())
config->llm()->set_model(id);
if (auto* config = config::Config::instance()) config->llm()->set_model(id);
}
} // namespace ZShell::llm
+9 -3
View File
@@ -25,12 +25,18 @@ class Chat : public QObject {
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged)
Q_PROPERTY(QString model READ model NOTIFY modelChanged)
Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged)
Q_PROPERTY(
QStringList availableModels READ availableModels NOTIFY
availableModelsChanged)
Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged)
Q_PROPERTY(bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY toolsEnabledChanged)
Q_PROPERTY(
bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY
toolsEnabledChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT)
Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged)
Q_PROPERTY(
QString streamingChatId READ streamingChatId NOTIFY
streamingChatIdChanged)
public:
explicit Chat(QObject* parent = nullptr);
+245 -298
View File
@@ -37,24 +37,16 @@ QString segmentTypeName(LlmSegment::Type type) {
}
LlmSegment::Type segmentTypeFromName(const QString& name) {
if (name == QLatin1String("tool_call"))
return LlmSegment::Type::ToolCall;
if (name == QLatin1String("content"))
return LlmSegment::Type::Content;
if (name == QLatin1String("tool_call")) return LlmSegment::Type::ToolCall;
if (name == QLatin1String("content")) return LlmSegment::Type::Content;
return LlmSegment::Type::Reasoning;
}
// A null QString binds as SQL NULL, which violates the NOT NULL columns;
// DEFAULT only applies to omitted columns, not explicit NULLs.
QString sqlText(const QString& value) {
if (value.isNull())
return QStringLiteral("");
if (value.isNull()) return QStringLiteral("");
return value;
}
// Plain data for one session's messages, fetched on a worker thread
// and turned into the QObject tree on the GUI thread. Messages are
// ordered as the model displays them (newest first).
struct SegmentRow {
QString type;
QString text;
@@ -82,14 +74,13 @@ struct MessageRow {
} // namespace
ChatStore::ChatStore(QObject* parent)
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
openDb();
load();
}
ChatStore::~ChatStore() {
if (m_connectionName.isEmpty())
return;
if (m_connectionName.isEmpty()) return;
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
db.close();
QSqlDatabase::removeDatabase(m_connectionName);
@@ -110,7 +101,7 @@ void ChatStore::openDb() {
db.setDatabaseName(m_dbPath);
if (!db.open()) {
qWarning() << "ChatStore: failed to open database" << m_dbPath << ":"
<< db.lastError().text();
<< db.lastError().text();
return;
}
{
@@ -119,59 +110,55 @@ void ChatStore::openDb() {
}
{
QSqlQuery query(db);
query.exec(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS sessions (\n"
" id TEXT PRIMARY KEY,\n"
" title TEXT NOT NULL DEFAULT '',\n"
" icon TEXT NOT NULL DEFAULT '',\n"
" created_at INTEGER NOT NULL,\n"
" updated_at INTEGER NOT NULL,\n"
" pinned INTEGER NOT NULL DEFAULT 0\n"
")"));
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS sessions (\n"
" id TEXT PRIMARY KEY,\n"
" title TEXT NOT NULL DEFAULT '',\n"
" icon TEXT NOT NULL DEFAULT '',\n"
" created_at INTEGER NOT NULL,\n"
" updated_at INTEGER NOT NULL,\n"
" pinned INTEGER NOT NULL DEFAULT 0\n"
")"));
}
{
QSqlQuery query(db);
query.exec(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS messages (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" session_id TEXT NOT NULL REFERENCES sessions (id) "
"ON DELETE CASCADE,\n"
" role TEXT NOT NULL,\n"
" timestamp INTEGER NOT NULL\n"
")"));
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS messages (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" session_id TEXT NOT NULL REFERENCES sessions (id) "
"ON DELETE CASCADE,\n"
" role TEXT NOT NULL,\n"
" timestamp INTEGER NOT NULL\n"
")"));
}
{
QSqlQuery query(db);
query.exec(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS generations (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" message_id INTEGER NOT NULL REFERENCES messages "
"(id) ON DELETE CASCADE,\n"
" timestamp INTEGER NOT NULL,\n"
" is_active INTEGER NOT NULL DEFAULT 1\n"
")"));
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS generations (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" message_id INTEGER NOT NULL REFERENCES messages "
"(id) ON DELETE CASCADE,\n"
" timestamp INTEGER NOT NULL,\n"
" is_active INTEGER NOT NULL DEFAULT 1\n"
")"));
}
{
QSqlQuery query(db);
query.exec(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS segments (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" generation_id INTEGER NOT NULL REFERENCES generations "
"(id) ON DELETE CASCADE,\n"
" type TEXT NOT NULL,\n"
" text TEXT NOT NULL DEFAULT '',\n"
" name TEXT NOT NULL DEFAULT '',\n"
" tool_call_id TEXT NOT NULL DEFAULT '',\n"
" arguments TEXT NOT NULL DEFAULT '',\n"
" result TEXT NOT NULL DEFAULT '',\n"
" status INTEGER NOT NULL DEFAULT 0,\n"
" elapsed_ms INTEGER NOT NULL DEFAULT 0,\n"
" timestamp INTEGER NOT NULL\n"
")"));
query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS segments (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" generation_id INTEGER NOT NULL REFERENCES generations "
"(id) ON DELETE CASCADE,\n"
" type TEXT NOT NULL,\n"
" text TEXT NOT NULL DEFAULT '',\n"
" name TEXT NOT NULL DEFAULT '',\n"
" tool_call_id TEXT NOT NULL DEFAULT '',\n"
" arguments TEXT NOT NULL DEFAULT '',\n"
" result TEXT NOT NULL DEFAULT '',\n"
" status INTEGER NOT NULL DEFAULT 0,\n"
" elapsed_ms INTEGER NOT NULL DEFAULT 0,\n"
" timestamp INTEGER NOT NULL\n"
")"));
}
{
QSqlQuery query(db);
@@ -200,8 +187,7 @@ QVariantList ChatStore::values() const {
}
ChatSession* ChatStore::at(int index) const {
if (index < 0 || index >= m_sessions.size())
return nullptr;
if (index < 0 || index >= m_sessions.size()) return nullptr;
return m_sessions.at(index);
}
@@ -218,7 +204,7 @@ ChatSession* ChatStore::insert(int index) {
query.bindValue(":updated_at", now);
if (!query.exec())
qWarning() << "ChatStore: failed to insert session" << id << ":"
<< query.lastError().text();
<< query.lastError().text();
}
auto* session = new ChatSession(id, this);
session->setMeta(QString(), now, now, 0);
@@ -238,8 +224,7 @@ void ChatStore::remove(ChatSession* chat) {
}
void ChatStore::removeSession(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
if (!session || !m_sessions.contains(session)) return;
const QList<ChatSession*> before = m_sessions;
Q_EMIT sessionRemoved(session);
{
@@ -255,7 +240,7 @@ void ChatStore::removeSession(ChatSession* session) {
void ChatStore::move(int from, int to) {
if (from < 0 || from >= m_sessions.size() || to < 0 ||
to >= m_sessions.size() || from == to)
to >= m_sessions.size() || from == to)
return;
m_sessions.move(from, to);
Q_EMIT valuesChanged();
@@ -269,8 +254,7 @@ void ChatStore::clear() {
ChatSession* ChatStore::sessionById(const QString& id) {
for (auto* session : m_sessions)
if (session->id() == id)
return session;
if (session->id() == id) return session;
return nullptr;
}
@@ -279,32 +263,28 @@ void ChatStore::setLlmClient(LlmClient* client) {
}
void ChatStore::persist(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
if (!session || !m_sessions.contains(session)) return;
if (!session->isLoaded()) {
// Saving now would persist an incomplete model and wipe the
// stored history; run it again when the load lands.
m_pendingPersists.insert(session);
return;
}
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
if (!saveSession(session))
return;
if (!saveSession(session)) return;
sortAndNotify();
}
void ChatStore::saveMeta(ChatSession* session) {
if (!session || !m_sessions.contains(session))
return;
if (!session || !m_sessions.contains(session)) return;
QSqlQuery query(db());
query.prepare("UPDATE sessions SET title = :title, icon = :icon "
"WHERE id = :id");
query.prepare(
"UPDATE sessions SET title = :title, icon = :icon "
"WHERE id = :id");
query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":id", session->id());
if (!query.exec())
qWarning() << "ChatStore: failed to save meta for" << session->id()
<< ":" << query.lastError().text();
<< ":" << query.lastError().text();
}
bool ChatStore::saveSession(ChatSession* session) {
@@ -312,7 +292,7 @@ bool ChatStore::saveSession(ChatSession* session) {
QSqlDatabase handle = db();
if (!handle.transaction()) {
qWarning() << "ChatStore: failed to begin transaction:"
<< handle.lastError().text();
<< handle.lastError().text();
return false;
}
bool ok = true;
@@ -338,18 +318,18 @@ bool ChatStore::saveSession(ChatSession* session) {
"INSERT INTO messages (session_id, role, timestamp) "
"VALUES (:id, :role, :timestamp)");
QSqlQuery generationInsert(handle);
ok = ok && generationInsert.prepare(
"INSERT INTO generations (message_id, timestamp, is_active) "
"VALUES (:mid, :timestamp, :is_active)");
ok = ok &&
generationInsert.prepare(
"INSERT INTO generations (message_id, timestamp, is_active) "
"VALUES (:mid, :timestamp, :is_active)");
QSqlQuery segmentInsert(handle);
ok = ok && segmentInsert.prepare(
"INSERT INTO segments (generation_id, type, text, name, "
"tool_call_id, arguments, result, status, elapsed_ms, "
"timestamp) VALUES (:gid, :type, :text, :name, "
":tool_call_id, :arguments, :result, :status, :elapsed_ms, "
":timestamp)");
// The model holds messages most recent first; the database keeps
// natural rowid order, so iterate from the oldest row up.
ok = ok &&
segmentInsert.prepare(
"INSERT INTO segments (generation_id, type, text, name, "
"tool_call_id, arguments, result, status, elapsed_ms, "
"timestamp) VALUES (:gid, :type, :text, :name, "
":tool_call_id, :arguments, :result, :status, :elapsed_ms, "
":timestamp)");
const auto* model = session->messagesModel();
for (int row = model->rowCount() - 1; ok && row >= 0; --row) {
const auto* message = model->at(row);
@@ -363,8 +343,8 @@ bool ChatStore::saveSession(ChatSession* session) {
if (!messageInsert.exec()) {
ok = false;
qWarning() << "ChatStore: saveSession" << id
<< "message insert failed:"
<< messageInsert.lastError().text();
<< "message insert failed:"
<< messageInsert.lastError().text();
break;
}
const int messageId = messageInsert.lastInsertId().toInt();
@@ -379,8 +359,8 @@ bool ChatStore::saveSession(ChatSession* session) {
if (!generationInsert.exec()) {
ok = false;
qWarning() << "ChatStore: saveSession" << id
<< "generation insert failed:"
<< generationInsert.lastError().text();
<< "generation insert failed:"
<< generationInsert.lastError().text();
break;
}
const int generationId =
@@ -395,18 +375,17 @@ bool ChatStore::saveSession(ChatSession* session) {
":tool_call_id", sqlText(segment->toolCallId()));
segmentInsert.bindValue(
":arguments", sqlText(segment->arguments()));
segmentInsert.bindValue(":result", sqlText(segment->result()));
segmentInsert.bindValue(
":result", sqlText(segment->result()));
segmentInsert.bindValue(
":status", static_cast<int>(segment->status()));
segmentInsert.bindValue(
":elapsed_ms", segment->elapsedMs());
segmentInsert.bindValue(
":timestamp", segment->timestamp());
segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs());
segmentInsert.bindValue(":timestamp", segment->timestamp());
if (!segmentInsert.exec()) {
ok = false;
qWarning() << "ChatStore: saveSession" << id
<< "segment insert failed:"
<< segmentInsert.lastError().text();
<< "segment insert failed:"
<< segmentInsert.lastError().text();
break;
}
}
@@ -414,211 +393,183 @@ bool ChatStore::saveSession(ChatSession* session) {
}
}
if (!ok || !handle.commit()) {
qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back";
qWarning() << "ChatStore: saveSession" << id
<< "commit failed, rolling back";
handle.rollback();
ok = false;
qWarning() << "ChatStore: failed to save session" << session->id() << ":"
<< handle.lastError().text();
qWarning() << "ChatStore: failed to save session" << session->id()
<< ":" << handle.lastError().text();
}
return ok;
}
void ChatStore::loadMessagesInto(ChatSession* session) {
if (!session)
return;
if (!session) return;
const QString sessionId = session->id();
const QString path = m_dbPath;
// SQL on a worker thread (its own connection; QSqlDatabase objects
// are thread-affine). Rows come back as plain data.
QThreadPool::globalInstance()->start(
[store = QPointer<ChatStore>(this),
session = QPointer<ChatSession>(session), sessionId, path]() {
QList<MessageRow> rows;
const QString connName = QUuid::createUuid().toString();
{
QSqlDatabase db = QSqlDatabase::addDatabase(
QStringLiteral("QSQLITE"), connName);
db.setDatabaseName(path);
if (db.open()) {
// Tolerate the GUI thread writing while we read.
QSqlQuery busy(db);
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
// Newest first so the model receives rows in
// display order.
QSqlQuery query(db);
query.prepare(
"SELECT id, role, timestamp FROM messages "
"WHERE session_id = :id ORDER BY rowid DESC");
query.bindValue(":id", sessionId);
if (!query.exec()) {
qWarning()
<< "ChatStore: failed to load messages for"
<< sessionId << ":"
<< query.lastError().text();
} else {
while (query.next()) {
const int messageId = query.value(0).toInt();
MessageRow message;
message.user =
query.value(1).toString() ==
QLatin1String("user");
message.timestamp = query.value(2).toLongLong();
QSqlQuery generationQuery(db);
generationQuery.prepare(
"SELECT id, timestamp, is_active FROM "
"generations WHERE message_id = :mid "
"ORDER BY rowid");
generationQuery.bindValue(":mid", messageId);
if (generationQuery.exec()) {
while (generationQuery.next()) {
GenerationRow generation;
generation.timestamp =
generationQuery.value(1).toLongLong();
generation.active =
generationQuery.value(2).toInt() != 0;
QSqlQuery segmentQuery(db);
segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, "
"arguments, result, status, elapsed_ms, "
"timestamp FROM segments WHERE "
"generation_id = :gid ORDER BY rowid");
segmentQuery.bindValue(
":gid",
generationQuery.value(0).toInt());
if (segmentQuery.exec()) {
while (segmentQuery.next()) {
SegmentRow segment;
segment.type =
segmentQuery.value(0).toString();
segment.text =
segmentQuery.value(1).toString();
segment.name =
segmentQuery.value(2).toString();
segment.toolCallId =
segmentQuery.value(3).toString();
segment.arguments =
segmentQuery.value(4).toString();
segment.result =
segmentQuery.value(5).toString();
segment.status =
segmentQuery.value(6).toInt();
segment.elapsedMs =
segmentQuery.value(7).toLongLong();
segment.timestamp =
segmentQuery.value(8).toLongLong();
generation.segments.append(segment);
}
} else {
qWarning()
<< "ChatStore: failed to load "
"segments for generation"
<< generationQuery.value(0).toInt()
<< ":"
<< segmentQuery.lastError().text();
}
message.generations.append(generation);
}
} else {
qWarning()
<< "ChatStore: failed to load generations "
"for message"
<< messageId << ":"
<< generationQuery.lastError().text();
}
rows.append(message);
}
}
db.close();
QThreadPool::globalInstance()->start([store = QPointer<ChatStore>(this),
session =
QPointer<ChatSession>(session),
sessionId,
path]() {
QList<MessageRow> rows;
const QString connName = QUuid::createUuid().toString();
{
QSqlDatabase db =
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName);
db.setDatabaseName(path);
if (db.open()) {
QSqlQuery busy(db);
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
QSqlQuery query(db);
query.prepare(
"SELECT id, role, timestamp FROM messages "
"WHERE session_id = :id ORDER BY rowid DESC");
query.bindValue(":id", sessionId);
if (!query.exec()) {
qWarning() << "ChatStore: failed to load messages for"
<< sessionId << ":" << query.lastError().text();
} else {
qWarning()
<< "ChatStore: failed to open database for load:"
<< db.lastError().text();
}
}
// Remove only once every QSqlDatabase copy and query is gone;
// while any reference is alive Qt refuses the removal and the
// connection is left dangling in a broken state.
QSqlDatabase::removeDatabase(connName);
// Build the object tree on the GUI thread. Deliver through
// the app instance (never destroyed) and re-check the
// pointers there: posting to `store` from the pool thread
// would race with its destruction.
QMetaObject::invokeMethod(
QCoreApplication::instance(),
[store, session, rows = std::move(rows)]() mutable {
ChatStore* st = store;
ChatSession* s = session;
if (!st || !s)
return;
// Rows fetched from disk; newest first.
auto* model = s->model();
if (!model)
return;
QList<ChatMessage*> messages;
for (const MessageRow& row : rows) {
auto* message = model->createMessage(
row.user ? ChatMessage::Role::User
: ChatMessage::Role::Assistant,
row.timestamp);
int activeIndex = 0;
for (int i = 0; i < row.generations.size(); ++i) {
const GenerationRow& generationRow =
row.generations.at(i);
auto* generation =
message->addGeneration(generationRow.timestamp);
for (const SegmentRow& segmentRow :
generationRow.segments) {
auto* segment = new LlmSegment(
segmentTypeFromName(segmentRow.type),
segmentRow.timestamp,
generation);
segment->setText(segmentRow.text);
segment->setName(segmentRow.name);
segment->setToolCallId(segmentRow.toolCallId);
segment->appendArguments(segmentRow.arguments);
segment->setResult(segmentRow.result);
segment->setStatus(
static_cast<LlmSegment::Status>(
segmentRow.status));
segment->restore(segmentRow.elapsedMs);
generation->addSegment(segment);
while (query.next()) {
const int messageId = query.value(0).toInt();
MessageRow message;
message.user = query.value(1).toString() ==
QLatin1String("user");
message.timestamp = query.value(2).toLongLong();
QSqlQuery generationQuery(db);
generationQuery.prepare(
"SELECT id, timestamp, is_active FROM "
"generations WHERE message_id = :mid "
"ORDER BY rowid");
generationQuery.bindValue(":mid", messageId);
if (generationQuery.exec()) {
while (generationQuery.next()) {
GenerationRow generation;
generation.timestamp =
generationQuery.value(1).toLongLong();
generation.active =
generationQuery.value(2).toInt() != 0;
QSqlQuery segmentQuery(db);
segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, "
"arguments, result, status, elapsed_ms, "
"timestamp FROM segments WHERE "
"generation_id = :gid ORDER BY rowid");
segmentQuery.bindValue(
":gid", generationQuery.value(0).toInt());
if (segmentQuery.exec()) {
while (segmentQuery.next()) {
SegmentRow segment;
segment.type =
segmentQuery.value(0).toString();
segment.text =
segmentQuery.value(1).toString();
segment.name =
segmentQuery.value(2).toString();
segment.toolCallId =
segmentQuery.value(3).toString();
segment.arguments =
segmentQuery.value(4).toString();
segment.result =
segmentQuery.value(5).toString();
segment.status =
segmentQuery.value(6).toInt();
segment.elapsedMs =
segmentQuery.value(7).toLongLong();
segment.timestamp =
segmentQuery.value(8).toLongLong();
generation.segments.append(segment);
}
} else {
qWarning()
<< "ChatStore: failed to load "
"segments for generation"
<< generationQuery.value(0).toInt()
<< ":"
<< segmentQuery.lastError().text();
}
message.generations.append(generation);
}
if (generationRow.active)
activeIndex = i;
} else {
qWarning()
<< "ChatStore: failed to load generations "
"for message"
<< messageId << ":"
<< generationQuery.lastError().text();
}
message->setActiveGeneration(activeIndex);
messages.append(message);
rows.append(message);
}
// Rows added live while the load was in flight are
// newer than anything on disk; keep them in front.
if (model->rowCount() > 0) {
QList<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);
}
db.close();
} else {
qWarning() << "ChatStore: failed to open database for load:"
<< db.lastError().text();
}
}
QSqlDatabase::removeDatabase(connName);
// Mark loaded only once the model holds both the
// fetched history and the rows added live while the
// load ran, so a deferred startGeneration (triggered
// by loaded()) builds its context from the complete
// conversation.
s->markLoaded();
QMetaObject::invokeMethod(
QCoreApplication::instance(),
[store, session, rows = std::move(rows)]() mutable {
ChatStore* st = store;
ChatSession* s = session;
if (!st || !s) return;
if (s->takeClearPending()) {
// Cleared while the load was in flight; drop
// everything now that the model is populated.
s->clear();
} else if (st->m_pendingPersists.remove(s)) {
st->persist(s);
auto* model = s->model();
if (!model) return;
QList<ChatMessage*> messages;
for (const MessageRow& row : rows) {
auto* message = model->createMessage(
row.user ? ChatMessage::Role::User
: ChatMessage::Role::Assistant,
row.timestamp);
int activeIndex = 0;
for (int i = 0; i < row.generations.size(); ++i) {
const GenerationRow& generationRow =
row.generations.at(i);
auto* generation =
message->addGeneration(generationRow.timestamp);
for (const SegmentRow& segmentRow :
generationRow.segments) {
auto* segment = new LlmSegment(
segmentTypeFromName(segmentRow.type),
segmentRow.timestamp,
generation);
segment->setText(segmentRow.text);
segment->setName(segmentRow.name);
segment->setToolCallId(segmentRow.toolCallId);
segment->appendArguments(segmentRow.arguments);
segment->setResult(segmentRow.result);
segment->setStatus(
static_cast<LlmSegment::Status>(
segmentRow.status));
segment->restore(segmentRow.elapsedMs);
generation->addSegment(segment);
}
if (generationRow.active) activeIndex = i;
}
},
Qt::QueuedConnection);
});
message->setActiveGeneration(activeIndex);
messages.append(message);
}
if (model->rowCount() > 0) {
QList<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() {
@@ -647,22 +598,18 @@ void ChatStore::sortAndNotify() {
m_sessions.begin(),
m_sessions.end(),
[](const ChatSession* a, const ChatSession* b) {
if (a->pinned() != b->pinned())
return a->pinned() > b->pinned();
if (a->pinned() != b->pinned()) return a->pinned() > b->pinned();
return a->updatedAtMs() > b->updatedAtMs();
});
notify(before);
}
void ChatStore::notify(const QList<ChatSession*>& before) {
if (before.size() != m_sessions.size())
Q_EMIT countChanged();
if (before.size() != m_sessions.size()) Q_EMIT countChanged();
bool same = before.size() == m_sessions.size();
for (int i = 0; same && i < m_sessions.size(); ++i)
if (before.at(i) != m_sessions.at(i))
same = false;
if (!same)
Q_EMIT valuesChanged();
if (before.at(i) != m_sessions.at(i)) same = false;
if (!same) Q_EMIT valuesChanged();
}
} // namespace ZShell::llm
-5
View File
@@ -39,9 +39,6 @@ class ChatStore : public QObject {
void persist(ChatSession* session);
void saveMeta(ChatSession* session);
// Loads the session's messages from the database. The SQL runs on a
// worker thread; the object tree is built and the model updated on
// the GUI thread when it arrives (ChatSession::loaded).
void loadMessagesInto(ChatSession* session);
Q_SIGNALS:
@@ -61,8 +58,6 @@ class ChatStore : public QObject {
LlmClient* m_llmClient = nullptr;
QString m_connectionName;
QString m_dbPath;
// Sessions whose persist() ran before their messages finished
// loading; persisted once the load lands.
QSet<ChatSession*> m_pendingPersists;
[[nodiscard]] QSqlDatabase db() const;
-1
View File
@@ -213,7 +213,6 @@ QVariantList CodeHighlighter::lookupSpans(
QMutexLocker locker(&m_cacheMutex);
const auto it = m_spanCache.constFind(key);
if (it == m_spanCache.constEnd() || it->code != code) return {};
// Most recently used; eviction drops the oldest entries first.
const qsizetype pos = m_spanCacheOrder.indexOf(key);
if (pos >= 0) m_spanCacheOrder.move(pos, m_spanCacheOrder.size() - 1);
return it->spans;
+16 -51
View File
@@ -15,38 +15,6 @@ class QJSEngine;
namespace ZShell::llm {
// Syntax highlighting for LLM code blocks via tree-sitter.
//
// The tree-sitter runtime is linked. Grammar libraries are dlopen()'d
// lazily, so a missing grammar degrades that language to plain text
// instead of breaking the build or the app. At configure time CMake
// discovers installed grammars (system packages plus the parsers
// Neovim's nvim-treesitter installs) and pairs each with highlight
// queries (vendored, Neovim's, or fetched from
// tree-sitter/highlighting); both are embedded in the generated
// highlight-queries.hpp.
//
// For each grammar the first loadable (ABI-compatible) library wins
// and the first query that compiles against it wins, so a version
// skew between a library and its query degrades gracefully.
//
// The fence language the LLM wrote (```cpp, ```python, ...) is mapped
// to a grammar through an alias table; tags not in the table are used
// as grammar ids as-is.
//
// highlight() parses the code off the GUI thread and delivers a list of
// span maps by calling target's "onHighlightSpans(token, spans)" method
// (on the GUI thread):
// { "start": int, "length": int, "kind": QString }
// where kind is a semantic role (keyword, string, comment, number,
// function, type, ...) that QML maps to theme colors. An empty list
// means "no highlighting" (unknown language or grammar not installed).
// token is passed back unchanged so the caller can drop results for
// superseded code; a destroyed target is simply skipped.
//
// Successful results are cached by (language, code). A request for
// unchanged code delivers the cached spans directly, without re-parsing
// — while a segment streams, only the grown tail is ever re-parsed.
class CodeHighlighter : public QObject {
Q_OBJECT
QML_ELEMENT
@@ -54,17 +22,16 @@ class CodeHighlighter : public QObject {
public:
Q_INVOKABLE void highlight(
const QString& code, const QString& language, QObject* target, int token);
const QString& code,
const QString& language,
QObject* target,
int token);
static CodeHighlighter* create(QQmlEngine*, QJSEngine*);
struct Grammar {
// Library candidates in priority order (system package, then
// Neovim copies); libs[i] pairs with symbols[i].
std::vector<std::string> libs;
std::vector<std::string> symbols;
// Candidate query sources in priority order; first that
// compiles against the loaded grammar wins.
std::vector<const char*> queries;
};
@@ -73,29 +40,27 @@ class CodeHighlighter : public QObject {
bool bad = false; // permanent failure, do not retry
void* lib = nullptr;
const void* lang = nullptr; // const TSLanguage*
void* query = nullptr; // TSQuery*
void* query = nullptr; // TSQuery*
};
[[nodiscard]] static const QHash<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 const char* roleName(uint8_t role);
// Maps a fence language tag to the grammar id (see aliases()).
[[nodiscard]] static QString resolveId(const QString& language);
[[nodiscard]] static QString cacheKey(const QString& id, const QString& code);
// The parsing work; runs on worker threads, so the per-language
// state must be initialized under m_stateMutex and is shared as an
// immutable object afterwards.
[[nodiscard]] QVariantList doHighlight(const QString& code, const QString& language) const;
// Exact-match span cache. lookupSpans() runs on the GUI thread,
// storeSpans() on worker threads; both take m_cacheMutex.
[[nodiscard]] QVariantList lookupSpans(const QString& code, const QString& language) const;
void storeSpans(const QString& code, const QString& language,
const QVariantList& spans) const;
[[nodiscard]] static QString cacheKey(
const QString& id, const QString& code);
[[nodiscard]] QVariantList doHighlight(
const QString& code, const QString& language) const;
[[nodiscard]] QVariantList lookupSpans(
const QString& code, const QString& language) const;
void storeSpans(
const QString& code,
const QString& language,
const QVariantList& spans) const;
struct SpanCacheEntry {
QString code; // re-compared on lookup; a hash collision can
// never deliver the wrong spans
// never deliver the wrong spans
QVariantList spans;
};
+151
View File
@@ -0,0 +1,151 @@
#include "filetool.hpp"
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
namespace ZShell::llm {
namespace {
QJsonObject makeOutput(const QString& text) {
QJsonObject obj;
obj[QStringLiteral("output")] = text;
return obj;
}
QJsonObject makeError(const QString& message) {
QJsonObject obj;
obj[QStringLiteral("error")] = message;
return obj;
}
} // namespace
FileReadTool::FileReadTool(QObject* parent) : LlmTool(parent) {}
QString FileReadTool::name() const {
return QStringLiteral("readfile");
}
QString FileReadTool::description() const {
return QStringLiteral(
"Read a text file from the local filesystem and return part of "
"its content. At most a limited number of characters is returned "
"per call; use offset and limit to page through larger files. "
"Binary files are not supported. Full output of webfetch calls "
"is stored under /tmp/zshell-llm/webfetch/ and can be read this "
"way. This tool is read-only.");
}
QJsonObject FileReadTool::parameters() const {
QJsonObject path;
path[QStringLiteral("type")] = QStringLiteral("string");
path[QStringLiteral("description")] =
QStringLiteral("Path of the file to read");
QJsonObject offset;
offset[QStringLiteral("type")] = QStringLiteral("integer");
offset[QStringLiteral("minimum")] = 0;
offset[QStringLiteral("description")] =
QStringLiteral("Byte offset to start reading from. Defaults to 0.");
QJsonObject limit;
limit[QStringLiteral("type")] = QStringLiteral("integer");
limit[QStringLiteral("minimum")] = 1;
limit[QStringLiteral("description")] =
QStringLiteral("Maximum number of characters to return. Defaults "
"to a value that fits the model's context window.");
QJsonObject properties;
properties[QStringLiteral("path")] = path;
properties[QStringLiteral("offset")] = offset;
properties[QStringLiteral("limit")] = limit;
QJsonObject schema;
schema[QStringLiteral("type")] = QStringLiteral("object");
schema[QStringLiteral("properties")] = properties;
QJsonArray required;
required.append(QStringLiteral("path"));
schema[QStringLiteral("required")] = required;
return schema;
}
void FileReadTool::execute(
const QString& toolCallId,
const QJsonObject& args,
std::function<void(const QJsonObject&)> done) {
Q_UNUSED(toolCallId);
const QString path = args[QStringLiteral("path")].toString().trimmed();
if (path.isEmpty()) {
done(makeError(QStringLiteral("Missing required argument 'path'")));
return;
}
const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0));
const int budget = inlineBudgetChars();
const int limit =
qBound(1, args[QStringLiteral("limit")].toInt(budget), budget);
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
const QFileInfo info(path);
if (info.exists() && info.isDir()) {
done(makeError(QStringLiteral("Path is a directory, not a "
"file: %1")
.arg(path)));
} else {
done(makeError(QStringLiteral("Cannot open file: %1").arg(path)));
}
return;
}
const qint64 fileSize = file.size();
if (fileSize == 0) {
file.close();
done(makeOutput(QString()));
return;
}
if (offset >= fileSize) {
file.close();
done(makeError(QStringLiteral("Offset %1 is beyond the end of the "
"file (%2 bytes)")
.arg(offset)
.arg(fileSize)));
return;
}
if (!file.seek(offset)) {
file.close();
done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path)));
return;
}
// UTF-8 uses at most 4 bytes per character, so this many bytes always
// cover `limit` characters.
const qint64 wanted = qint64(limit) * 4 + 16;
const QByteArray chunk = file.read(qMin(wanted, fileSize - offset));
file.close();
if (chunk.contains('\0')) {
done(makeError(QStringLiteral("Binary files are not supported: %1")
.arg(path)));
return;
}
const QString content = QString::fromUtf8(chunk);
if (content.size() <= limit) {
done(makeOutput(content));
return;
}
const QString head = content.left(limit);
const int nextOffset = offset + head.toUtf8().size();
const QString output =
head +
QStringLiteral(
"\n\n[... truncated: showing %1 characters (bytes %2-%3 of a "
"%4 byte file). Continue with offset=%5.]")
.arg(limit)
.arg(offset)
.arg(nextOffset)
.arg(fileSize)
.arg(nextOffset);
done(makeOutput(output));
}
} // namespace ZShell::llm
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "tool.hpp"
namespace ZShell::llm {
// Lets the model read text files from the local filesystem, in
// byte-offset windows small enough to fit the context. Primarily used to
// page through the full webfetch output stored under
// /tmp/zshell-llm/webfetch/.
class FileReadTool : public LlmTool {
Q_OBJECT
public:
explicit FileReadTool(QObject* parent = nullptr);
QString name() const override;
QString description() const override;
QJsonObject parameters() const override;
void execute(
const QString& toolCallId,
const QJsonObject& args,
std::function<void(const QJsonObject& result)> done) override;
};
} // namespace ZShell::llm
+91 -78
View File
@@ -1,26 +1,27 @@
#include "generation.hpp"
#include "llmclient.hpp"
#include "messagemodel.hpp"
#include "session.hpp"
#include <QDateTime>
namespace ZShell::llm {
ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent)
: QObject(parent), m_timestamp(timestamp) {
: QObject(parent), m_timestamp(timestamp) {
m_timer.setParent(this);
m_timer.setInterval(500);
m_timer.setTimerType(Qt::CoarseTimer);
connect(&m_timer, &QTimer::timeout, this, [this]() {
bool anyRunning = false;
for (auto* segment : m_segments) {
if (!segment->running())
continue;
if (!segment->running()) continue;
anyRunning = true;
segment->elapsedMsChanged();
}
if (anyRunning)
Q_EMIT elapsedMsChanged();
if (!anyRunning && !m_streaming)
m_timer.stop();
if (anyRunning) Q_EMIT elapsedMsChanged();
if (!anyRunning && !m_streaming) m_timer.stop();
});
}
@@ -28,7 +29,7 @@ QString ChatGeneration::content() const {
QStringList parts;
for (const auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content ||
segment->text().isEmpty())
segment->text().isEmpty())
continue;
parts.append(segment->text());
}
@@ -39,7 +40,7 @@ QString ChatGeneration::reasoning() const {
QStringList parts;
for (const auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Reasoning ||
segment->text().isEmpty())
segment->text().isEmpty())
continue;
parts.append(segment->text());
}
@@ -47,11 +48,9 @@ QString ChatGeneration::reasoning() const {
}
bool ChatGeneration::reasoningActive() const {
if (!m_streaming)
return false;
if (!content().isEmpty())
return false;
return !hasRunningTool();
if (!m_streaming) return false;
if (!content().isEmpty()) return false;
return !hasRunningTool() && !hasPendingTool();
}
qint64 ChatGeneration::reasoningElapsedMs() const {
@@ -81,44 +80,74 @@ qint64 ChatGeneration::toolsElapsedMs() const {
int ChatGeneration::toolCallCount() const {
int count = 0;
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall)
++count;
if (segment->type() == LlmSegment::Type::ToolCall) ++count;
return count;
}
bool ChatGeneration::hasRunningTool() const {
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->running())
if (segment->type() == LlmSegment::Type::ToolCall && segment->running())
return true;
return false;
}
bool ChatGeneration::hasPendingTool() const {
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->status() == LlmSegment::Status::Pending)
return true;
return false;
}
void ChatGeneration::setApprovalPending(bool value) {
if (m_approvalPending == value) return;
m_approvalPending = value;
Q_EMIT toolApprovalPendingChanged();
}
QVariantList ChatGeneration::pendingToolCalls() const {
QVariantList out;
if (!m_approvalPending) return out;
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->status() == LlmSegment::Status::Pending)
out.append(QVariant::fromValue(segment));
return out;
}
void ChatGeneration::approveTools() {
if (auto* llmClient = client()) llmClient->approveTools();
}
void ChatGeneration::denyTools() {
if (auto* llmClient = client()) llmClient->denyTools();
}
LlmClient* ChatGeneration::client() const {
if (auto* message = qobject_cast<ChatMessage*>(parent()))
if (auto* model = qobject_cast<ChatMessageModel*>(message->parent()))
if (auto* session = model->session()) return session->client();
return nullptr;
}
void ChatGeneration::updateReasoningActive() {
const bool active = reasoningActive();
if (m_reasoningActive == active)
return;
if (m_reasoningActive == active) return;
m_reasoningActive = active;
Q_EMIT reasoningActiveChanged();
}
void ChatGeneration::setContent(const QString& value) {
// Replaces the entire answer: the first content segment takes the
// new text, any later content bursts are cleared.
LlmSegment* first = nullptr;
for (auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content)
continue;
if (segment->type() != LlmSegment::Type::Content) continue;
if (!first)
first = segment;
else
segment->setText(QString());
}
if (!first) {
// Do not materialize an empty content segment (e.g. the assistant
// placeholder created before the stream starts).
if (value.isEmpty())
return;
if (value.isEmpty()) return;
first = new LlmSegment(
LlmSegment::Type::Content,
QDateTime::currentMSecsSinceEpoch(),
@@ -129,35 +158,30 @@ void ChatGeneration::setContent(const QString& value) {
}
void ChatGeneration::appendContent(const QString& piece) {
if (piece.isEmpty())
return;
if (piece.isEmpty()) return;
for (auto* segment : m_segments) {
if (segment->type() == LlmSegment::Type::Reasoning &&
segment->running())
segment->running())
segment->close();
}
openContentSegment()->appendText(piece);
}
void ChatGeneration::appendReasoning(const QString& piece) {
if (piece.isEmpty())
return;
if (piece.isEmpty()) return;
for (auto* segment : m_segments) {
if (segment->type() == LlmSegment::Type::Content &&
segment->running())
if (segment->type() == LlmSegment::Type::Content && segment->running())
segment->close();
}
openReasoningSegment()->appendText(piece);
}
void ChatGeneration::setStreaming(bool value) {
if (m_streaming == value)
return;
if (m_streaming == value) return;
m_streaming = value;
Q_EMIT streamingChanged();
if (value) {
if (!m_timer.isActive())
m_timer.start();
if (!m_timer.isActive()) m_timer.start();
} else {
closeOpenSegments();
m_timer.stop();
@@ -168,13 +192,10 @@ void ChatGeneration::setStreaming(bool value) {
LlmSegment* ChatGeneration::openContentSegment() {
for (auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::Content &&
segment->running())
if (segment->type() == LlmSegment::Type::Content && segment->running())
return segment;
auto* segment = new LlmSegment(
LlmSegment::Type::Content,
QDateTime::currentMSecsSinceEpoch(),
this);
LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), this);
segment->begin();
addSegment(segment);
return segment;
@@ -183,12 +204,10 @@ LlmSegment* ChatGeneration::openContentSegment() {
LlmSegment* ChatGeneration::openReasoningSegment() {
for (auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::Reasoning &&
segment->running())
segment->running())
return segment;
auto* segment = new LlmSegment(
LlmSegment::Type::Reasoning,
QDateTime::currentMSecsSinceEpoch(),
this);
LlmSegment::Type::Reasoning, QDateTime::currentMSecsSinceEpoch(), this);
segment->begin();
addSegment(segment);
return segment;
@@ -198,12 +217,12 @@ LlmSegment* ChatGeneration::beginToolCall(
const QString& name, const QString& toolCallId) {
closeOpenSegments();
auto* segment = new LlmSegment(
LlmSegment::Type::ToolCall,
QDateTime::currentMSecsSinceEpoch(),
this);
LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this);
segment->setName(name);
segment->setToolCallId(toolCallId);
segment->setStatus(LlmSegment::Status::Running);
// Awaiting user approval until LlmClient::approveTools() promotes it
// to Running; denyTools()/endStream() finalize it as an error.
segment->setStatus(LlmSegment::Status::Pending);
segment->begin();
addSegment(segment);
Q_EMIT toolStateChanged();
@@ -211,39 +230,33 @@ LlmSegment* ChatGeneration::beginToolCall(
}
void ChatGeneration::addSegment(LlmSegment* segment) {
if (!segment || m_segments.contains(segment))
return;
if (!segment || m_segments.contains(segment)) return;
segment->setParent(this);
connect(
segment, &LlmSegment::textChanged, this, [this, segment]() {
if (segment->type() == LlmSegment::Type::Reasoning)
Q_EMIT reasoningChanged();
else if (segment->type() == LlmSegment::Type::Content)
Q_EMIT contentChanged();
updateReasoningActive();
});
connect(
segment, &LlmSegment::statusChanged, this, [this]() {
Q_EMIT toolStateChanged();
});
connect(
segment, &LlmSegment::resultChanged, this, [this]() {
Q_EMIT toolStateChanged();
});
connect(
segment, &LlmSegment::runningChanged, this, [this]() {
Q_EMIT elapsedMsChanged();
Q_EMIT toolStateChanged();
updateReasoningActive();
});
connect(segment, &LlmSegment::textChanged, this, [this, segment]() {
if (segment->type() == LlmSegment::Type::Reasoning)
Q_EMIT reasoningChanged();
else if (segment->type() == LlmSegment::Type::Content)
Q_EMIT contentChanged();
updateReasoningActive();
});
connect(segment, &LlmSegment::statusChanged, this, [this]() {
Q_EMIT toolStateChanged();
});
connect(segment, &LlmSegment::resultChanged, this, [this]() {
Q_EMIT toolStateChanged();
});
connect(segment, &LlmSegment::runningChanged, this, [this]() {
Q_EMIT elapsedMsChanged();
Q_EMIT toolStateChanged();
updateReasoningActive();
});
m_segments.append(segment);
Q_EMIT segmentsChanged();
}
void ChatGeneration::closeOpenSegments() {
for (auto* segment : m_segments)
if (segment->running())
segment->close();
if (segment->running()) segment->close();
updateReasoningActive();
}
+31 -22
View File
@@ -6,43 +6,48 @@
#include <QObject>
#include <QString>
#include <QTimer>
#include <QVariantList>
#include <QtQml>
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 LlmClient;
class ChatGeneration : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Chat generations are managed by ChatMessage")
Q_PROPERTY(QString content READ content WRITE setContent NOTIFY contentChanged)
Q_PROPERTY(
QString content READ content WRITE setContent NOTIFY contentChanged)
Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged)
Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(
qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY
elapsedMsChanged)
Q_PROPERTY(
qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged)
Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged)
Q_PROPERTY(
bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged)
Q_PROPERTY(bool hasRunningTool READ hasRunningTool NOTIFY toolStateChanged)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY(
QList<ZShell::llm::LlmSegment*> segments READ segments
NOTIFY segmentsChanged)
QList<ZShell::llm::LlmSegment*> segments READ segments NOTIFY
segmentsChanged)
Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged)
Q_PROPERTY(
bool toolApprovalPending READ toolApprovalPending NOTIFY
toolApprovalPendingChanged)
Q_PROPERTY(
QVariantList pendingToolCalls READ pendingToolCalls NOTIFY
toolApprovalPendingChanged)
public:
explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr);
// All content bursts, joined (the model's full answer).
[[nodiscard]] QString content() const;
// Every reasoning burst, joined.
[[nodiscard]] QString reasoning() const;
// True while the model is thinking: streaming, no content yet, and
// no tool call in flight.
[[nodiscard]] bool reasoningActive() const;
[[nodiscard]] qint64 reasoningElapsedMs() const;
[[nodiscard]] qint64 contentElapsedMs() const;
@@ -52,24 +57,25 @@ class ChatGeneration : public QObject {
[[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; }
[[nodiscard]] int toolCallCount() const;
[[nodiscard]] bool hasRunningTool() const;
[[nodiscard]] bool hasPendingTool() const;
[[nodiscard]] bool toolApprovalPending() const { return m_approvalPending; }
void setApprovalPending(bool value);
[[nodiscard]] QVariantList pendingToolCalls() const;
Q_INVOKABLE void approveTools();
Q_INVOKABLE void denyTools();
void setContent(const QString& value);
void appendContent(const QString& piece);
void appendReasoning(const QString& piece);
void setStreaming(bool value);
// The in-flight content segment, or a fresh one. A new content
// segment starts whenever the model resumes writing after reasoning
// or a tool call.
[[nodiscard]] LlmSegment* openContentSegment();
// The in-flight reasoning segment, or a fresh one.
[[nodiscard]] LlmSegment* openReasoningSegment();
// Creates and appends a running tool-call segment.
[[nodiscard]] LlmSegment* beginToolCall(
const QString& name, const QString& toolCallId);
// Appends a segment created by the persistence layer.
void addSegment(LlmSegment* segment);
// Stops the clocks of every in-flight segment.
void closeOpenSegments();
Q_SIGNALS:
@@ -80,14 +86,17 @@ class ChatGeneration : public QObject {
void streamingChanged();
void toolStateChanged();
void segmentsChanged();
void toolApprovalPendingChanged();
private:
void updateReasoningActive();
LlmClient* client() const;
QTimer m_timer;
QList<LlmSegment*> m_segments;
bool m_reasoningActive = false;
bool m_streaming = false;
bool m_approvalPending = false;
qint64 m_timestamp;
};
File diff suppressed because it is too large Load Diff
+18 -25
View File
@@ -23,9 +23,6 @@ class ChatSession;
class LlmSegment;
class LlmTool;
// The only component that talks to the LLM server: owns the network
// manager, the in-flight streaming state, the SSE parsing and the
// tool-calling loop.
class LlmClient : public QObject {
Q_OBJECT
@@ -53,19 +50,19 @@ class LlmClient : public QObject {
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; }
[[nodiscard]] ChatSession* streamingSession() const { return m_active; }
// Streams a new assistant reply into `target` (the active generation
// of a message of `session`). The context sent to the model is every
// message of the session that is older than the target message.
// Tool calls made by the model are executed and fed back
// transparently until the model produces its final answer.
void startGeneration(ChatSession* session, ChatGeneration* target);
void stop();
void endStream();
// Clears the session's conversation once the current stream ends.
void clearOnFinish(ChatSession* session);
// A session is about to be destroyed; drop any state pointing at it.
void sessionRemoved(ChatSession* session);
// Tool calls are executed only after the user approves the batch the
// model requested. The per-generation state (toolApprovalPending,
// pendingToolCalls) is mirrored onto ChatGeneration; these are the
// entry points QML reaches through it.
void approveTools();
void denyTools();
void refreshModels();
void requestTitle(ChatSession* session, const QString& userText);
void requestIcon(ChatSession* session, const QString& userText);
@@ -91,23 +88,15 @@ class LlmClient : public QObject {
bool seen = false;
};
// Sends one streaming round: context + transcript so far.
void sendRound();
// The session context for a round, oldest first, ending just before
// `stopBeforeRow` (the message of the generation being streamed).
QJsonArray buildContextMessages(
ChatSession* session, int stopBeforeRow) const;
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const;
void applyToolCallDelta(const QJsonObject& call);
// One round's stream ended; either ends the turn or executes the
// requested tool calls and sends the next round. Runs at most once
// per round ([DONE] and the reply's finished signal both reach it).
void roundFinished();
// Dispatches every call of the round; tools run concurrently.
void executeAllCalls();
// All results in: appends the tool messages (in call order) and
// sends the next round.
void flushCallResults();
// Ends the current turn gracefully and persists the session.
void setApprovalPending(bool value);
void finishPendingCalls(const QString& resultText);
void finishTurn();
void fail(const QString& message);
void handleLine(const QByteArray& line);
@@ -120,7 +109,8 @@ class LlmClient : public QObject {
const QString& systemPrompt,
const QString& userText,
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(
const QByteArray& body, const QString& fallback);
@@ -128,8 +118,11 @@ class LlmClient : public QObject {
ToolRegistry* m_tools = nullptr;
QNetworkReply* m_reply = nullptr;
QByteArray m_buffer;
ChatSession* m_active = nullptr;
ChatGeneration* m_streaming = nullptr;
// QPointer: these objects are owned by the ChatStore tree, which Qt
// destroys *before* this client on shutdown (children die in creation
// order). The pointers must self-null instead of dangling.
QPointer<ChatSession> m_active;
QPointer<ChatGeneration> m_streaming;
QPointer<ChatSession> m_pendingClear;
bool m_busy = false;
QString m_streamingChatId;
@@ -139,7 +132,6 @@ class LlmClient : public QObject {
double m_temperature = 0.7;
int m_contextSize = 0;
// State of the multi-round tool loop of the current turn.
QJsonArray m_transcript;
QList<ToolCallBuilder> m_callBuilders;
struct ToolCallResult {
@@ -153,6 +145,7 @@ class LlmClient : public QObject {
qsizetype m_reasoningMark = 0;
bool m_roundDone = false;
bool m_toolPhase = false;
bool m_approvalPending = false;
int m_pendingCalls = 0;
static constexpr int kMaxToolRounds = 12;
};
+3 -6
View File
@@ -5,9 +5,6 @@
namespace ZShell::llm {
// Marker class exposing the LlmMarkdown block type enum to QML
// (LlmMarkdown.Type.*). Blocks themselves are value maps produced by
// MarkdownParser and stored on LlmSegment.
class LlmMarkdown : public QObject {
Q_OBJECT
QML_ELEMENT
@@ -16,9 +13,9 @@ class LlmMarkdown : public QObject {
public:
enum class Type : int {
Text = 0, // Paragraph, list, quote, table; "text" is markdown source
Heading, // "level" + "text" (markdown source of the content)
Code, // "language" + "code"
Math // "latex" (display math, without the $$ delimiters)
Heading, // "level" + "text" (markdown source of the content)
Code, // "language" + "code"
Math // "latex" (display math, without the $$ delimiters)
};
Q_ENUM(Type)
+21 -40
View File
@@ -13,15 +13,12 @@ namespace ZShell::llm {
QVariantList MarkdownParser::parse(const QString& source) {
QVariantList blocks;
if (source.trimmed().isEmpty())
return blocks;
if (source.trimmed().isEmpty()) return blocks;
const QStringList lines = source.split('\n');
// cmark-gfm line/column numbers are 1-based and inclusive.
auto sliceSource = [&](int startLine, int endLine) -> QString {
if (startLine < 1 || endLine < startLine)
return QString();
if (startLine < 1 || endLine < startLine) return QString();
const int from = startLine;
const int to = qMin(endLine, static_cast<int>(lines.size()));
return lines.mid(from - 1, to - from + 1).join('\n').trimmed();
@@ -29,15 +26,11 @@ QVariantList MarkdownParser::parse(const QString& source) {
const QByteArray utf8 = source.toUtf8();
cmark_node* doc = cmark_parse_document(
utf8.constData(), static_cast<size_t>(utf8.size()),
utf8.constData(),
static_cast<size_t>(utf8.size()),
CMARK_OPT_DEFAULT | CMARK_OPT_SOURCEPOS);
if (!doc)
return blocks;
if (!doc) return blocks;
// cmark-gfm has no math extension, so $$...$$ parses as an ordinary
// paragraph. Re-detect display math (a $$...$$ pair) here and split it
// out as its own block. Single-$ inline math is intentionally left
// untouched (rendered raw) for now.
const QRegularExpression mathRe(
QStringLiteral("\\$\\$(.+?)\\$\\$"),
QRegularExpression::DotMatchesEverythingOption);
@@ -45,29 +38,22 @@ QVariantList MarkdownParser::parse(const QString& source) {
auto makeBlock = [&](LlmMarkdown::Type type) {
QVariantMap block;
block.insert("type", static_cast<int>(type));
// Stable per-position identity ("index:type") for the QML
// ScriptModel: blocks that survive a re-parse keep their id, so
// their delegates are updated in place instead of recreated
// (which would drop code highlights mid-stream). The type is
// part of the id so a block that changes type is rebuilt.
block.insert("id",
QString::number(static_cast<int>(blocks.size()))
+ QLatin1Char(':')
+ QString::number(static_cast<int>(type)));
block.insert(
"id",
QString::number(static_cast<int>(blocks.size())) +
QLatin1Char(':') + QString::number(static_cast<int>(type)));
return block;
};
auto appendText = [&](const QString& text) {
if (text.trimmed().isEmpty())
return;
if (text.trimmed().isEmpty()) return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Text);
block.insert("text", text);
blocks.append(block);
};
auto appendMath = [&](const QString& latex) {
if (latex.trimmed().isEmpty())
return;
if (latex.trimmed().isEmpty()) return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Math);
block.insert("latex", latex);
blocks.append(block);
@@ -81,8 +67,7 @@ QVariantList MarkdownParser::parse(const QString& source) {
};
auto appendHeading = [&](int level, const QString& text) {
if (text.trimmed().isEmpty())
return;
if (text.trimmed().isEmpty()) return;
QVariantMap block = makeBlock(LlmMarkdown::Type::Heading);
block.insert("level", level);
block.insert("text", text);
@@ -90,7 +75,7 @@ QVariantList MarkdownParser::parse(const QString& source) {
};
for (cmark_node* node = cmark_node_first_child(doc); node;
node = cmark_node_next(node)) {
node = cmark_node_next(node)) {
const cmark_node_type type = cmark_node_get_type(node);
const int startLine = cmark_node_get_start_line(node);
const int endLine = cmark_node_get_end_line(node);
@@ -98,21 +83,20 @@ QVariantList MarkdownParser::parse(const QString& source) {
if (type == CMARK_NODE_CODE_BLOCK) {
const char* literal = cmark_node_get_literal(node);
QString code = literal ? QString::fromUtf8(literal) : QString();
// Fenced block literals carry a trailing newline; drop one.
if (code.endsWith('\n'))
code.chop(1);
if (code.endsWith('\n')) code.chop(1);
QString language;
if (const char* info = cmark_node_get_fence_info(node); info)
language = QString::fromUtf8(info).section(' ', 0, 0).trimmed().toLower();
language = QString::fromUtf8(info)
.section(' ', 0, 0)
.trimmed()
.toLower();
appendCode(language, code);
continue;
}
if (type == CMARK_NODE_HEADING) {
// Inline content only (no leading #), so QML can style by
// level.
const char* content = cmark_node_get_string_content(node);
appendHeading(
cmark_node_get_heading_level(node),
@@ -125,11 +109,11 @@ QVariantList MarkdownParser::parse(const QString& source) {
int cursor = 0;
bool anyMath = false;
for (auto it = mathRe.globalMatch(text, cursor); it.hasNext();
it = mathRe.globalMatch(text, cursor)) {
it = mathRe.globalMatch(text, cursor)) {
const QRegularExpressionMatch m = it.next();
anyMath = true;
appendText(
text.mid(cursor, static_cast<int>(m.capturedStart() - cursor)));
appendText(text.mid(
cursor, static_cast<int>(m.capturedStart() - cursor)));
appendMath(m.captured(1).trimmed());
cursor = static_cast<int>(m.capturedEnd());
}
@@ -140,9 +124,6 @@ QVariantList MarkdownParser::parse(const QString& source) {
continue;
}
// Lists, block quotes, tables, horizontal rules, custom blocks:
// hand the raw markdown source to QML (rendered via
// Text.MarkdownText).
appendText(sliceSource(startLine, endLine));
}
-12
View File
@@ -5,18 +5,6 @@
namespace ZShell::llm {
// Splits a markdown document into top-level blocks for QML rendering
// (cmark-gfm AST walk). Inline structure is not flattened; Text and
// Heading blocks carry markdown source that QML renders with
// Text.MarkdownText.
//
// Block map keys:
// "type" int (LlmMarkdown::Type)
// "level" int (Heading)
// "language" QString (Code, lowercased, empty when unknown)
// "code" QString (Code)
// "text" QString (Text/Heading, markdown source)
// "latex" QString (Math, without the $$ delimiters)
class MarkdownParser {
public:
[[nodiscard]] static QVariantList parse(const QString& source);
+99 -123
View File
@@ -16,15 +16,10 @@
namespace ZShell::llm {
namespace {
// A few px of breathing room around the equation.
constexpr int kRenderMargin = 2;
constexpr unsigned int kResolutionDpi = 96;
// The render cache is unbounded between clears; cap it so a very long
// session with many distinct equations cannot grow it forever.
constexpr int kCacheLimit = 512;
// Registers the embedded Latin Modern faces with the font database
// (once per process) and reports which families became available.
struct LatinModern {
bool roman = false;
bool math = false;
@@ -33,37 +28,44 @@ struct LatinModern {
const LatinModern& loadLatinModern() {
static const LatinModern fonts = [] {
LatinModern result;
// addApplicationFont(QByteArray) fails in this environment, so
// the embedded bytes are staged to a per-process temp file and
// registered through the (stable) file-based API.
const auto add = [](const unsigned char* data, size_t size,
const QString& fileName, const QString& family) {
const auto add = [](const unsigned char* data,
size_t size,
const QString& fileName,
const QString& family) {
const QString path = QDir::tempPath() + QLatin1Char('/') + fileName;
{
QFile f(path);
if (!f.open(QIODevice::WriteOnly) ||
f.write(reinterpret_cast<const char*>(data),
static_cast<qint64>(size)) != static_cast<qint64>(size))
f.write(
reinterpret_cast<const char*>(data),
static_cast<qint64>(size)) != static_cast<qint64>(size))
return false;
}
const int key = QFontDatabase::addApplicationFont(path);
if (key < 0)
return false;
if (key < 0) return false;
return QFontDatabase::applicationFontFamilies(key).contains(family);
};
result.roman =
add(lmfont::lmroman10_regular, sizeof(lmfont::lmroman10_regular),
QStringLiteral("lmroman10-regular.otf"), QStringLiteral("LMRoman10"))
&& add(lmfont::lmroman10_italic, sizeof(lmfont::lmroman10_italic),
QStringLiteral("lmroman10-italic.otf"), QStringLiteral("LMRoman10"))
&& add(lmfont::lmroman10_bold, sizeof(lmfont::lmroman10_bold),
QStringLiteral("lmroman10-bold.otf"), QStringLiteral("LMRoman10"))
&& add(lmfont::lmroman10_bolditalic, sizeof(lmfont::lmroman10_bolditalic),
QStringLiteral("lmroman10-bolditalic.otf"),
QStringLiteral("LMRoman10"));
result.math = add(
lmfont::latinmodern_math, sizeof(lmfont::latinmodern_math),
QStringLiteral("latinmodern-math.otf"), QStringLiteral("Latin Modern Math"));
result.roman = add(lmfont::lmroman10_regular,
sizeof(lmfont::lmroman10_regular),
QStringLiteral("lmroman10-regular.otf"),
QStringLiteral("LMRoman10")) &&
add(lmfont::lmroman10_italic,
sizeof(lmfont::lmroman10_italic),
QStringLiteral("lmroman10-italic.otf"),
QStringLiteral("LMRoman10")) &&
add(lmfont::lmroman10_bold,
sizeof(lmfont::lmroman10_bold),
QStringLiteral("lmroman10-bold.otf"),
QStringLiteral("LMRoman10")) &&
add(lmfont::lmroman10_bolditalic,
sizeof(lmfont::lmroman10_bolditalic),
QStringLiteral("lmroman10-bolditalic.otf"),
QStringLiteral("LMRoman10"));
result.math =
add(lmfont::latinmodern_math,
sizeof(lmfont::latinmodern_math),
QStringLiteral("latinmodern-math.otf"),
QStringLiteral("Latin Modern Math"));
return result;
}();
return fonts;
@@ -72,22 +74,17 @@ const LatinModern& loadLatinModern() {
} // namespace
LlmMathText::LlmMathText(QObject* parent)
// No parent: the renderer is used from pool threads, and a parented
// QObject would taint children it creates there.
: QObject(parent), m_renderer(std::make_shared<JKQTMathText>(
nullptr, /* useFontsForGUI */ true)) {
// Latin Modern is the default font of modern LaTeX; use the embedded
// faces instead of whatever the system happens to have installed.
: QObject(parent)
, m_renderer(
std::make_shared<JKQTMathText>(nullptr, /* useFontsForGUI */ true)) {
const LatinModern& fonts = loadLatinModern();
if (fonts.roman)
m_renderer->setFontRomanAndMath(QStringLiteral("LMRoman10"),
JKQTMathTextFontEncoding::MTFEUnicode);
m_renderer->setFontRomanAndMath(
QStringLiteral("LMRoman10"), JKQTMathTextFontEncoding::MTFEUnicode);
if (fonts.math) {
// Same pattern as JKQTMathText's useXITS(): the OpenType math
// font supplies the math alphabet and operators from its MATH
// table.
m_renderer->setFontMathRoman(QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode);
m_renderer->setFontMathRoman(
QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode);
m_renderer->setFallbackFontSymbols(
QStringLiteral("Latin Modern Math"),
JKQTMathTextFontEncoding::MTFEUnicode);
@@ -95,38 +92,31 @@ LlmMathText::LlmMathText(QObject* parent)
}
void LlmMathText::setLatex(const QString& value) {
if (m_latex == value)
return;
if (m_latex == value) return;
m_latex = value;
reRender();
}
void LlmMathText::setColor(const QColor& value) {
if (m_color == value)
return;
if (m_color == value) return;
m_color = value;
reRender();
}
void LlmMathText::setFontPointSize(double value) {
if (qFuzzyCompare(m_fontPointSize, value))
return;
if (qFuzzyCompare(m_fontPointSize, value)) return;
m_fontPointSize = value;
reRender();
}
void LlmMathText::setDevicePixelRatio(qreal value) {
if (qFuzzyCompare(m_devicePixelRatio, value))
return;
if (qFuzzyCompare(m_devicePixelRatio, value)) return;
m_devicePixelRatio = value;
reRender();
}
namespace {
// Process-wide render cache; the key is the full render state, so
// re-opening a chat reuses already-rendered equations. Accessed on the
// GUI thread only.
struct MathRender {
bool ok = false;
QImage image;
@@ -154,10 +144,9 @@ void LlmMathText::reRender() {
return;
}
const QString key = m_latex
+ QLatin1Char(0x1f) + m_color.name()
+ QLatin1Char(0x1f) + QString::number(m_fontPointSize)
+ QLatin1Char(0x1f) + QString::number(m_devicePixelRatio);
const QString key = m_latex + QLatin1Char(0x1f) + m_color.name() +
QLatin1Char(0x1f) + QString::number(m_fontPointSize) +
QLatin1Char(0x1f) + QString::number(m_devicePixelRatio);
if (auto it = mathCache().find(key); it != mathCache().end()) {
m_image = it->image;
m_imageUrl = it->url;
@@ -168,83 +157,70 @@ void LlmMathText::reRender() {
return;
}
// A render is already running; it re-renders the latest state when
// it completes (id mismatch), so there is nothing to do here.
if (m_inFlight)
return;
if (m_inFlight) return;
m_inFlight = true;
const QString latex = m_latex;
const QColor color = m_color;
const double pointSize = m_fontPointSize;
const qreal dpr = m_devicePixelRatio;
// The worker captures the renderer by value (shared_ptr) so it
// stays alive even if this object is destroyed mid-render; it is
// only ever used by the single in-flight worker (m_inFlight),
// never concurrently.
auto renderer = m_renderer;
QThreadPool::globalInstance()->start([this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() {
MathRender render;
renderer->setFontPointSize(pointSize);
renderer->setFontColor(color);
if (renderer->parse(
latex, JKQTMathText::LatexParser, JKQTMathText::DefaultParseOptions)) {
const QImage image = renderer->drawIntoImage(
/* drawBoxes */ false,
QColor(Qt::transparent),
kRenderMargin,
dpr,
kResolutionDpi);
if (!image.isNull()) {
QByteArray png;
{
QBuffer buffer(&png);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
QThreadPool::globalInstance()->start(
[this, renderer, id = m_requestId, key, latex, color, pointSize, dpr]() {
MathRender render;
renderer->setFontPointSize(pointSize);
renderer->setFontColor(color);
if (renderer->parse(
latex,
JKQTMathText::LatexParser,
JKQTMathText::DefaultParseOptions)) {
const QImage image = renderer->drawIntoImage(
/* drawBoxes */ false,
QColor(Qt::transparent),
kRenderMargin,
dpr,
kResolutionDpi);
if (!image.isNull()) {
QByteArray png;
{
QBuffer buffer(&png);
buffer.open(QIODevice::WriteOnly);
image.save(&buffer, "PNG");
}
render.image = image;
render.url = QUrl(
QStringLiteral("data:image/png;base64,") +
QString::fromLatin1(png.toBase64()));
render.width = image.width() / dpr;
render.height = image.height() / dpr;
render.ok = true;
}
render.image = image;
render.url = QUrl(
QStringLiteral("data:image/png;base64,")
+ QString::fromLatin1(png.toBase64()));
// drawIntoImage renders at devicePixelRatio; convert
// back to logical pixels.
render.width = image.width() / dpr;
render.height = image.height() / dpr;
render.ok = true;
}
}
// Deliver through the app instance (never destroyed) and
// re-check the pointer on the GUI thread: posting to `this`
// from the pool thread would race with its destruction.
QPointer<LlmMathText> guard(this);
QMetaObject::invokeMethod(
QCoreApplication::instance(),
[guard, id, key, render = std::move(render)]() mutable {
LlmMathText* self = guard;
if (!self)
return;
self->m_inFlight = false;
if (id != self->m_requestId) {
// Superseded while the worker ran; render the
// latest state.
self->reRender();
return;
}
if (render.ok) {
auto& cache = mathCache();
if (cache.size() >= kCacheLimit)
cache.clear();
cache.insert(key, render);
}
self->m_image = render.image;
self->m_imageUrl = render.url;
self->m_width = render.width;
self->m_height = render.height;
self->m_ok = render.ok;
Q_EMIT self->changed();
},
Qt::QueuedConnection);
});
QPointer<LlmMathText> guard(this);
QMetaObject::invokeMethod(
QCoreApplication::instance(),
[guard, id, key, render = std::move(render)]() mutable {
LlmMathText* self = guard;
if (!self) return;
self->m_inFlight = false;
if (id != self->m_requestId) {
self->reRender();
return;
}
if (render.ok) {
auto& cache = mathCache();
if (cache.size() >= kCacheLimit) cache.clear();
cache.insert(key, render);
}
self->m_image = render.image;
self->m_imageUrl = render.url;
self->m_width = render.width;
self->m_height = render.height;
self->m_ok = render.ok;
Q_EMIT self->changed();
},
Qt::QueuedConnection);
});
}
} // namespace ZShell::llm
+6 -17
View File
@@ -13,27 +13,20 @@
namespace ZShell::llm {
// QML wrapper around JKQTMathText (JKQtPlotter's LaTeX renderer).
//
// Parses a display-math string and renders it into a transparent
// QImage at the given device pixel ratio, off the GUI thread (with a
// process-wide cache keyed on the full render state, so re-opening a
// chat does not re-render the same equations). QML displays the image
// (scaling it to the bubble width when needed) and falls back to the
// raw LaTeX when parsing fails.
class LlmMathText : public QObject {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QString latex READ latex WRITE setLatex NOTIFY changed)
Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY changed)
Q_PROPERTY(double fontPointSize READ fontPointSize WRITE setFontPointSize NOTIFY changed)
Q_PROPERTY(qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio NOTIFY changed)
Q_PROPERTY(
double fontPointSize READ fontPointSize WRITE setFontPointSize NOTIFY
changed)
Q_PROPERTY(
qreal devicePixelRatio READ devicePixelRatio WRITE setDevicePixelRatio
NOTIFY changed)
Q_PROPERTY(QImage image READ image NOTIFY changed)
// data: URL of the rendered equation; usable directly as
// Image.source (a raw QImage is not).
Q_PROPERTY(QUrl imageUrl READ imageUrl NOTIFY changed)
// Logical (CSS pixel) size of the rendered equation.
Q_PROPERTY(qreal width READ width NOTIFY changed)
Q_PROPERTY(qreal height READ height NOTIFY changed)
Q_PROPERTY(bool ok READ ok NOTIFY changed)
@@ -61,8 +54,6 @@ class LlmMathText : public QObject {
private:
void reRender();
// Shared so an in-flight worker render keeps the renderer alive if
// this object (and its QML item) is destroyed mid-render.
std::shared_ptr<JKQTMathText> m_renderer;
QString m_latex;
QColor m_color;
@@ -73,8 +64,6 @@ class LlmMathText : public QObject {
qreal m_width = 0;
qreal m_height = 0;
bool m_ok = false;
// Bumps on every reRender; a delivery carrying an older id was
// superseded and is dropped.
int m_requestId = 0;
bool m_inFlight = false;
};
+8 -15
View File
@@ -16,13 +16,12 @@ ChatSession* sessionOf(const ChatMessage* message) {
} // namespace
ChatMessage::ChatMessage(Role role, qint64 timestamp, QObject* parent)
: QObject(parent), m_role(role), m_timestamp(timestamp) {}
: QObject(parent), m_role(role), m_timestamp(timestamp) {}
ChatGeneration* ChatMessage::addGeneration(qint64 timestamp) {
auto* generation = new ChatGeneration(timestamp, this);
m_generations.append(generation);
if (m_active < 0)
m_active = static_cast<int>(m_generations.size() - 1);
if (m_active < 0) m_active = static_cast<int>(m_generations.size() - 1);
Q_EMIT generationsChanged();
return generation;
}
@@ -35,8 +34,7 @@ ChatGeneration* ChatMessage::appendGeneration(qint64 timestamp) {
void ChatMessage::removeGeneration(ChatGeneration* generation) {
const int index = static_cast<int>(m_generations.indexOf(generation));
if (index < 0)
return;
if (index < 0) return;
const bool wasActive = index == m_active;
m_generations.removeAt(index);
delete generation;
@@ -46,13 +44,11 @@ void ChatMessage::removeGeneration(ChatGeneration* generation) {
m_active = static_cast<int>(m_generations.size() - 1);
}
Q_EMIT generationsChanged();
if (wasActive)
Q_EMIT activeGenerationChanged();
if (wasActive) Q_EMIT activeGenerationChanged();
}
void ChatMessage::setActiveInternal(int index) {
if (index < 0 || index >= m_generations.size() || index == m_active)
return;
if (index < 0 || index >= m_generations.size() || index == m_active) return;
m_active = index;
Q_EMIT activeGenerationChanged();
}
@@ -64,18 +60,15 @@ void ChatMessage::setActiveGeneration(int index) {
void ChatMessage::edit(const QString& newContent) {
if (auto* generation = activeGeneration())
generation->setContent(newContent);
if (auto* session = sessionOf(this))
session->persist();
if (auto* session = sessionOf(this)) session->persist();
}
void ChatMessage::retry() {
if (auto* session = sessionOf(this))
session->retry(this);
if (auto* session = sessionOf(this)) session->retry(this);
}
void ChatMessage::generate() {
if (auto* session = sessionOf(this))
session->continueFrom(this);
if (auto* session = sessionOf(this)) session->continueFrom(this);
}
} // namespace ZShell::llm
+10 -13
View File
@@ -16,24 +16,23 @@ class ChatMessage : public QObject {
Q_PROPERTY(Role role READ role CONSTANT)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY(int generationCount READ generationCount NOTIFY generationsChanged)
Q_PROPERTY(
QList<ZShell::llm::ChatGeneration*> generations READ generations
NOTIFY generationsChanged)
int generationCount READ generationCount NOTIFY generationsChanged)
Q_PROPERTY(
QList<ZShell::llm::ChatGeneration*> generations READ generations NOTIFY
generationsChanged)
Q_PROPERTY(
ZShell::llm::ChatGeneration* activeGeneration READ activeGeneration
NOTIFY activeGenerationChanged)
Q_PROPERTY(int activeGenerationIndex READ activeGenerationIndex NOTIFY activeGenerationChanged)
Q_PROPERTY(
int activeGenerationIndex READ activeGenerationIndex NOTIFY
activeGenerationChanged)
public:
enum class Role : int {
User = 0,
Assistant
};
enum class Role : int { User = 0, Assistant };
Q_ENUM(Role)
explicit ChatMessage(
Role role, qint64 timestamp, QObject* parent = nullptr);
explicit ChatMessage(Role role, qint64 timestamp, QObject* parent = nullptr);
[[nodiscard]] Role role() const { return m_role; }
[[nodiscard]] qint64 timestamp() const { return m_timestamp; }
@@ -48,8 +47,7 @@ class ChatMessage : public QObject {
}
[[nodiscard]] int activeGenerationIndex() const { return m_active; }
[[nodiscard]] ChatGeneration* generation(int index) const {
if (index < 0 || index >= m_generations.size())
return nullptr;
if (index < 0 || index >= m_generations.size()) return nullptr;
return m_generations.at(index);
}
@@ -58,7 +56,6 @@ class ChatMessage : public QObject {
Q_INVOKABLE void retry();
Q_INVOKABLE void generate();
// Creates an empty generation; callers fill it with segments.
ChatGeneration* addGeneration(qint64 timestamp);
ChatGeneration* appendGeneration(qint64 timestamp);
void removeGeneration(ChatGeneration* generation);
+2 -4
View File
@@ -100,11 +100,9 @@ void ChatMessageModel::clear() {
void ChatMessageModel::loadMessages(QList<ChatMessage*> messages) {
beginResetModel();
// The new list may share rows with the current one (live rows kept
// in front of fetched rows); delete only what is truly gone.
for (ChatMessage* message : m_messages)
if (std::find(messages.begin(), messages.end(), message)
== messages.end())
if (std::find(messages.begin(), messages.end(), message) ==
messages.end())
delete message;
m_messages = std::move(messages);
endResetModel();
+2 -11
View File
@@ -12,17 +12,14 @@ namespace ZShell::llm {
class ChatSession;
// Owns a session's messages, most recent first: row 0 is always the newest
// message. Items are exposed through a role named "modelData" (like
// FileSystemModel), so delegates receive each ChatMessage as `modelData`.
class ChatMessageModel : public QAbstractListModel {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("Chat message models are owned by ChatSession")
Q_PROPERTY(
ZShell::llm::ChatMessage* lastMessage READ lastMessage
NOTIFY lastMessageChanged)
ZShell::llm::ChatMessage* lastMessage READ lastMessage NOTIFY
lastMessageChanged)
public:
explicit ChatMessageModel(ChatSession* session, QObject* parent = nullptr);
@@ -35,7 +32,6 @@ class ChatMessageModel : public QAbstractListModel {
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
[[nodiscard]] ChatSession* session() const { return m_session; }
// Most recent message first.
[[nodiscard]] QList<ChatMessage*> messages() const { return m_messages; }
[[nodiscard]] ChatMessage* at(int row) const;
[[nodiscard]] int rowOf(const ChatMessage* message) const;
@@ -44,17 +40,12 @@ class ChatMessageModel : public QAbstractListModel {
return m_messages.isEmpty() ? nullptr : m_messages.first();
}
// Creates a message owned by this model without inserting it.
ChatMessage* createMessage(ChatMessage::Role role, qint64 timestamp);
// Appends a new message as the newest one (row 0) with a single
// generation holding `content`.
ChatMessage* appendNewest(
ChatMessage::Role role, const QString& content, qint64 timestamp);
void removeMessage(ChatMessage* message);
void removeRange(int firstRow, int lastRow);
void clear();
// Replaces every row; takes ownership of the given messages, most recent
// first.
void loadMessages(QList<ChatMessage*> messages);
signals:
+21 -52
View File
@@ -10,36 +10,27 @@
namespace ZShell::llm {
namespace {
// Re-parse cadence while a segment streams; content between refreshes is
// at most this stale.
constexpr int kMarkdownRefreshMs = 150;
} // namespace
LlmSegment::LlmSegment(Type type, qint64 timestamp, QObject* parent)
: QObject(parent), m_type(type), m_timestamp(timestamp) {
: QObject(parent), m_type(type), m_timestamp(timestamp) {
m_markdownTimer.setInterval(kMarkdownRefreshMs);
connect(
&m_markdownTimer, &QTimer::timeout, this, [this]() {
if (!m_markdownDirty)
return;
// A parse may still be running; leave the dirty flag set so
// it re-parses the newest text when that one completes.
if (parseMarkdown())
m_markdownDirty = false;
});
connect(&m_markdownTimer, &QTimer::timeout, this, [this]() {
if (!m_markdownDirty) return;
if (parseMarkdown()) m_markdownDirty = false;
});
}
qint64 LlmSegment::elapsedMs() const {
if (m_startedAt <= 0)
return 0;
if (m_startedAt <= 0) return 0;
const qint64 end = m_endedAt > 0 ? m_endedAt
: QDateTime::currentMSecsSinceEpoch();
: QDateTime::currentMSecsSinceEpoch();
return end - m_startedAt;
}
void LlmSegment::begin() {
if (m_running)
return;
if (m_running) return;
m_running = true;
m_startedAt = QDateTime::currentMSecsSinceEpoch();
m_endedAt = 0;
@@ -54,62 +45,52 @@ void LlmSegment::close() {
Q_EMIT runningChanged();
Q_EMIT elapsedMsChanged();
}
// The segment is final; parse immediately so the UI does not wait
// out the debounce.
if (m_type == Type::Content && m_markdownDirty) {
m_markdownTimer.stop();
if (parseMarkdown())
m_markdownDirty = false;
if (parseMarkdown()) m_markdownDirty = false;
}
}
void LlmSegment::appendText(const QString& piece) {
if (piece.isEmpty())
return;
if (piece.isEmpty()) return;
m_text += piece;
Q_EMIT textChanged();
scheduleMarkdown();
}
void LlmSegment::setText(const QString& value) {
if (m_text == value)
return;
if (m_text == value) return;
m_text = value;
Q_EMIT textChanged();
scheduleMarkdown();
}
void LlmSegment::setName(const QString& value) {
if (m_name == value)
return;
if (m_name == value) return;
m_name = value;
Q_EMIT nameChanged();
}
void LlmSegment::setToolCallId(const QString& value) {
if (m_toolCallId == value)
return;
if (m_toolCallId == value) return;
m_toolCallId = value;
Q_EMIT toolCallIdChanged();
}
void LlmSegment::appendArguments(const QString& piece) {
if (piece.isEmpty())
return;
if (piece.isEmpty()) return;
m_arguments += piece;
Q_EMIT argumentsChanged();
}
void LlmSegment::setResult(const QString& value) {
if (m_result == value)
return;
if (m_result == value) return;
m_result = value;
Q_EMIT resultChanged();
}
void LlmSegment::setStatus(Status value) {
if (m_status == value)
return;
if (m_status == value) return;
m_status = value;
Q_EMIT statusChanged();
}
@@ -126,39 +107,27 @@ void LlmSegment::restore(qint64 elapsedMs) {
}
void LlmSegment::scheduleMarkdown() {
// Content segments only; reasoning/tool output is never parsed.
// (User content is parsed too, so it can later be rendered as blocks
// as well; the QML currently only does that for assistant messages.)
if (m_type != Type::Content)
return;
if (m_type != Type::Content) return;
m_markdownDirty = true;
if (!m_markdownTimer.isActive())
m_markdownTimer.start();
if (!m_markdownTimer.isActive()) m_markdownTimer.start();
}
bool LlmSegment::parseMarkdown() {
if (m_parseInFlight)
return false;
if (m_parseInFlight) return false;
m_parseInFlight = true;
const QString text = m_text;
QThreadPool::globalInstance()->start([this, text]() {
const QVariantList blocks = MarkdownParser::parse(text);
// Deliver through qApp (never destroyed) and re-check the
// pointer on the GUI thread: posting to `this` from the pool
// thread would race with its destruction.
QPointer<LlmSegment> guard(this);
QMetaObject::invokeMethod(
QCoreApplication::instance(),
[guard, blocks]() {
LlmSegment* seg = guard;
if (!seg)
return;
if (!seg) return;
seg->m_parseInFlight = false;
seg->m_markdown = blocks;
Q_EMIT seg->markdownChanged();
// Text arrived while the worker was running; parse it.
if (seg->m_markdownDirty)
seg->parseMarkdown();
if (seg->m_markdownDirty) seg->parseMarkdown();
},
Qt::QueuedConnection);
});
+4 -25
View File
@@ -8,10 +8,6 @@
namespace ZShell::llm {
// One unit of activity within a ChatGeneration. A generation keeps its
// segments in chronological order: zero or more reasoning bursts and
// tool calls interleaved, plus at most one content segment holding the
// final answer.
class LlmSegment : public QObject {
Q_OBJECT
QML_ELEMENT
@@ -27,26 +23,15 @@ class LlmSegment : public QObject {
Q_PROPERTY(Status status READ status NOTIFY statusChanged)
Q_PROPERTY(bool running READ running NOTIFY runningChanged)
Q_PROPERTY(qint64 elapsedMs READ elapsedMs NOTIFY elapsedMsChanged)
// Parsed markdown blocks (QVariantList of maps, see MarkdownParser).
// Only Content segments are parsed. Refreshes are debounced so a
// streaming segment re-parses at a steady cadence rather than per
// chunk.
Q_PROPERTY(QVariantList markdown READ markdown NOTIFY markdownChanged)
public:
enum class Type : int {
Reasoning = 0,
ToolCall,
Content
};
enum class Type : int { Reasoning = 0, ToolCall, Content };
Q_ENUM(Type)
enum class Status : int {
None = 0,
Running,
Success,
Error
};
// Pending is appended last: statuses are persisted as integers in the
// chat database, so existing values must not move.
enum class Status : int { None = 0, Running, Success, Error, Pending };
Q_ENUM(Status)
explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
@@ -63,9 +48,7 @@ class LlmSegment : public QObject {
[[nodiscard]] qint64 elapsedMs() const;
[[nodiscard]] QVariantList markdown() const { return m_markdown; }
// Starts the segment's clock; a no-op while already running.
void begin();
// Stops the segment's clock; a no-op when not running.
void close();
void appendText(const QString& piece);
void setText(const QString& value);
@@ -74,9 +57,7 @@ class LlmSegment : public QObject {
void appendArguments(const QString& piece);
void setResult(const QString& value);
void setStatus(Status value);
// Completes a tool call with the model-facing result text.
void finishTool(const QString& resultText, bool success);
// Restores persisted timing without a live clock.
void restore(qint64 elapsedMs);
Q_SIGNALS:
@@ -92,8 +73,6 @@ class LlmSegment : public QObject {
private:
void scheduleMarkdown();
// Kicks off an off-thread parse; returns false when one is already
// in flight (the pending change is picked up when it completes).
bool parseMarkdown();
Type m_type;
+3 -6
View File
@@ -145,10 +145,10 @@ void ChatSession::startGeneration(ChatMessage* target) {
if (isLoaded()) {
clientObject->startGeneration(this, generation);
} else {
// The store load is still in flight; the request needs the
// full history, so start once it lands.
connect(
this, &ChatSession::loaded, clientObject,
this,
&ChatSession::loaded,
clientObject,
[this, generation, clientObject]() {
clientObject->startGeneration(this, generation);
});
@@ -192,8 +192,6 @@ void ChatSession::retry(ChatMessage* target) {
const int row = m_model->rowOf(target);
if (row < 0) return;
// Drop everything newer than the target, then regenerate from the
// context ending at the user message before it.
m_model->removeRange(0, row - 1);
if (m_model->rowCount() < 2) return;
@@ -233,7 +231,6 @@ void ChatSession::clear() {
}
}
if (!isLoaded()) {
// The load lands shortly; drop everything once it does.
m_clearPending = true;
return;
}
-7
View File
@@ -44,14 +44,9 @@ class ChatSession : public QObject {
[[nodiscard]] qint64 updatedAtMs() const { return m_updatedAt; }
[[nodiscard]] bool pinned() const { return m_pinned; }
[[nodiscard]] int messageCount() const { return m_messageCount; }
// The messages model; the first access starts the (async) load
// from the store.
[[nodiscard]] ChatMessageModel* messagesModel();
void ensureLoaded();
// True once the async load from the store has finished.
[[nodiscard]] bool isLoaded() const { return m_loaded; }
// The model without triggering a load (ChatStore use during the
// load itself).
[[nodiscard]] ChatMessageModel* model() const { return m_model; }
void markLoaded();
[[nodiscard]] bool takeClearPending();
@@ -71,7 +66,6 @@ class ChatSession : public QObject {
qint64 updatedAt,
int messageCount);
// Replaces the model's rows with `messages` (most recent first).
void adoptMessages(QList<ChatMessage*> messages);
void persist();
void removeMessage(ChatMessage* message);
@@ -88,7 +82,6 @@ class ChatSession : public QObject {
void updatedAtChanged();
void pinnedChanged();
void messageCountChanged();
// The messages finished loading from the store.
void loaded();
private:
+26 -8
View File
@@ -6,6 +6,20 @@ LlmTool::LlmTool(QObject* parent) : QObject(parent) {}
LlmTool::~LlmTool() = default;
void LlmTool::setContextSize(int value) {
if (m_contextSize == value) return;
m_contextSize = value;
}
int LlmTool::inlineBudgetChars() const {
if (m_contextSize <= 0) return DefaultInlineChars;
// About a quarter of the context window, clamped so tiny contexts
// still get a usable result and huge ones stay sane.
const int tokens =
qBound(512, m_contextSize / 4, DefaultInlineChars / CharsPerToken);
return tokens * CharsPerToken;
}
void LlmTool::cancel() {}
QJsonObject LlmTool::specification() const {
@@ -22,29 +36,26 @@ QJsonObject LlmTool::specification() const {
ToolRegistry::ToolRegistry(QObject* parent) : QObject(parent) {}
void ToolRegistry::setEnabled(bool value) {
if (m_enabled == value)
return;
if (m_enabled == value) return;
m_enabled = value;
Q_EMIT enabledChanged();
}
void ToolRegistry::registerTool(LlmTool* tool) {
if (!tool || m_tools.contains(tool))
return;
if (!tool || m_tools.contains(tool)) return;
tool->setParent(this);
tool->setContextSize(m_contextSize);
m_tools.append(tool);
}
LlmTool* ToolRegistry::tool(const QString& name) const {
for (const auto* tool : m_tools)
if (tool->name() == name)
return const_cast<LlmTool*>(tool);
if (tool->name() == name) return const_cast<LlmTool*>(tool);
return nullptr;
}
QJsonArray ToolRegistry::specifications() const {
if (!m_enabled)
return {};
if (!m_enabled) return {};
QJsonArray specs;
for (const auto* tool : m_tools)
specs.append(tool->specification());
@@ -56,4 +67,11 @@ void ToolRegistry::cancelAll() {
tool->cancel();
}
void ToolRegistry::setContextSize(int value) {
if (m_contextSize == value) return;
m_contextSize = value;
for (auto* tool : m_tools)
tool->setContextSize(value);
}
} // namespace ZShell::llm
+25 -14
View File
@@ -10,9 +10,6 @@
namespace ZShell::llm {
// A capability the model may invoke mid-turn. Tools run asynchronously
// and report exactly one result: `{"output": ...}` on success or
// `{"error": ...}` on failure.
class LlmTool : public QObject {
Q_OBJECT
@@ -22,24 +19,37 @@ class LlmTool : public QObject {
[[nodiscard]] virtual QString name() const = 0;
[[nodiscard]] virtual QString description() const = 0;
// JSON Schema describing the tool's `arguments` object.
[[nodiscard]] virtual QJsonObject parameters() const = 0;
// Runs the tool; `done` is invoked exactly once, with
// `{"output": ...}` on success or `{"error": ...}` on failure.
// `done` must be invoked asynchronously (on a later event loop
// iteration), never synchronously within execute().
// toolCallId identifies the call within the conversation; tools may use
// it to name files they write for later retrieval.
virtual void execute(
const QString& toolCallId,
const QJsonObject& args,
std::function<void(const QJsonObject& result)> done) = 0;
// Abandons in-flight work, if any.
virtual void cancel();
// The OpenAI-compatible `tools` entry for this tool.
[[nodiscard]] QJsonObject specification() const;
// The endpoint's context size in tokens (0 = unknown). Set by LlmClient.
[[nodiscard]] int contextSize() const { return m_contextSize; }
void setContextSize(int value);
// Rough cap, in characters, for tool output embedded in a tool result
// message. Scales with the context size so a single result cannot
// consume the whole window.
[[nodiscard]] int inlineBudgetChars() const;
// ~4 characters per token; a deliberately coarse estimate used for
// budgeting (never for exact accounting).
static constexpr int CharsPerToken = 4;
// Inline budget when the context size is unknown.
static constexpr int DefaultInlineChars = 64 * 1024;
protected:
int m_contextSize = 0;
};
// Owns the set of tools available to the model.
class ToolRegistry : public QObject {
Q_OBJECT
@@ -51,19 +61,20 @@ class ToolRegistry : public QObject {
[[nodiscard]] bool enabled() const { return m_enabled; }
void setEnabled(bool value);
// Takes ownership; tools become children of the registry.
void registerTool(LlmTool* tool);
[[nodiscard]] LlmTool* tool(const QString& name) const;
// The request body's `tools` array; empty while disabled.
[[nodiscard]] QJsonArray specifications() const;
// Abandons in-flight work in every tool.
void cancelAll();
// Propagated to every registered tool, including later ones.
void setContextSize(int value);
Q_SIGNALS:
void enabledChanged();
private:
bool m_enabled = true;
int m_contextSize = 0;
QList<LlmTool*> m_tools;
};
+136 -96
View File
@@ -1,5 +1,7 @@
#include "webfetchtool.hpp"
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QNetworkReply>
#include <QNetworkRequest>
@@ -8,6 +10,9 @@
namespace ZShell::llm {
const QString WebFetchTool::StoragePath =
QStringLiteral("/tmp/zshell-llm/webfetch");
namespace {
const char* kUserAgent =
@@ -32,12 +37,9 @@ WebFetchTool::WebFetchTool(QObject* parent) : LlmTool(parent) {}
WebFetchTool::~WebFetchTool() {
for (auto* job : m_jobs) {
if (job->timer)
job->timer->stop();
if (job->reply)
job->reply->abort();
if (job->timer) job->timer->stop();
if (job->reply) job->reply->abort();
}
// Pending result callbacks are dropped; the client is going away.
qDeleteAll(m_jobs);
}
@@ -49,7 +51,10 @@ QString WebFetchTool::description() const {
return QStringLiteral(
"Fetch content from an HTTP or HTTPS URL and return it as plain "
"text or raw HTML. HTML pages are reduced to their visible text "
"by default. This tool is read-only.");
"by default. Only a limited amount of content is returned inline; "
"the full output of every call is saved under "
"/tmp/zshell-llm/webfetch/, where it can be paged through with "
"the readfile tool. This tool is read-only.");
}
QJsonObject WebFetchTool::parameters() const {
@@ -64,9 +69,9 @@ QJsonObject WebFetchTool::parameters() const {
QJsonObject format;
format[QStringLiteral("type")] = QStringLiteral("string");
format[QStringLiteral("enum")] = formats;
format[QStringLiteral("description")] =
QStringLiteral("The format to return the content in. Defaults to "
"text.");
format[QStringLiteral("description")] = QStringLiteral(
"The format to return the content in. Defaults to "
"text.");
QJsonObject timeout;
timeout[QStringLiteral("type")] = QStringLiteral("integer");
@@ -90,18 +95,14 @@ QJsonObject WebFetchTool::parameters() const {
}
void WebFetchTool::completeJob(Job* job, QJsonObject result) {
// Deliver on a later event loop iteration; LlmClient relies on tool
// results never arriving synchronously within execute().
QMetaObject::invokeMethod(
this,
[this, job, result = std::move(result)]() mutable {
if (!m_jobs.contains(job))
return;
if (!m_jobs.contains(job)) return;
auto done = std::move(job->done);
m_jobs.removeAll(job);
job->timer->deleteLater();
if (job->reply)
job->reply->deleteLater();
if (job->reply) job->reply->deleteLater();
delete job;
done(std::move(result));
},
@@ -109,9 +110,12 @@ void WebFetchTool::completeJob(Job* job, QJsonObject result) {
}
void WebFetchTool::execute(
const QJsonObject& args, std::function<void(const QJsonObject&)> done) {
const QString& toolCallId,
const QJsonObject& args,
std::function<void(const QJsonObject&)> done) {
auto* job = new Job;
job->done = std::move(done);
job->toolCallId = toolCallId;
m_jobs.append(job);
auto fail = [this, job](const QString& message) {
@@ -126,7 +130,7 @@ void WebFetchTool::execute(
return;
}
if (url.scheme() != QLatin1String("http") &&
url.scheme() != QLatin1String("https")) {
url.scheme() != QLatin1String("https")) {
fail(QStringLiteral("URL must use http:// or https://"));
return;
}
@@ -136,20 +140,18 @@ void WebFetchTool::execute(
if (job->format != QLatin1String("html"))
job->format = QStringLiteral("text");
const int timeoutMs = qBound(
1,
args[QStringLiteral("timeout")].toInt(
DefaultTimeoutSeconds),
MaxTimeoutSeconds) *
1000;
const int timeoutMs =
qBound(
1,
args[QStringLiteral("timeout")].toInt(DefaultTimeoutSeconds),
MaxTimeoutSeconds) *
1000;
job->timer = new QTimer(this);
job->timer->setSingleShot(true);
connect(
job->timer, &QTimer::timeout, this, [this, job]() {
if (job->reply)
job->reply->abort();
});
connect(job->timer, &QTimer::timeout, this, [this, job]() {
if (job->reply) job->reply->abort();
});
job->timer->start(timeoutMs);
QNetworkRequest request(url);
@@ -159,13 +161,12 @@ void WebFetchTool::execute(
job->format == QLatin1String("html")
? "text/html;q=1.0, application/xhtml+xml;q=0.9, */*;q=0.1"
: "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, "
"*/*;q=0.1");
"*/*;q=0.1");
request.setRawHeader("Accept-Language", "en-US,en;q=0.9");
job->reply = m_manager.get(request);
connect(job->reply, &QNetworkReply::readyRead, this, [this, job]() {
if (!job->reply)
return;
if (!job->reply) return;
job->body += job->reply->readAll();
if (job->body.size() > MaxResponseBytes) {
job->tooLarge = true;
@@ -176,8 +177,7 @@ void WebFetchTool::execute(
QNetworkReply* reply = job->reply;
job->reply = nullptr;
job->timer->stop();
if (!reply)
return;
if (!reply) return;
const QNetworkReply::NetworkError error = reply->error();
const QString errorString = reply->errorString();
@@ -187,13 +187,14 @@ void WebFetchTool::execute(
const QByteArray contentType =
reply->rawHeader("Content-Type").toLower();
const int status =
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute)
.toInt();
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (job->tooLarge) {
completeJob(job, makeError(
QStringLiteral("Response too large (exceeds the 5 MB "
"limit")));
completeJob(
job,
makeError(QStringLiteral(
"Response too large (exceeds the 5 MB "
"limit")));
return;
}
if (error != QNetworkReply::NoError) {
@@ -207,25 +208,23 @@ void WebFetchTool::execute(
completeJob(
job,
makeError(
QStringLiteral("Server returned status %1")
.arg(status)));
QStringLiteral("Server returned status %1").arg(status)));
return;
}
const QString mime =
QString::fromLatin1(contentType).section(QLatin1Char(';'), 0, 0)
.trimmed();
const QString mime = QString::fromLatin1(contentType)
.section(QLatin1Char(';'), 0, 0)
.trimmed();
if (mime.startsWith(QLatin1String("image/"))) {
completeJob(
job,
makeError(
QStringLiteral(
"Unsupported fetched image content type: %1")
QStringLiteral("Unsupported fetched image content type: %1")
.arg(mime)));
return;
}
const bool textual = mime.isEmpty() ||
mime.startsWith(QLatin1String("text/")) ||
const bool textual =
mime.isEmpty() || mime.startsWith(QLatin1String("text/")) ||
mime == QLatin1String("application/json") ||
mime.endsWith(QLatin1String("+json")) ||
mime == QLatin1String("application/xml") ||
@@ -236,34 +235,82 @@ void WebFetchTool::execute(
completeJob(
job,
makeError(
QStringLiteral(
"Unsupported fetched file content type: %1")
QStringLiteral("Unsupported fetched file content type: %1")
.arg(mime)));
return;
}
QString content = QString::fromUtf8(body);
if (mime.contains(QLatin1String("text/html")) &&
job->format == QLatin1String("text"))
job->format == QLatin1String("text"))
content = extractTextFromHtml(content);
if (content.size() > MaxOutputChars)
content = content.left(MaxOutputChars) +
QStringLiteral("\n[... truncated ...]");
completeJob(job, makeOutput(content));
const QString savedPath = saveToFile(*job, content, mime);
QString output = content;
const int budget = inlineBudgetChars();
if (content.size() > budget) {
output = content.left(budget);
const QString note = QStringLiteral(
"\n\n[... truncated: showing %1 of %2 characters")
.arg(budget)
.arg(content.size());
if (!savedPath.isEmpty())
output += note +
QStringLiteral(
". The full content is saved to %1; use "
"the readfile tool to read the rest.")
.arg(savedPath);
else
output += note + QStringLiteral(
". The remaining content is not "
"available.");
}
completeJob(job, makeOutput(output));
});
}
QString WebFetchTool::saveToFile(
const Job& job, const QString& content, const QString& mime) const {
QDir dir(StoragePath);
if (!dir.exists() && !dir.mkpath(QStringLiteral(".")))
return QString();
QString fileName;
fileName.reserve(job.toolCallId.size());
for (const QChar& c : job.toolCallId)
fileName += (c.isLetterOrNumber() || c == QLatin1Char('-') ||
c == QLatin1Char('_'))
? c
: QLatin1Char('_');
if (fileName.isEmpty()) fileName = QStringLiteral("fetch");
QString extension = QStringLiteral("txt");
if (job.format == QLatin1String("html"))
extension = QStringLiteral("html");
else if (mime.contains(QLatin1String("json")))
extension = QStringLiteral("json");
else if (mime.contains(QLatin1String("xml")))
extension = QStringLiteral("xml");
const QString path =
dir.filePath(fileName + QLatin1Char('.') + extension);
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
return QString();
file.write(content.toUtf8());
return path;
}
void WebFetchTool::cancel() {
for (auto* job : m_jobs) {
job->timer->stop();
if (job->reply)
job->reply->abort();
if (job->reply) job->reply->abort();
}
}
QString WebFetchTool::decodeEntities(const QString& text) {
if (!text.contains(QLatin1Char('&')))
return text;
if (!text.contains(QLatin1Char('&'))) return text;
QString out;
out.reserve(text.size());
for (qsizetype i = 0; i < text.size(); ++i) {
@@ -292,10 +339,11 @@ QString WebFetchTool::decodeEntities(const QString& text) {
replacement = QLatin1Char(' ');
else {
bool ok = false;
const quint32 codePoint = entity.startsWith(QLatin1String("#x")) ||
entity.startsWith(QLatin1String("#X"))
? entity.mid(2).toUInt(&ok, 16)
: entity.toUInt(&ok);
const quint32 codePoint =
entity.startsWith(QLatin1String("#x")) ||
entity.startsWith(QLatin1String("#X"))
? entity.mid(2).toUInt(&ok, 16)
: entity.toUInt(&ok);
if (ok && codePoint != 0) {
const char32_t ucs4[2] = {
static_cast<char32_t>(codePoint),
@@ -326,12 +374,18 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) {
QStringLiteral("style"),
};
static const QSet<QString> kVoidTags = {
QStringLiteral("area"), QStringLiteral("base"),
QStringLiteral("br"), QStringLiteral("col"),
QStringLiteral("embed"), QStringLiteral("hr"),
QStringLiteral("img"), QStringLiteral("input"),
QStringLiteral("link"), QStringLiteral("meta"),
QStringLiteral("source"), QStringLiteral("track"),
QStringLiteral("area"),
QStringLiteral("base"),
QStringLiteral("br"),
QStringLiteral("col"),
QStringLiteral("embed"),
QStringLiteral("hr"),
QStringLiteral("img"),
QStringLiteral("input"),
QStringLiteral("link"),
QStringLiteral("meta"),
QStringLiteral("source"),
QStringLiteral("track"),
QStringLiteral("wbr"),
};
@@ -342,55 +396,43 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) {
while (i < html.size()) {
const qsizetype open = html.indexOf(QLatin1Char('<'), i);
if (open < 0) {
if (skipDepth == 0)
text += html.mid(i);
if (skipDepth == 0) text += html.mid(i);
break;
}
if (skipDepth == 0)
text += html.mid(i, open - i);
if (skipDepth == 0) text += html.mid(i, open - i);
const qsizetype close = html.indexOf(QLatin1Char('>'), open);
if (close < 0)
break;
if (close < 0) break;
const QString tag =
html.mid(open + 1, close - open - 1).trimmed().toLower();
i = close + 1;
if (tag.startsWith(QLatin1Char('!')) ||
tag.startsWith(QLatin1Char('?')))
tag.startsWith(QLatin1Char('?')))
continue;
QString name = tag;
if (name.startsWith(QLatin1Char('/'))) {
if (skipDepth > 0)
--skipDepth;
if (skipDepth > 0) --skipDepth;
continue;
}
qsizetype j = 0;
while (j < name.size() &&
(name.at(j).isLetterOrNumber() ||
name.at(j) == QLatin1Char(':') ||
name.at(j) == QLatin1Char('-')))
while (j < name.size() && (name.at(j).isLetterOrNumber() ||
name.at(j) == QLatin1Char(':') ||
name.at(j) == QLatin1Char('-')))
++j;
name = name.left(j);
if (kRawTags.contains(name)) {
// Raw-text element: swallow everything up to its close tag.
const qsizetype rawEnd =
html.indexOf(QStringLiteral("</") + name, i,
Qt::CaseInsensitive);
if (rawEnd < 0)
break;
const qsizetype rawEnd = html.indexOf(
QStringLiteral("</") + name, i, Qt::CaseInsensitive);
if (rawEnd < 0) break;
const qsizetype rawClose = html.indexOf(QLatin1Char('>'), rawEnd);
if (rawClose < 0)
break;
if (rawClose < 0) break;
i = rawClose + 1;
continue;
}
if (kVoidTags.contains(name))
continue;
if (kVoidTags.contains(name)) continue;
if (skipDepth > 0) {
// Browsers implicitly close <head> at <body>; malformed pages
// without a </head> would otherwise swallow the whole page.
if (name == QLatin1String("body")) {
skipDepth = 0;
continue;
@@ -402,7 +444,6 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) {
++skipDepth;
continue;
}
// Normal tag: replace with a space so words do not merge.
text += QLatin1Char(' ');
}
@@ -411,8 +452,7 @@ QString WebFetchTool::extractTextFromHtml(const QString& html) {
for (const QString& line : out.split(QLatin1Char('\n'))) {
const QString flat = line.simplified();
if (flat.isEmpty()) {
if (!lines.isEmpty() && lines.last().isEmpty())
continue;
if (!lines.isEmpty() && lines.last().isEmpty()) continue;
lines.append(QString());
} else {
lines.append(flat);

Some files were not shown because too many files have changed in this diff Show More