Author SHA1 Message Date
zach a3178ad9cd Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Successful in 6s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Failing after 11s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 1m3s
Python / verify (pull_request) Successful in 1m42s
Rust / fmt (pull_request) Successful in 33s
Rust / clippy (pull_request) Successful in 1m4s
2026-09-14 09:52:08 +02:00
zach 82ca5b5335 fix: properly convert vram mib -> kib & generic gpu vram reading 2026-09-13 22:25:58 +02:00
zach 02254a10d8 gitignore
C++ / fmt (pull_request) Successful in 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Failing after 11s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 38s
Rust / build (pull_request) Successful in 56s
Python / verify (pull_request) Successful in 1m45s
Rust / fmt (pull_request) Successful in 33s
Rust / clippy (pull_request) Successful in 1m0s
2026-09-12 23:58:19 +02:00
zach 54d4676ddf Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Successful in 4s
C++ / build (pull_request) Failing after 2m3s
C++ / clang-tidy (pull_request) Failing after 2m2s
JS/TS / fmt (pull_request) Failing after 12s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 29s
Rust / build (pull_request) Successful in 51s
Python / verify (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 33s
Rust / clippy (pull_request) Successful in 57s
2026-09-04 19:44:12 +02:00
zach bd699e72a7 fix: polkit width 2026-09-04 19:43:13 +02:00
zach cd3da1941b fix: truncate context logic
C++ / fmt (pull_request) Successful in 4s
C++ / build (pull_request) Failing after 2m6s
C++ / clang-tidy (pull_request) Failing after 2m5s
JS/TS / fmt (pull_request) Failing after 3m8s
Python / static (pull_request) Successful in 24s
Python / verify (pull_request) Successful in 1m21s
JS/TS / lint (pull_request) Successful in 5m10s
Rust / fmt (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 56s
Rust / clippy (pull_request) Successful in 46s
2026-09-04 02:20:37 +02:00
zach 34dde4bbfb fix: tool call prompt
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 2m4s
C++ / clang-tidy (pull_request) Failing after 2m2s
JS/TS / fmt (pull_request) Failing after 6s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 27s
Rust / build (pull_request) Successful in 47s
Python / verify (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 27s
Rust / clippy (pull_request) Successful in 49s
2026-09-03 23:45:07 +02:00
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 ed7ec326b1 fix: idlemonitors list value 2026-09-03 16:15:55 +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 04869656aa Merge pull request 'changed old appearance vars for the lockscreen + warning hunting for the polkit' (#154) from fix/lockscreen-font into main
Reviewed-on: #154
Reviewed-by: AramJonghu <aramjonghu@tutamail.com>
Reviewed-by: zach <zach@brohn.se>
2026-08-31 00:14:06 +02:00
zach 2f6847ce0e Merge branch 'main' into fix/lockscreen-font
C++ / fmt (pull_request) Successful in 3s
JS/TS / fmt (pull_request) Successful in 14s
JS/TS / lint (pull_request) Successful in 20s
Python / static (pull_request) Successful in 58s
Rust / fmt (pull_request) Successful in 55s
C++ / build (pull_request) Successful in 2m31s
Rust / build (pull_request) Successful in 2m16s
Rust / clippy (pull_request) Successful in 1m33s
Python / verify (pull_request) Successful in 2m53s
C++ / clang-tidy (pull_request) Successful in 4m5s
2026-08-31 00:13:16 +02:00
zach e5e52802de test aur 2026-08-30 23:49:53 +02:00
zach 42f02f4dd2 test aur 2026-08-30 23:33:50 +02:00
zach 9df3a64815 fix: lowercase battery device state 2026-08-30 22:20:39 +02:00
Inorishio 9370284c6b changed old appearance vars for the lockscreen + warning hunting for the polkit
C++ / fmt (pull_request) Successful in 4s
JS/TS / fmt (pull_request) Successful in 14s
JS/TS / lint (pull_request) Successful in 16s
Python / static (pull_request) Successful in 1m14s
Rust / fmt (pull_request) Successful in 1m8s
Rust / build (pull_request) Successful in 2m14s
C++ / build (pull_request) Successful in 2m36s
Rust / clippy (pull_request) Successful in 1m15s
Python / verify (pull_request) Successful in 3m19s
C++ / clang-tidy (pull_request) Successful in 4m1s
2026-08-30 16:35:20 +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
zach 293bf67e09 better scrollbar in codeblocks + enter anim for delegates 2026-08-27 01:47:17 +02:00
zach c33dbc147f rotated view + wheel inverter 2026-08-26 17:10:34 +02:00
zach 5598f4f435 README.md 2026-08-23 17:01:27 +02:00
zach 7e02c87b91 README.md 2026-08-23 17:00:41 +02:00
135 changed files with 7873 additions and 3812 deletions
+2
View File
@@ -18,3 +18,5 @@ dist/
network-dev/ network-dev/
**/zshell.build/ **/zshell.build/
**/zshell.dist/ **/zshell.dist/
.opencode/
run-agent.sh
+1 -1
View File
@@ -3,7 +3,7 @@ FunctionsSpacing=true
IndentWidth=4 IndentWidth=4
MaxColumnWidth=-1 MaxColumnWidth=-1
NewlineType=native NewlineType=native
GroupAttributesTogether=true GroupAttributesTogether=false
ObjectsSpacing=true ObjectsSpacing=true
SemicolonRule=always SemicolonRule=always
SingleLineEmptyObjects=true SingleLineEmptyObjects=true
+20 -20
View File
@@ -26,22 +26,36 @@ if(NOT DEFINED VERSION)
endif() endif()
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}") set(VERSION "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}")
project(ZShell VERSION ${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}) project(ZShell VERSION ${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH})
message(STATUS "ZShell version: ${VERSION}") message(STATUS "ZShell version: ${VERSION}")
include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) 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_LIBDIR "${CMAKE_INSTALL_LIBDIR}/ZShell" CACHE STRING "Library install dir")
set(INSTALL_QMLDIR "usr/lib/qt6/qml" CACHE STRING "QML install dir") set(INSTALL_QMLDIR "${CMAKE_INSTALL_LIBDIR}/qt6/qml" CACHE STRING "QML install dir")
set(INSTALL_QSCONFDIR "etc/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir") set(INSTALL_QSCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/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_GREETERCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir")
set(CMAKE_INSTALL_MESSAGE NEVER) set(CMAKE_INSTALL_MESSAGE NEVER)
@@ -54,21 +68,7 @@ add_compile_options(
) )
if("shell" IN_LIST ENABLE_MODULES) if("cli" 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()
# Nuitka compilation # Nuitka compilation
set(ZSHELL_CLI_BUILD_DIR "${CMAKE_BINARY_DIR}/zshell-cli") set(ZSHELL_CLI_BUILD_DIR "${CMAKE_BINARY_DIR}/zshell-cli")
set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist") set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist")
-1
View File
@@ -30,7 +30,6 @@ Flickable {
onTriggered: { onTriggered: {
root.doneFakeFlick = false; root.doneFakeFlick = false;
root.returnToBounds();
} }
} }
} }
+1
View File
@@ -4,6 +4,7 @@ MouseArea {
property int scrollAccumulatedY: 0 property int scrollAccumulatedY: 0
function onWheel(event: WheelEvent): void { function onWheel(event: WheelEvent): void {
event.accepted = false;
} }
onWheel: event => { onWheel: event => {
+1 -2
View File
@@ -6,7 +6,6 @@ Rectangle {
color: "transparent" color: "transparent"
Behavior on color { Behavior on color {
CAnim { CAnim {}
}
} }
} }
+17 -15
View File
@@ -12,7 +12,7 @@ ScrollBar {
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
readonly property real axisContentPos: isHorizontal ? flickable.contentX : flickable.contentY readonly property real axisContentPos: isHorizontal ? (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 axisLength: isHorizontal ? root.width : root.height
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize) readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize) readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
@@ -24,13 +24,8 @@ ScrollBar {
readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0 readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0
enabled: !Visibilities.getForActive().isDrawing enabled: !Visibilities.getForActive().isDrawing
parent: flickable.parent implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2
anchors.left: isHorizontal ? flickable.left : undefined implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0
anchors.right: flickable.right
anchors.top: isHorizontal ? undefined : flickable.top
anchors.bottom: flickable.bottom
implicitWidth: isHorizontal ? 0 : Tokens.padding.extraSmall * 2
implicitHeight: isHorizontal ? Tokens.padding.extraSmall * 2 : 0
contentItem: Item {} contentItem: Item {}
Behavior on position { Behavior on position {
@@ -59,6 +54,7 @@ ScrollBar {
Loader { Loader {
anchors.fill: parent anchors.fill: parent
active: root.size < 1
sourceComponent: root.isHorizontal ? horizontalTrack : verticalTrack sourceComponent: root.isHorizontal ? horizontalTrack : verticalTrack
} }
@@ -106,9 +102,9 @@ ScrollBar {
var newPos = contentPosFromThumbStart(thumbStart); var newPos = contentPosFromThumbStart(thumbStart);
if (root.isHorizontal) if (root.isHorizontal)
root.flickable.contentX = newPos; root.flickable.contentX = newPos + (root.flickable.originX + (root.reversed ? root.flickable.contentWidth : 0));
else else
root.flickable.contentY = newPos; root.flickable.contentY = newPos + (root.flickable.originY + (root.reversed ? root.flickable.contentHeight : 0));
} }
function visualThumbStart() { function visualThumbStart() {
@@ -116,6 +112,17 @@ ScrollBar {
return visualPos * root.travelScale; return visualPos * root.travelScale;
} }
function onWheel(event: WheelEvent): void {
if (root.horizontal) {
event.accepted = false;
return;
}
var delta = event.angleDelta.y > 0 ? -0.1 : 0.1;
var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta));
root.position = newPos;
}
anchors.fill: parent anchors.fill: parent
cursorShape: undefined cursorShape: undefined
hoverEnabled: true hoverEnabled: true
@@ -136,10 +143,5 @@ ScrollBar {
updateFromEvent(event); updateFromEvent(event);
} }
onWheel: event => {
var delta = (root.isHorizontal ? event.angleDelta.x : event.angleDelta.y) > 0 ? -0.1 : 0.1;
var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta));
root.position = newPos;
}
} }
} }
+1
View File
@@ -12,6 +12,7 @@ TextEdit {
color: Colors.palette.m3onSurface color: Colors.palette.m3onSurface
cursorVisible: !readOnly cursorVisible: !readOnly
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
font.family: Config.appearance.font.family.sans
renderType: TextField.NativeRendering renderType: TextField.NativeRendering
selectedTextColor: color selectedTextColor: color
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4) selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
+1 -1
View File
@@ -55,7 +55,7 @@ CustomWindow {
property color surfaceColor: Colors.tPalette.m3surface property color surfaceColor: Colors.tPalette.m3surface
WlrLayershell.exclusionMode: ExclusionMode.Ignore WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings || visibilities.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top
color: "transparent" color: "transparent"
contentItem.focus: true contentItem.focus: true
+5 -10
View File
@@ -13,21 +13,17 @@ import qs.Paths
Singleton { Singleton {
id: root id: root
readonly property M3Palette current: M3Palette { readonly property M3Palette current: M3Palette {}
}
property bool currentLight property bool currentLight
property string flavour property string flavour
readonly property bool light: showPreview ? previewLight : currentLight readonly property bool light: showPreview ? previewLight : currentLight
readonly property M3Palette palette: showPreview ? preview : current readonly property M3Palette palette: showPreview ? preview : current
readonly property M3Palette preview: M3Palette { readonly property M3Palette preview: M3Palette {}
}
property bool previewLight property bool previewLight
property string scheme property string scheme
property bool showPreview property bool showPreview
readonly property M3TPalette tPalette: M3TPalette { readonly property M3TPalette tPalette: M3TPalette {}
} readonly property Transparency transparency: Transparency {}
readonly property Transparency transparency: Transparency {
}
readonly property alias wallLuminance: analyser.luminance readonly property alias wallLuminance: analyser.luminance
function alterColor(c: color, a: real, layer: int): color { function alterColor(c: color, a: real, layer: int): color {
@@ -112,8 +108,7 @@ Singleton {
function setMode(mode: string): void { function setMode(mode: string): void {
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]); Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]);
Config.general.color.mode = mode; Config.colors.mode = mode;
Config.save();
} }
function swapRG(c: color): color { function swapRG(c: color): color {
+8 -6
View File
@@ -9,13 +9,13 @@ Singleton {
id: root id: root
readonly property bool enabled: service.enabled readonly property bool enabled: service.enabled
readonly property int end: Config.general.color.scheduleHyprsunsetEnd readonly property int end: Config.display.nightlight.scheduleEnd
property bool manualToggle: false property bool manualToggle: false
readonly property int start: Config.general.color.scheduleHyprsunsetStart readonly property int start: Config.display.nightlight.scheduleStart
readonly property int temp: Config.general.color.hyprsunsetTemp readonly property int temp: Config.display.nightlight.temp
function checkStartup(): void { function checkStartup(): void {
if (!Config.general.color.scheduleHyprsunset) if (!Config.display.nightlight.schedule)
return; return;
service.apply(); service.apply();
@@ -26,12 +26,14 @@ Singleton {
service.toggle(); service.toggle();
} }
HyprsunsetManager { NightlightManager {
id: service id: service
activeAuto: Config.general.color.scheduleHyprsunset activeAuto: Config.display.nightlight.schedule
endTime: root.end endTime: root.end
startTime: root.start startTime: root.start
temp: root.temp temp: root.temp
fadeDuration: Config.display.nightlight.fadeDuration / 1000
useNativeNightlight: Config.display.nightlight.useNative
} }
} }
+6 -6
View File
@@ -11,14 +11,14 @@ import qs.Services
Singleton { Singleton {
id: root id: root
readonly property int darkEnd: Config.general.color.scheduleDarkEnd readonly property int darkEnd: Config.colors.scheduleDarkEnd
readonly property int darkStart: Config.general.color.scheduleDarkStart readonly property int darkStart: Config.colors.scheduleDarkStart
readonly property bool enabled: Config.general.color.scheduleDark && Config.general.color.schemeGeneration readonly property bool enabled: Config.colors.scheduleDark && Config.colors.schemeGen
function applyDarkMode() { function applyDarkMode() {
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "dark"]); Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "dark"]);
Config.general.color.mode = "dark"; Config.colors.mode = "dark";
Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-dark'"]); Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-dark'"]);
@@ -30,13 +30,13 @@ Singleton {
function applyLightMode() { function applyLightMode() {
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "light"]); Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "light"]);
Config.general.color.mode = "light"; Config.colors.mode = "light";
Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-light'"]); Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-light'"]);
Quickshell.execDetached(["sh", "-c", `sed -i 's/color_scheme_path=\\(.*\\)Dark.colors/color_scheme_path=\\1Light.colors/' ${Paths.home}/.config/qt6ct/qt6ct.conf`]); Quickshell.execDetached(["sh", "-c", `sed -i 's/color_scheme_path=\\(.*\\)Dark.colors/color_scheme_path=\\1Light.colors/' ${Paths.home}/.config/qt6ct/qt6ct.conf`]);
if (Config.general.color.neovimColors) if (Config.colors.neovimColors)
Quickshell.execDetached(["sed", "-i", "'s/\\(vim.cmd.colorscheme \\).*/\\1\"onelight\"/'", "~/.config/nvim/lua/config/load-colorscheme.lua"]); Quickshell.execDetached(["sed", "-i", "'s/\\(vim.cmd.colorscheme \\).*/\\1\"onelight\"/'", "~/.config/nvim/lua/config/load-colorscheme.lua"]);
} }
+3 -3
View File
@@ -38,7 +38,7 @@ Searcher {
previewPath = path; previewPath = path;
showPreview = true; showPreview = true;
if (Config.general.color.schemeGeneration) if (Config.colors.schemeGen)
previewColorsProc.running = true; previewColorsProc.running = true;
} }
@@ -62,8 +62,8 @@ Searcher {
WallpaperPath.currentWallpaperPath = path; WallpaperPath.currentWallpaperPath = path;
Quickshell.screens.forEach(n => setCrop(n.name, Qt.rect(0, 0, 1, 1), 1.0)); Quickshell.screens.forEach(n => setCrop(n.name, Qt.rect(0, 0, 1, 1), 1.0));
Quickshell.execDetached(["zshell-cli", "wallpaper", "lockscreen", "--input-image", `${root.actualCurrent}`, "--output-path", `${Paths.state}/lockscreen_bg.png`, "--blur-amount", `${Config.lock.blurAmount}`]); Quickshell.execDetached(["zshell-cli", "wallpaper", "lockscreen", "--input-image", `${root.actualCurrent}`, "--output-path", `${Paths.state}/lockscreen_bg.png`, "--blur-amount", `${Config.lock.blurAmount}`]);
if (Config.general.color.schemeGeneration) if (Config.colors.schemeGen)
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.general.color.mode}`]); Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.colors.mode}`]);
} }
function stopPreview(): void { function stopPreview(): void {
@@ -40,7 +40,7 @@ Item {
anchors.centerIn: parent anchors.centerIn: parent
sourceComponent: BatteryIcon { sourceComponent: BatteryIcon {
devState: Battery.deviceStateString devState: Battery.deviceStateString.toLowerCase()
percentage: Battery.currentPerc percentage: Battery.currentPerc
} }
} }
+18 -1
View File
@@ -25,6 +25,8 @@ CustomListView {
return [0]; return [0];
case "variant": case "variant":
return SchemeVariants.query(text); return SchemeVariants.query(text);
case "files":
return Files.query(text);
default: default:
return Apps.search(text); return Apps.search(text);
} }
@@ -33,7 +35,7 @@ CustomListView {
function stateForText(text: string): string { function stateForText(text: string): string {
const prefix = Config.launcher.actionPrefix; const prefix = Config.launcher.actionPrefix;
if (text.startsWith(prefix)) { if (text.startsWith(prefix)) {
for (const action of ["calc", "scheme", "variant"]) for (const action of ["calc", "scheme", "variant", "files"])
if (text.startsWith(`${prefix}${action} `)) if (text.startsWith(`${prefix}${action} `))
return action; return action;
@@ -163,6 +165,13 @@ CustomListView {
PropertyChanges { PropertyChanges {
root.delegate: variantItem root.delegate: variantItem
} }
},
State {
name: "files"
PropertyChanges {
root.delegate: filesItem
}
} }
] ]
transitions: Transition { transitions: Transition {
@@ -264,6 +273,14 @@ CustomListView {
} }
} }
Component {
id: filesItem
FilesItem {
list: root
}
}
Connections { Connections {
function onTextChanged() { function onTextChanged() {
root.syncDisplayText(); root.syncDisplayText();
+2
View File
@@ -85,6 +85,8 @@ Item {
} else if (text.startsWith(Config.launcher.actionPrefix)) { } else if (text.startsWith(Config.launcher.actionPrefix)) {
if (text.startsWith(`${Config.launcher.actionPrefix}calc `)) if (text.startsWith(`${Config.launcher.actionPrefix}calc `))
currentItem.onClicked(); currentItem.onClicked();
else if (text.startsWith(`${Config.launcher.actionPrefix}files `))
currentItem.onClicked();
else else
currentItem.modelData.onClicked(list.currentList); currentItem.modelData.onClicked(list.currentList);
} else { } else {
+1 -1
View File
@@ -16,7 +16,7 @@ Item {
implicitHeight: Config.launcher.sizes.itemHeight implicitHeight: Config.launcher.sizes.itemHeight
StateLayer { StateLayer {
radius: Tokens.rounding.small radius: Tokens.rounding.medium
onClicked: { onClicked: {
root.modelData?.onClicked(root.list); root.modelData?.onClicked(root.list);
+1 -1
View File
@@ -19,7 +19,7 @@ Item {
implicitHeight: Config.launcher.sizes.itemHeight implicitHeight: Config.launcher.sizes.itemHeight
StateLayer { StateLayer {
radius: Tokens.rounding.small radius: Tokens.rounding.medium
onClicked: { onClicked: {
Apps.launch(root.modelData); Apps.launch(root.modelData);
+2 -3
View File
@@ -24,7 +24,7 @@ Item {
implicitHeight: Config.launcher.sizes.itemHeight implicitHeight: Config.launcher.sizes.itemHeight
StateLayer { StateLayer {
radius: Tokens.rounding.small radius: Tokens.rounding.medium
onClicked: { onClicked: {
root.onClicked(); root.onClicked();
@@ -97,8 +97,7 @@ Item {
text: qsTr("Open in calculator") text: qsTr("Open in calculator")
Behavior on opacity { 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 implicitHeight: Config.launcher.sizes.itemHeight
StateLayer { StateLayer {
radius: Tokens.rounding.small radius: Tokens.rounding.medium
onClicked: { onClicked: {
root.modelData?.onClicked(root.list); 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;
}
}
}
+6 -6
View File
@@ -29,7 +29,7 @@ ColumnLayout {
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
color: Colors.palette.m3secondary color: Colors.palette.m3secondary
font.bold: true font.bold: true
font.family: Appearance.font.family.clock font.family: Config.appearance.font.family.clock
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
text: Time.hourStr text: Time.hourStr
} }
@@ -38,7 +38,7 @@ ColumnLayout {
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
color: Colors.palette.m3primary color: Colors.palette.m3primary
font.bold: true font.bold: true
font.family: Appearance.font.family.clock font.family: Config.appearance.font.family.clock
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
text: ":" text: ":"
} }
@@ -47,7 +47,7 @@ ColumnLayout {
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
color: Colors.palette.m3secondary color: Colors.palette.m3secondary
font.bold: true font.bold: true
font.family: Appearance.font.family.clock font.family: Config.appearance.font.family.clock
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
text: Time.minuteStr text: Time.minuteStr
} }
@@ -58,7 +58,7 @@ ColumnLayout {
Layout.topMargin: -Tokens.padding.large * 2 Layout.topMargin: -Tokens.padding.large * 2
color: Colors.palette.m3tertiary color: Colors.palette.m3tertiary
font.bold: true font.bold: true
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale) font.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale)
text: Time.format("dddd, d MMMM yyyy") text: Time.format("dddd, d MMMM yyyy")
} }
@@ -226,7 +226,7 @@ ColumnLayout {
anchors.right: parent.right anchors.right: parent.right
animateProp: "opacity" animateProp: "opacity"
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
horizontalAlignment: Qt.AlignHCenter horizontalAlignment: Qt.AlignHCenter
lineHeight: 1.2 lineHeight: 1.2
opacity: shouldBeVisible && !message.msg ? 1 : 0 opacity: shouldBeVisible && !message.msg ? 1 : 0
@@ -295,7 +295,7 @@ ColumnLayout {
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
color: Colors.palette.m3error color: Colors.palette.m3error
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
horizontalAlignment: Qt.AlignHCenter horizontalAlignment: Qt.AlignHCenter
opacity: 0 opacity: 0
+2 -1
View File
@@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import QtQuick
import ZShell.Internal import ZShell.Internal
import ZShell.Config import ZShell.Config
import qs.Helpers import qs.Helpers
@@ -35,7 +36,7 @@ Scope {
} }
Variants { Variants {
model: Config.general.idle.timeouts model: Config.general.idle.timeouts.values
IdleMonitor { IdleMonitor {
required property var modelData required property var modelData
+1 -1
View File
@@ -41,7 +41,7 @@ Item {
anchors.centerIn: parent anchors.centerIn: parent
animate: true animate: true
color: root.pam.passwd.active ? Colors.palette.m3secondary : Colors.palette.m3outline color: root.pam.passwd.active ? Colors.palette.m3secondary : Colors.palette.m3outline
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.normal font.pointSize: Tokens.font.size.normal
opacity: root.buffer ? 0 : 1 opacity: root.buffer ? 0 : 1
text: { text: {
+2 -2
View File
@@ -24,7 +24,7 @@ ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
color: Colors.palette.m3outline color: Colors.palette.m3outline
elide: Text.ElideRight elide: Text.ElideRight
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.weight: 500 font.weight: 500
text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications") text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications")
} }
@@ -66,7 +66,7 @@ ColumnLayout {
CustomText { CustomText {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
color: Colors.palette.m3outlineVariant color: Colors.palette.m3outlineVariant
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.large font.pointSize: Tokens.font.size.large
font.weight: 500 font.weight: 500
text: qsTr("No Notifications") text: qsTr("No Notifications")
@@ -1,7 +1,8 @@
import ZShell.Config
import ZShell.Llm
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import ZShell.Components
import ZShell.Config
import ZShell.Llm
import qs.Modules.Notifications.Sidebar.Chat.Content import qs.Modules.Notifications.Sidebar.Chat.Content
import qs.Components import qs.Components
import qs.Services import qs.Services
@@ -11,13 +12,81 @@ Item {
property ChatSession chatData property ChatSession chatData
property bool following: true property bool following: true
// qmlformat off
readonly property var keys: [
Qt.Key_A,
Qt.Key_B,
Qt.Key_C,
Qt.Key_D,
Qt.Key_E,
Qt.Key_F,
Qt.Key_G,
Qt.Key_H,
Qt.Key_I,
Qt.Key_J,
Qt.Key_K,
Qt.Key_L,
Qt.Key_M,
Qt.Key_N,
Qt.Key_O,
Qt.Key_P,
Qt.Key_Q,
Qt.Key_R,
Qt.Key_S,
Qt.Key_T,
Qt.Key_U,
Qt.Key_V,
Qt.Key_W,
Qt.Key_X,
Qt.Key_Y,
Qt.Key_Z,
Qt.Key_Agrave,
Qt.Key_Aacute,
Qt.Key_Acircumflex,
Qt.Key_Atilde,
Qt.Key_Adiaeresis,
Qt.Key_Aring,
Qt.Key_AE,
Qt.Key_Ccedilla,
Qt.Key_Egrave,
Qt.Key_Eacute,
Qt.Key_Ecircumflex,
Qt.Key_Ediaeresis,
Qt.Key_Igrave,
Qt.Key_Iacute,
Qt.Key_Icircumflex,
Qt.Key_Idiaeresis,
Qt.Key_ETH,
Qt.Key_Ntilde,
Qt.Key_Ograve,
Qt.Key_Oacute,
Qt.Key_Ocircumflex,
Qt.Key_Otilde,
Qt.Key_Odiaeresis,
Qt.Key_Ooblique,
Qt.Key_Ugrave,
Qt.Key_Uacute,
Qt.Key_Ucircumflex,
Qt.Key_Udiaeresis,
Qt.Key_Yacute,
Qt.Key_ydiaeresis,
Qt.Key_THORN,
Qt.Key_ssharp
]
// qmlformat on
signal requestClose signal requestClose
function scrollToBottom(): void {
Qt.callLater(list.positionViewAtBeginning);
}
function send(text: string): void { function send(text: string): void {
if (text.trim() === "") if (text.trim() === "")
return; return;
@@ -26,7 +95,20 @@ Item {
input.text = ""; input.text = "";
} }
Component.onCompleted: input.focus = true function focusInput(): void {
Qt.callLater(() => input.forceActiveFocus());
}
onChatDataChanged: {
if (chatData)
focusInput();
}
Keys.onPressed: e => {
if (root.keys.includes(e.key)) {
input.insert(input.length, e.text);
focusInput();
}
}
RowLayout { RowLayout {
id: header id: header
@@ -43,21 +125,23 @@ Item {
isRound: true isRound: true
type: IconButton.Tonal type: IconButton.Tonal
enabled: !ChatState.isWindow
visible: enabled
onClicked: root.requestClose() onClicked: root.requestClose()
} }
CustomText { CustomText {
Layout.fillWidth: true Layout.fillWidth: ChatState.isWindow ? false : true
Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter
elide: Text.ElideRight elide: Text.ElideRight
font.pointSize: Tokens.font.size.larger font.pointSize: Tokens.font.size.larger
text: qsTr(root.chatData.title) text: qsTr(root.chatData.title)
} }
} }
VerticalFadeListView { Loader {
id: list id: listLoader
property bool userScrolledUp: false
anchors.bottom: input.top anchors.bottom: input.top
anchors.bottomMargin: Tokens.spacing.medium anchors.bottomMargin: Tokens.spacing.medium
@@ -65,22 +149,58 @@ Item {
anchors.right: parent.right anchors.right: parent.right
anchors.top: header.bottom anchors.top: header.bottom
anchors.topMargin: Tokens.spacing.medium anchors.topMargin: Tokens.spacing.medium
cacheBuffer: height * 20 active: root.chatData && root.chatData.loaded
sourceComponent: VerticalFadeListView {
id: list
property bool userScrolledUp: false
property real lastCHeight: 0.0
function scrollToBottom(): void {
scrollAnim.start();
}
cacheBuffer: Math.max(height * 20, 0)
clip: true clip: true
fadeAmount: 0.05 fadeAmount: 0.05
fadeThreshold: Tokens.padding.medium fadeThreshold: Tokens.padding.medium
model: root.chatData.messagesModel model: root.chatData.messagesModel
spacing: 0 spacing: 0
verticalLayoutDirection: VerticalFadeListView.BottomToTop rotation: 180
add: Transition { add: Transition {
Anim { Anim {
from: list.width from: -100
property: "x" property: "y"
to: 0
} }
} }
delegate: MessageDelegate {}
CustomScrollBar.vertical: CustomScrollBar {
id: scrollBar
flickable: list
parent: list.parent
anchors.top: list.top
anchors.right: list.right
anchors.bottom: list.bottom
transform: Rotation {
origin.y: list.height / 2
origin.x: scrollBar.width / 2
angle: 180
axis {
y: 0
x: 1
z: 0
}
}
}
delegate: MessageDelegate {
rotation: 180
rootParent: list
}
displaced: Transition { displaced: Transition {
Anim { Anim {
property: "y" property: "y"
@@ -92,22 +212,24 @@ Item {
} }
} }
Component.onCompleted: positionViewAtBeginning() Component.onCompleted: {
onAtYEndChanged: { positionViewAtBeginning();
if (atYEnd) }
onAtYBeginningChanged: {
if (atYBeginning)
userScrolledUp = false; userScrolledUp = false;
} }
onContentHeightChanged: { onContentHeightChanged: {
if (!userScrolledUp && atYEnd) if (userScrolledUp && Chat.busy) {
root.scrollToBottom(); const delta = contentHeight - lastCHeight;
contentY += delta;
} }
onCountChanged: {
if (!userScrolledUp) lastCHeight = contentHeight;
root.scrollToBottom();
} }
onMovingChanged: { onMovingChanged: {
if (moving) if (moving)
userScrolledUp = !atYEnd; userScrolledUp = !atYBeginning;
} }
Anim { Anim {
@@ -116,7 +238,17 @@ Item {
property: "contentY" property: "contentY"
target: list target: list
type: Anim.DefaultEffects type: Anim.DefaultEffects
to: list.originY
} }
WheelInverter {
target: list
}
}
// Trigger the load even before we're ready to show the list.
Component.onCompleted: if (root.chatData)
root.chatData.messagesModel
} }
EmptyBackground { EmptyBackground {
@@ -124,7 +256,7 @@ Item {
anchors.fill: parent anchors.fill: parent
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
visible: !root.chatData visible: root.chatData.messageCount < 1
} }
Item { Item {
@@ -133,7 +265,7 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
implicitHeight: scrollToBottom.implicitHeight implicitHeight: scrollToBottom.implicitHeight
implicitWidth: scrollToBottom.implicitWidth implicitWidth: scrollToBottom.implicitWidth
scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 && list.contentHeight > list.height ? 1 : 0 scale: !listLoader.item.atYBeginning && listLoader.item.contentHeight > listLoader.item.height ? 1 : 0
visible: scale > 0 visible: scale > 0
Behavior on scale { Behavior on scale {
@@ -157,7 +289,7 @@ Item {
type: IconButton.Tonal type: IconButton.Tonal
onClicked: { onClicked: {
list.positionViewAtBeginning(); listLoader.item.scrollToBottom();
} }
} }
} }
@@ -172,11 +304,13 @@ Item {
bg.color: Colors.tPalette.m3surfaceContainerLowest bg.color: Colors.tPalette.m3surfaceContainerLowest
font.pointSize: Tokens.font.size.normal font.pointSize: Tokens.font.size.normal
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
focus: true
placeholderText: qsTr("Send a message") placeholderText: qsTr("Send a message")
sendIcon.font.pointSize: Tokens.font.size.large sendIcon.font.pointSize: Tokens.font.size.large
sendIcon.icon: "arrow_upward" sendIcon.icon: "arrow_upward"
sendIcon.padding: Tokens.padding.extraSmall sendIcon.padding: Tokens.padding.extraSmall
Component.onCompleted: root.focusInput()
Keys.onPressed: e => { Keys.onPressed: e => {
if (e.key == Qt.Key_Return) { if (e.key == Qt.Key_Return) {
if (!(e.modifiers & Qt.ShiftModifier)) { if (!(e.modifiers & Qt.ShiftModifier)) {
@@ -2,6 +2,7 @@ import QtQuick
import ZShell.Components import ZShell.Components
import ZShell.Config import ZShell.Config
import ZShell.Llm import ZShell.Llm
import qs.Effects
import qs.Components import qs.Components
import qs.Services import qs.Services
@@ -9,15 +10,20 @@ CustomClippingRect {
id: root id: root
property bool expanded: false property bool expanded: false
property bool highlighted: false
property bool slim: false
required property ChatSession modelData required property ChatSession modelData
signal clicked(content: ChatSession) signal clicked(content: ChatSession)
signal remove(content: ChatSession) signal remove(content: ChatSession)
color: Colors.tPalette.m3surfaceContainer color: highlighted ? Qt.alpha(Colors.palette.m3primaryContainer, 0.3) : Qt.alpha(Colors.tPalette.m3surfaceContainer, 0.2)
implicitHeight: { implicitHeight: {
let h = 0; let h = 0;
if (slim)
return chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2;
h += infoContainer.implicitHeight; h += infoContainer.implicitHeight;
if (expanded) if (expanded)
@@ -33,6 +39,20 @@ CustomClippingRect {
return h; return h;
} }
radius: Tokens.rounding.large radius: Tokens.rounding.large
clip: true
CustomRect {
id: borderHighlight
anchors.fill: parent
border.width: root.highlighted ? 2 : 0
border.color: Colors.palette.m3secondary
radius: root.radius - border.width + 1
Behavior on border.width {
Anim {}
}
}
Behavior on implicitHeight { Behavior on implicitHeight {
enabled: titleText.readOnly enabled: titleText.readOnly
@@ -49,6 +69,7 @@ CustomClippingRect {
anchors.topMargin: Tokens.padding.large anchors.topMargin: Tokens.padding.large
font.pointSize: Tokens.font.size.extraLarge font.pointSize: Tokens.font.size.extraLarge
text: root.modelData.icon || "check" text: root.modelData.icon || "check"
color: Colors.palette.m3onSurface
} }
Item { Item {
@@ -68,6 +89,14 @@ CustomClippingRect {
return h; return h;
} }
opacity: root.slim ? 0 : 1
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
CustomRect { CustomRect {
id: title id: title
@@ -85,7 +114,20 @@ CustomClippingRect {
} }
} }
TextEditBase { Item {
id: titleWrapper
anchors.left: parent.left
anchors.top: parent.top
anchors.right: parent.right
implicitHeight: titleText.implicitHeight + Tokens.padding.small * 2
layer.enabled: true
layer.effect: OpacityMask {
maskSource: fadeMask
}
TextFieldBase {
id: titleText id: titleText
anchors.left: parent.left anchors.left: parent.left
@@ -93,8 +135,9 @@ CustomClippingRect {
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2 anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2
font.pointSize: Tokens.font.size.normal font.pointSize: Tokens.font.size.normal
text: root.modelData.title text: root.modelData.title || qsTr("New chat")
readOnly: true readOnly: true
color: Colors.palette.m3onSurface
Behavior on anchors.leftMargin { Behavior on anchors.leftMargin {
Anim {} Anim {}
@@ -113,6 +156,34 @@ CustomClippingRect {
} }
} }
} }
}
CustomRect {
id: fadeMask
// anchors.top: titleText.top
// anchors.bottom: titleText.bottom
// anchors.left: titleText.left
// anchors.right: parent.right
anchors.fill: titleWrapper
layer.enabled: true
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
}
}
}
CustomText { CustomText {
id: created id: created
@@ -151,7 +222,7 @@ CustomClippingRect {
id: timestamp id: timestamp
anchors.left: parent.left anchors.left: parent.left
anchors.top: titleText.bottom anchors.top: title.bottom
anchors.topMargin: Tokens.spacing.extraSmall anchors.topMargin: Tokens.spacing.extraSmall
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
@@ -187,8 +258,8 @@ CustomClippingRect {
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
maximumLineCount: 1 maximumLineCount: 1
opacity: !root.expanded ? 1 : 0 opacity: !root.expanded && !root.slim ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? "" text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet")
Behavior on y { Behavior on y {
Anim {} Anim {}
@@ -213,7 +284,7 @@ CustomClippingRect {
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
maximumLineCount: 5 maximumLineCount: 5
opacity: root.expanded ? 1 : 0 opacity: root.expanded ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? "" text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? qsTr("No messages yet")
wrapMode: Text.WrapAtWordBoundaryOrAnywhere wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Behavior on y { Behavior on y {
@@ -286,7 +357,17 @@ CustomClippingRect {
implicitHeight: expandIcon.implicitHeight implicitHeight: expandIcon.implicitHeight
implicitWidth: expandIcon.implicitHeight implicitWidth: expandIcon.implicitHeight
radius: Tokens.rounding.full radius: Tokens.rounding.full
color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 3) color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
opacity: root.slim ? 0 : 1
visible: opacity > 0
enabled: visible
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
StateLayer { StateLayer {
onClicked: root.expanded = !root.expanded onClicked: root.expanded = !root.expanded
@@ -298,6 +379,7 @@ CustomClippingRect {
anchors.centerIn: parent anchors.centerIn: parent
anchors.verticalCenterOffset: root.expanded ? -1 : 1 anchors.verticalCenterOffset: root.expanded ? -1 : 1
rotation: root.expanded ? 180 : 0 rotation: root.expanded ? 180 : 0
color: Colors.palette.m3onSurface
text: "expand_more" text: "expand_more"
Behavior on anchors.verticalCenterOffset { Behavior on anchors.verticalCenterOffset {
@@ -31,7 +31,7 @@ TextAreaBase {
enabled: !root.activeFocus enabled: !root.activeFocus
manualPressOverride: tapHandler.pressed manualPressOverride: tapHandler.pressed
onClicked: root.focus = true onClicked: root.forceActiveFocus()
} }
} }
+374 -12
View File
@@ -13,9 +13,12 @@ Item {
property alias list: list property alias list: list
property alias model: list.model property alias model: list.model
property bool highlight: false
property bool slim: false
signal requestExpand
signal deleteChatRequest(content: ChatSession) signal deleteChatRequest(content: ChatSession)
signal loadChatRequest(content: ChatSession) signal loadChatRequest(content: ChatSession, index: int)
signal newChatRequest signal newChatRequest
CustomText { CustomText {
@@ -35,45 +38,404 @@ Item {
CustomListView { CustomListView {
id: list id: list
anchors.bottom: parent.bottom anchors.bottom: modelsContainer.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.top: header.bottom anchors.top: header.bottom
anchors.topMargin: Tokens.spacing.medium anchors.topMargin: Tokens.spacing.medium
cacheBuffer: height * 2
clip: true clip: true
spacing: Tokens.spacing.medium spacing: Tokens.spacing.medium
add: Transition {
Anim {
from: list.width
property: "x"
}
}
remove: Transition {
Anim {
to: list.width
property: "x"
}
Anim {
to: 0
property: "opacity"
}
}
displaced: Transition {
Anim {
property: "y"
}
}
move: Transition {
Anim {
property: "y"
}
}
delegate: ChatDelegate { delegate: ChatDelegate {
id: chat id: chat
implicitWidth: ListView.view.width required property int index
onClicked: content => root.loadChatRequest(content) implicitWidth: ListView.view.width
highlighted: root.highlight && ChatState.chatSession === modelData
slim: root.slim
onHighlightedChanged: if (highlighted)
list.currentIndex = index
onClicked: content => root.loadChatRequest(content, index)
onRemove: content => root.deleteChatRequest(content) onRemove: content => root.deleteChatRequest(content)
} }
TapHandler {
onTapped: {
root.focus = true;
ChatState.fabExpanded = false;
modelsContainer.expanded = false;
}
}
}
CustomClippingRect {
id: modelsContainer
property bool expanded: false
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: fabRoot.left
anchors.margins: Tokens.padding.small
anchors.rightMargin: Tokens.spacing.small
implicitHeight: expanded ? 400 : fabRoot.implicitHeight
radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2
function prettyModelName(): string {
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 { IconButton {
id: newChatBtn id: fabRoot
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.small
anchors.right: parent.right anchors.right: parent.right
font.pointSize: Math.round(18 * 1.2) anchors.margins: Tokens.padding.small
icon: "add"
padding: 8 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: { onClicked: {
root.newChatRequest(); if (root.slim && !ChatState.narrowSidebarExpanded) {
root.requestExpand();
return;
}
modelsContainer.expanded = false;
ChatState.fabExpanded = !ChatState.fabExpanded;
} }
Elevation { Elevation {
anchors.fill: parent anchors.fill: parent
level: newChatBtn.stateLayer.containsMouse ? 4 : 3 level: fabRoot.stateLayer.containsMouse ? 4 : 3
radius: parent.radius radius: parent.radius
z: -1 z: -1
} }
} }
ColumnLayout {
id: fabMenu
anchors.bottom: fabRoot.top
anchors.right: fabRoot.right
anchors.bottomMargin: Tokens.padding.medium
Repeater {
id: fabRep
model: ListModel {
ListElement {
name: "new chat"
icon: "add"
}
ListElement {
name: "pop out in window"
icon: "open_in_new"
}
}
CustomClippingRect {
id: fabMenuItem
required property var modelData
required property int index
Layout.alignment: Qt.AlignRight
implicitHeight: fabMenuItemInner.implicitHeight + Tokens.padding.medium * 2
radius: sLayer.pressed ? Tokens.rounding.small : implicitHeight / 2
color: Colors.palette.m3primaryContainer
visible: !(modelData.name === "pop out in window" && ChatState.isWindow)
Behavior on radius {
Anim {
type: Anim.DefaultEffects
}
}
opacity: 0
states: State {
name: "visible"
when: ChatState.fabExpanded
PropertyChanges {
fabMenuItem.implicitWidth: fabMenuItemInner.implicitWidth + Tokens.padding.large * 2
fabMenuItem.opacity: 1
fabMenuItemInner.opacity: 1
}
}
transitions: [
Transition {
to: "visible"
SequentialAnimation {
PauseAnimation {
duration: (fabRep.count - 1 - fabMenuItem.index) * 50
}
ParallelAnimation {
Anim {
property: "implicitWidth"
type: Anim.FastSpatial
}
Anim {
property: "opacity"
duration: Tokens.anim.durations.small
easing: Tokens.anim.standard
}
}
}
},
Transition {
from: "visible"
SequentialAnimation {
PauseAnimation {
duration: fabMenuItem.index * Tokens.anim.durations.small / 8
}
ParallelAnimation {
Anim {
property: "implicitWidth"
type: Anim.FastSpatial
}
Anim {
property: "opacity"
duration: Tokens.anim.durations.small
easing: Tokens.anim.standard
}
}
}
}
]
RowLayout {
id: fabMenuItemInner
anchors.centerIn: parent
spacing: Tokens.spacing.medium
opacity: 0
MaterialIcon {
text: fabMenuItem.modelData.icon
color: Colors.palette.m3onPrimaryContainer
font.pointSize: Tokens.font.size.large
fill: 1
}
CustomText {
animate: true
text: fabMenuItem.modelData.name
font.capitalization: Font.Capitalize
color: Colors.palette.m3onPrimaryContainer
Layout.preferredWidth: implicitWidth
Behavior on Layout.preferredWidth {
Anim {}
}
}
}
StateLayer {
id: sLayer
onClicked: {
const name = fabMenuItem.modelData.name;
if (name === "new chat")
root.newChatRequest();
else if (name === "pop out in window")
Detach.create();
ChatState.fabExpanded = false;
}
}
}
}
}
} }
@@ -18,45 +18,19 @@ Item {
Component.onCompleted: { Component.onCompleted: {
if (ChatState.inChat) { if (ChatState.inChat) {
stackLoader.item?.push(chatList); stack.push(chatList);
stackLoader.item?.push(chatContent, { stack.push(chatContent, {
"chatData": ChatState.chatSession "chatData": ChatState.chatSession
}); });
} else } else
stackLoader.item?.push(chatList); stack.push(chatList);
} }
Loader { StackView {
id: stackLoader
active: !ChatState.isWindow || root.width <= (ChatState.screen.width / 4)
anchors.fill: parent
sourceComponent: StackView {
id: stack id: stack
}
}
Loader {
id: sidebarViewLoader
active: ChatState.isWindow && root.width > (ChatState.screen.width / 4)
anchors.fill: parent anchors.fill: parent
sourceComponent: SidebarView {}
} }
// IconButton {
// anchors.right: parent.right
// icon: "close"
// visible: !ChatState.isWindow
//
// onClicked: {
// Detach.create();
// Visibilities.getForActive().sidebar = false;
// }
// }
Component { Component {
id: chatList id: chatList
@@ -68,8 +42,8 @@ Item {
onDeleteChatRequest: chat => { onDeleteChatRequest: chat => {
Chat.chats.remove(chat); Chat.chats.remove(chat);
} }
onLoadChatRequest: chat => { onLoadChatRequest: (chat, index) => {
stackLoader.item?.push(chatContent, { stack.push(chatContent, {
"chatData": chat "chatData": chat
}); });
ChatState.inChat = true; ChatState.inChat = true;
@@ -77,7 +51,7 @@ Item {
} }
onNewChatRequest: { onNewChatRequest: {
const data = Chat.chats.insert(); const data = Chat.chats.insert();
stackLoader.item?.push(chatContent, { stack.push(chatContent, {
"chatData": data "chatData": data
}); });
ChatState.inChat = true; ChatState.inChat = true;
@@ -91,7 +65,7 @@ Item {
ChatContent { ChatContent {
onRequestClose: { onRequestClose: {
stackLoader.item?.pop(); stack.pop();
ChatState.inChat = false; ChatState.inChat = false;
} }
} }
@@ -3,12 +3,25 @@ pragma Singleton
import QtQuick import QtQuick
import Quickshell import Quickshell
import ZShell.Llm import ZShell.Llm
import qs.Helpers
Singleton { Singleton {
id: root id: root
property ChatSession chatSession property ChatSession chatSession
readonly property string title: chatSession ? chatSession.title : "Chat"
property int currentIdx: -1
property bool inChat: false property bool inChat: false
property bool fabExpanded: false
property bool isWindow: false property bool isWindow: false
property bool isWide: false
property bool settledSlim: false
property bool settledWide: false
property bool narrowSidebarExpanded: false
property bool windowIsWide: false
property ShellScreen screen property ShellScreen screen
property bool sidebarVisible: Visibilities.getForActive().sidebar
onSidebarVisibleChanged: if (!sidebarVisible)
fabExpanded = false
} }
@@ -0,0 +1,27 @@
import Quickshell
import QtQuick
import ZShell.Config
import qs.Components
import qs.Services
TextEditBase {
id: root
color: Colors.palette.m3onSurface
readOnly: true
textFormat: Text.MarkdownText
font.pointSize: Tokens.font.size.smaller
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
onLinkActivated: link => {
Qt.openUrlExternally(link);
}
CustomMouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
cursorShape: root.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor
preventStealing: false
hoverEnabled: true
}
}
@@ -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
}
}
}
}
@@ -1,4 +1,5 @@
import QtQuick import QtQuick
import QtQuick.Effects
import QtQuick.Shapes import QtQuick.Shapes
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell import Quickshell
@@ -7,7 +8,7 @@ import ZShell.Llm
import qs.Components import qs.Components
import qs.Services import qs.Services
CustomClippingRect { CustomRect {
id: root id: root
required property string language required property string language
@@ -15,16 +16,11 @@ CustomClippingRect {
property bool copied: false property bool copied: false
property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh
property color codeHeaderColor: Colors.palette.m3outline property color codeHeaderColor: Colors.palette.m3outline
// Highlighter spans for the current code; refreshed when the code or
// its language changes. Highlighting runs off the GUI thread; the
// token drops results that arrive after the code already changed.
property var codeSpans: [] property var codeSpans: []
property int highlightToken: 0 property int highlightToken: 0
function refresh() { function refresh() {
const token = ++root.highlightToken; const token = ++root.highlightToken;
codeSpans = [];
CodeHighlighter.highlight(root.code, root.language, root, token); CodeHighlighter.highlight(root.code, root.language, root, token);
} }
@@ -84,10 +80,14 @@ CustomClippingRect {
let pos = 0; let pos = 0;
for (let i = 0; i < spans.length; i++) { for (let i = 0; i < spans.length; i++) {
const span = spans[i]; const span = spans[i];
if (span.start > pos) const start = Math.min(span.start, code.length);
out += escapeHtml(code.slice(pos, span.start)); const end = Math.min(span.start + span.length, code.length);
out += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(span.start, span.start + span.length)) + "</font>"; if (end <= pos)
pos = span.start + span.length; continue;
if (start > pos)
out += escapeHtml(code.slice(pos, start));
out += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(start, end)) + "</font>";
pos = end;
} }
if (pos < code.length) if (pos < code.length)
out += escapeHtml(code.slice(pos)); out += escapeHtml(code.slice(pos));
@@ -100,7 +100,10 @@ CustomClippingRect {
color: root.codeBackgroundColor color: root.codeBackgroundColor
radius: Tokens.rounding.medium radius: Tokens.rounding.medium
onLanguageChanged: refresh() onLanguageChanged: {
codeSpans = [];
refresh();
}
onCodeChanged: refresh() onCodeChanged: refresh()
RowLayout { RowLayout {
@@ -212,7 +215,7 @@ CustomClippingRect {
} }
} }
CustomRect { CustomClippingRect {
id: codeRect id: codeRect
anchors.left: parent.left anchors.left: parent.left
@@ -226,6 +229,21 @@ CustomClippingRect {
implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2 implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2
color: CodeColors.active.bg color: CodeColors.active.bg
CustomText {
id: code
anchors.top: parent.top
anchors.bottom: parent.bottom
x: implicitWidth * (0 - codeFlick.visibleArea.xPosition) + Tokens.padding.small
anchors.margins: Tokens.padding.small
text: root.highlightedHtml(root.code, root.codeSpans)
textFormat: Text.RichText
color: CodeColors.active.normal
layer.enabled: true
clip: false
font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small
}
Flickable { Flickable {
id: codeFlick id: codeFlick
@@ -234,15 +252,23 @@ CustomClippingRect {
CustomScrollBar.horizontal: CustomScrollBar { CustomScrollBar.horizontal: CustomScrollBar {
flickable: codeFlick flickable: codeFlick
parent: codeFlick.parent
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
} }
TextAreaBase.flickable: TextAreaBase { TextAreaBase.flickable: TextAreaBase {
id: codeText id: codeText
color: CodeColors.active.normal color: CodeColors.active.normal
layer.enabled: true
leftInset: Tokens.padding.small
clip: false
font.family: Config.appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
textFormat: Text.RichText textFormat: Text.RichText
text: root.highlightedHtml(root.code, root.codeSpans) text: code.text
readOnly: true readOnly: true
} }
} }
@@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import ZShell.Llm import ZShell.Llm
import ZShell.Config import ZShell.Config
import qs.Modules.Notifications.Sidebar.Chat
import qs.Components import qs.Components
import qs.Services import qs.Services
@@ -16,27 +17,66 @@ Item {
required property ChatMessage message required property ChatMessage message
required property ChatGeneration current 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 readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
signal edit(text: string) signal edit(text: string)
implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin implicitHeight: bubble.implicitHeight + (actionsRow.shouldBeActive ? actionsRow.implicitHeight + actionsRow.anchors.topMargin : 0)
CustomRect { CustomClippingRect {
id: bubble 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 radius: Tokens.rounding.medium
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer 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 implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2
anchors.right: root.isUser ? parent.right : undefined anchors.right: root.isUser ? parent.right : undefined
layer.enabled: false
layer.smooth: true
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 { TextEditBase {
id: msgText id: msgText
property string cachedText: root.segment.text
property bool cancelled: false
visible: root.isUser visible: root.isUser
anchors.left: parent.left anchors.left: parent.left
anchors.margins: Tokens.padding.medium anchors.margins: Tokens.padding.medium
@@ -59,27 +99,29 @@ Item {
event.accepted = true; event.accepted = true;
} }
} else if (event.key == Qt.Key_Escape) { } else if (event.key == Qt.Key_Escape) {
text = root.segment.text; cancelled = true;
readOnly = true; readOnly = true;
event.accepted = true; event.accepted = true;
} }
} }
onEditingFinished: { onEditingFinished: {
const old = root.segment.text;
if (old !== text)
root.edit(text);
readOnly = true; readOnly = true;
} }
onReadOnlyChanged: { onReadOnlyChanged: {
if (readOnly) { if (readOnly) {
animateCursor = false; animateCursor = false;
root.forceActiveFocus();
textFormat = CustomText.MarkdownText; textFormat = CustomText.MarkdownText;
if (cancelled) {
text = cachedText;
cancelled = false;
return;
}
root.edit(text);
} else { } else {
var raw = root.segment.text;
textFormat = CustomText.PlainText; textFormat = CustomText.PlainText;
text = raw; text = cachedText;
forceActiveFocus(); forceActiveFocus();
cursorPosition = text.length; cursorPosition = text.length;
animateCursor = true; animateCursor = true;
@@ -103,12 +145,13 @@ Item {
Actions { Actions {
id: actionsRow id: actionsRow
readonly property bool shouldBeActive: (root.segment.type === LlmSegment.Type.Content) && !root.segment.running && root.repeater.count === (root.index + 1)
anchors.left: parent.left anchors.left: parent.left
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
anchors.top: bubble.bottom anchors.top: bubble.bottom
visible: { opacity: shouldBeActive ? 1 : 0
return (root.segment.type === LlmSegment.Type.Content) && root.segment.status !== LlmSegment.Status.Running && root.repeater.count === (root.index + 1); visible: opacity > 0
}
anchors.topMargin: Tokens.spacing.medium anchors.topMargin: Tokens.spacing.medium
edit: msgText edit: msgText
hovered: root.hovered hovered: root.hovered
@@ -116,5 +159,9 @@ Item {
segment: root.segment segment: root.segment
current: root.current current: root.current
message: root.message message: root.message
Behavior on opacity {
Anim {}
}
} }
} }
@@ -23,7 +23,7 @@ ColumnLayout {
CustomText { CustomText {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant color: Colors.tPalette.m3onSurfaceVariant
text: qsTr("No messages yet") text: qsTr("Send a message to get started")
} }
Item { Item {
@@ -1,4 +1,5 @@
import QtQuick import QtQuick
import Quickshell
import ZShell.Config import ZShell.Config
import ZShell.Llm import ZShell.Llm
import qs.Components import qs.Components
@@ -7,7 +8,6 @@ import qs.Services
Column { Column {
id: root id: root
// Top-level markdown blocks (see MarkdownParser / LlmSegment.markdown).
required property var blocks required property var blocks
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
@@ -15,8 +15,10 @@ Column {
Repeater { Repeater {
id: blockRep id: blockRep
model: root.blocks model: ScriptModel {
values: root.blocks
objectProp: "id"
}
delegate: DelegateChooser { delegate: DelegateChooser {
role: "type" role: "type"
@@ -50,7 +52,7 @@ Column {
DelegateChoice { DelegateChoice {
roleValue: LlmMarkdown.Type.Heading roleValue: LlmMarkdown.Type.Heading
delegate: CustomText { delegate: TextEditBase {
required property var modelData required property var modelData
color: Colors.palette.m3onSurface color: Colors.palette.m3onSurface
@@ -59,10 +61,11 @@ Column {
anchors.margins: Tokens.padding.medium anchors.margins: Tokens.padding.medium
text: modelData.text text: modelData.text
textFormat: Text.MarkdownText textFormat: Text.MarkdownText
readOnly: true
font.bold: true font.bold: true
font.pointSize: { font.pointSize: {
if (modelData.level <= 1) if (modelData.level <= 1)
return Tokens.font.size.larger; return Tokens.font.size.large;
if (modelData.level === 2) if (modelData.level === 2)
return Tokens.font.size.normal; return Tokens.font.size.normal;
return Tokens.font.size.smaller; return Tokens.font.size.smaller;
@@ -74,17 +77,13 @@ Column {
DelegateChoice { DelegateChoice {
roleValue: LlmMarkdown.Type.Text roleValue: LlmMarkdown.Type.Text
delegate: CustomText { delegate: BubbleEdit {
required property var modelData required property var modelData
color: Colors.palette.m3onSurface
anchors.right: parent.right anchors.right: parent.right
anchors.left: parent.left anchors.left: parent.left
anchors.margins: Tokens.padding.medium anchors.margins: Tokens.padding.medium
text: modelData.text text: modelData.text
textFormat: Text.MarkdownText
font.pointSize: Tokens.font.size.smaller
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
} }
} }
} }
@@ -11,7 +11,6 @@ Item {
property color mathColor: Colors.palette.m3onSurface property color mathColor: Colors.palette.m3onSurface
property real fontSize: Tokens.font.size.large property real fontSize: Tokens.font.size.large
implicitWidth: Math.max(fallbackText.implicitWidth, root.width)
implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2 implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2
LlmMathText { LlmMathText {
@@ -11,13 +11,15 @@ import qs.Services
MouseArea { MouseArea {
id: root id: root
readonly property ChatGeneration current: modelData.activeGeneration property ChatGeneration current: modelData.activeGeneration
required property int index required property int index
readonly property bool isUser: modelData.role === ChatMessage.Role.User readonly property bool isUser: modelData.role === ChatMessage.Role.User
required property Item rootParent
required property ChatMessage modelData required property ChatMessage modelData
property bool reasoningExpanded: false property bool reasoningExpanded: false
readonly property var blocks: blockify(current.segments) readonly property var blocks: blockify(current.segments)
property real savedContentY: -1 property real savedOffset: -1
property bool stickActive: false
function handleReasoningToggle(expanded: bool): void { function handleReasoningToggle(expanded: bool): void {
const view = root.ListView.view; const view = root.ListView.view;
@@ -25,14 +27,16 @@ MouseArea {
return; return;
if (expanded) { if (expanded) {
root.savedContentY = view.contentY; root.savedOffset = view.contentY - root.y;
root.reasoningExpanded = true; root.reasoningExpanded = true;
} else { } else {
root.reasoningExpanded = false; root.reasoningExpanded = false;
if (root.savedContentY !== -1) { root.stickActive = false;
restoreAnim.to = root.savedContentY; settleTimer.stop();
if (!isNaN(root.savedOffset)) {
restoreAnim.to = root.y + root.savedOffset;
restoreAnim.restart(); restoreAnim.restart();
root.savedContentY = -1; root.savedOffset = NaN;
} }
} }
} }
@@ -60,20 +64,87 @@ MouseArea {
} }
} }
} }
return blocks; return blocks;
} }
implicitWidth: ListView.view.width implicitWidth: ListView.view.width
hoverEnabled: true hoverEnabled: true
preventStealing: true preventStealing: false
implicitHeight: layout.implicitHeight + Tokens.padding.medium implicitHeight: layout.implicitHeight + Tokens.padding.medium
Binding { Timer {
property: "contentY" id: settleTimer
restoreMode: Binding.RestoreNone interval: 16
target: root.ListView.view
value: root.y - Tokens.padding.large * 2 onTriggered: {
when: root.reasoningExpanded && root.ListView.view && ((root.y - Tokens.padding.large * 2) < root.ListView.view.contentY) 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
property var object: targetProperty
readonly property bool next: {
const cond = root.modelData.generations.indexOf(current) < root.modelData.generations.indexOf(changeAnim.targetValue);
return cond;
}
animation: SequentialAnimation {
ParallelAnimation {
Anim {
target: root
property: "x"
to: changeAnim.next ? root.width / 4 : -root.width / 4
from: 0
type: Anim.FastEffects
}
Anim {
target: root
property: "opacity"
from: 1
to: 0
type: Anim.FastEffects
}
}
PropertyAction {}
ParallelAnimation {
Anim {
target: root
property: "x"
from: changeAnim.next ? -root.width / 4 : root.width / 4
type: Anim.FastEffects
to: 0
}
Anim {
target: root
property: "opacity"
from: 0
type: Anim.FastEffects
to: 1
}
}
}
} }
Anim { Anim {
@@ -90,6 +161,7 @@ MouseArea {
anchors.top: parent.top anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
spacing: Tokens.spacing.medium
Repeater { Repeater {
id: segmentRep id: segmentRep
@@ -105,12 +177,10 @@ MouseArea {
roleValue: "process" roleValue: "process"
delegate: ProcessBlock { delegate: ProcessBlock {
required property int index
required property var modelData
segments: modelData.segments
isActive: index === root.blocks.length - 1
width: root.width width: root.width
blocks: root.blocks
current: root.current
rootParent: root.rootParent
onExpandedChanged: root.handleReasoningToggle(expanded) onExpandedChanged: root.handleReasoningToggle(expanded)
} }
@@ -1,6 +1,8 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import ZShell.Blobs
import ZShell.Config import ZShell.Config
import ZShell.Components
import ZShell.Llm import ZShell.Llm
import qs.Components import qs.Components
import qs.Services import qs.Services
@@ -8,19 +10,54 @@ import qs.Services
Item { Item {
id: root id: root
required property var segments required property int index
required property bool isActive required property var modelData
required property var blocks
readonly property var segments: modelData.segments
required property Item rootParent
required property ChatGeneration current
readonly property bool isActive: index === blocks.length - 1
property bool expanded: false property bool expanded: false
readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1] 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 real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).some(s => current.pendingToolCalls.includes(s))
implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin 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 {
type: Anim.DefaultEffects
}
}
RowLayout {
id: layout
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
Item {
implicitHeight: expandBtn.implicitHeight
implicitWidth: expandBtn.implicitWidth
LoadingIndicator { LoadingIndicator {
id: spinnerReasoning id: spinnerReasoning
anchors.centerIn: expandBtn
implicitSize: collapsedText.implicitHeight implicitSize: collapsedText.implicitHeight
anchors.centerIn: parent
opacity: root.isActive ? 1 : 0 opacity: root.isActive ? 1 : 0
visible: opacity > 0 visible: opacity > 0
@@ -32,13 +69,12 @@ Item {
IconButton { IconButton {
id: expandBtn id: expandBtn
anchors.left: parent.left
anchors.verticalCenter: collapsedText.verticalCenter
font.pointSize: Tokens.font.size.large font.pointSize: Tokens.font.size.large
anchors.topMargin: 0
icon: "keyboard_arrow_down" icon: "keyboard_arrow_down"
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
anchors.centerIn: parent
opacity: root.isActive ? 0 : 1 opacity: root.isActive ? 0 : 1
enabled: opacity > 0
rotation: root.expanded ? 180 : 0 rotation: root.expanded ? 180 : 0
type: IconButton.Text type: IconButton.Text
@@ -48,19 +84,20 @@ Item {
onClicked: root.expanded = !root.expanded onClicked: root.expanded = !root.expanded
} }
}
CustomText { CustomText {
id: collapsedText id: collapsedText
anchors.left: expandBtn.right
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 0
color: Colors.palette.m3outline color: Colors.palette.m3outline
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
Layout.fillWidth: true
Layout.leftMargin: Tokens.spacing.medium
text: { text: {
if (root.isActive) { if (root.isActive) {
if (root.pendingApproval)
return qsTr("Waiting for approval...");
if (root.lastSegment.type === LlmSegment.Type.ToolCall) if (root.lastSegment.type === LlmSegment.Type.ToolCall)
return qsTr("Using %1...").arg(root.lastSegment.name); return qsTr("Using %1...").arg(root.lastSegment.name);
return qsTr("Thinking..."); return qsTr("Thinking...");
@@ -72,25 +109,277 @@ Item {
Anim {} Anim {}
} }
} }
}
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
property Item rootParent: root.rootParent
property int openHeight: toolsBg.rootParent.height - Tokens.padding.medium * 2
property int openWidth: toolsBg.rootParent.width - Tokens.padding.medium * 2
implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2
Layout.fillWidth: true
function reparentWrapper(): void {
const newParent = open ? toolsBg.rootParent : toolsBg;
const pos = toolsWrapper.mapToItem(newParent, 0, 0);
toolsWrapper.parent = newParent;
toolsWrapper.x = pos.x;
toolsWrapper.y = pos.y;
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
}
BlobGroup {
id: blobGroup
color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer
Behavior on color {
CAnim {}
}
}
MouseArea {
id: backdrop
anchors.fill: parent
enabled: false
hoverEnabled: enabled
preventStealing: true
parent: toolsBg.open ? toolsBg.rootParent : toolsBg
onClicked: toolsBg.open = false
}
Item {
id: toolsWrapper
width: toolsBg.width
height: toolBox.implicitHeight
states: State {
name: "open"
when: toolsBg.open
PropertyChanges {
backdrop.enabled: true
toolsBackground.bottomLeftRadius: Tokens.rounding.largeIncreased
toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased
toolsBackground.topRightRadius: Tokens.rounding.largeIncreased
toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased
// toolsContent.opacity: 1
toolsWrapper.height: toolsBg.openHeight
toolsWrapper.width: toolsBg.openWidth
toolsWrapper.x: (toolsBg.rootParent.width - toolsBg.openWidth) / 2
toolsWrapper.y: (toolsBg.rootParent.height - toolsBg.openHeight) / 2
elevation.opacity: 1
toolBox.opacity: 0
}
}
transitions: Transition {
id: dialogTransition
SequentialAnimation {
ScriptAction {
script: toolsBg.reparentWrapper()
}
Anim {
properties: "x,y"
}
}
PropertyAction {
property: "enabled"
}
Anim {
properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
type: Anim.DefaultEffects
}
Anim {
properties: "width,height"
}
}
Elevation {
id: elevation
anchors.fill: parent
bottomLeftRadius: toolsBackground.bottomLeftRadius
bottomRightRadius: toolsBackground.bottomRightRadius
level: 4
opacity: 0
radius: toolsBackground.radius
transform: Matrix4x4 {
matrix: toolsBackground.deformMatrix
}
}
BlobRect {
id: toolsBackground
anchors.fill: parent
bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
deformScale: 0
group: blobGroup
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
}
CustomRect {
id: toolBox
anchors.left: parent.left
anchors.right: parent.right
height: Math.min(implicitHeight, parent.height)
implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2
radius: Tokens.rounding.small
StateLayer {
onClicked: {
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
toolsBg.open = true;
}
}
ColumnLayout {
id: toolList
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Tokens.padding.medium
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 id: expandedRect
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.top: collapsedText.bottom anchors.top: layout.bottom
anchors.topMargin: Tokens.spacing.medium anchors.bottom: parent.bottom
anchors.topMargin: Tokens.spacing.small
color: Colors.palette.m3surfaceContainerLow color: Colors.palette.m3surfaceContainerLow
implicitHeight: root.expanded ? expandedContent.contentHeight + expandedContent.anchors.margins * 2 : 0 implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2
opacity: root.expanded ? 1 : 0 opacity: root.expanded ? 1 : 0
radius: Tokens.rounding.medium radius: Tokens.rounding.medium
visible: opacity > 0 visible: opacity > 0
Behavior on implicitHeight {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on opacity { Behavior on opacity {
Anim {} Anim {}
} }
@@ -133,6 +422,49 @@ Item {
CustomText { CustomText {
id: content 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.left: parent.left
anchors.leftMargin: Tokens.padding.small anchors.leftMargin: Tokens.padding.small
anchors.top: header.bottom anchors.top: header.bottom
@@ -141,7 +473,7 @@ Item {
color: Colors.palette.m3outline color: Colors.palette.m3outline
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere 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 Quickshell
import QtQuick import QtQuick
import ZShell.Config import ZShell.Config
import qs.Helpers
import qs.Services import qs.Services
Singleton { Singleton {
@@ -10,6 +11,7 @@ Singleton {
function create(parent: Item, props: var): void { function create(parent: Item, props: var): void {
chatComp.createObject(parent ?? dummy, props); chatComp.createObject(parent ?? dummy, props);
Visibilities.getForActive().sidebar = false;
ChatState.isWindow = true; ChatState.isWindow = true;
} }
@@ -31,7 +33,7 @@ Singleton {
minimumSize.height: Config.sidebar.sizes.width minimumSize.height: Config.sidebar.sizes.width
minimumSize.width: Config.sidebar.sizes.width minimumSize.width: Config.sidebar.sizes.width
surfaceFormat.opaque: false surfaceFormat.opaque: false
title: qsTr("ZShell - Chat") title: qsTr("ZShell - %1").arg(ChatState.title)
Component.onCompleted: ChatState.screen = screen Component.onCompleted: ChatState.screen = screen
onVisibleChanged: { onVisibleChanged: {
@@ -41,7 +43,7 @@ Singleton {
} }
} }
ChatPanel { SidebarView {
id: chat id: chat
anchors.fill: parent anchors.fill: parent
@@ -2,90 +2,238 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Controls
import Quickshell import Quickshell
import ZShell.Blobs
import ZShell.Config import ZShell.Config
import ZShell.Llm import ZShell.Llm
import qs.Components import qs.Components
import qs.Modules.Notifications.Sidebar.Chat.Content
import qs.Services import qs.Services
Item { Item {
id: root 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 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 readonly property bool isWide: root.width >= root.breakpoint
property bool narrowShowsSidebar: true
function closeConversation() { readonly property bool showList: isWide || !chatOpen
narrowShowsSidebar = true; readonly property bool showContent: !isWide && chatOpen
function openConversation(conv: ChatSession, index: int): void {
} }
function openConversation(conv) { BlobGroup {
currentConversation = conv; id: blobGroup
narrowShowsSidebar = false; // in narrow mode, jump to the chat
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 { ChatList {
id: sidebar id: sidebar
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: root.isWide ? undefined : parent.right
anchors.top: parent.top anchors.top: parent.top
implicitWidth: 20 anchors.left: parent.left
list.currentIndex: -1 anchors.bottom: parent.bottom
list.highlightFollowsCurrentItem: false 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 { 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 { Anim {
duration: Tokens.anim.durations.small type: Anim.DefaultEffects
easing: Tokens.anim.expressiveDefaultEffects
}
} }
} }
model: ScriptModel { model: ScriptModel {
values: Chat.chats.values values: Chat.chats.values
} }
anchors.onRightChanged: {
if (anchors.right === undefined)
implicitWidth = 20;
}
onLoadChatRequest: (chat, index) => { onLoadChatRequest: (chat, index) => {
sidebar.list.currentIndex = index; ChatState.currentIdx = index;
root.openConversation(chat); ChatState.chatSession = chat;
}
onDeleteChatRequest: chat => {
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 { CustomClippingRect {
anchors.bottom: parent.bottom anchors.fill: parent
anchors.left: sidebar.right anchors.leftMargin: invertedRect.borderLeft
anchors.right: parent.right anchors.topMargin: invertedRect.borderTop
anchors.bottomMargin: invertedRect.borderBottom
anchors.rightMargin: invertedRect.borderRight
radius: invertedRect.radius
children: [contentArea, dim]
}
CustomRect {
id: dim
anchors.fill: parent
color: Colors.palette.m3shadow
opacity: ChatState.narrowSidebarExpanded && !root.isWide ? 0.3 : 0
z: 1
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
Item {
id: contentArea
property int leftMargin: sidebar.implicitWidth
z: 0
anchors.top: parent.top anchors.top: parent.top
child: conversationView 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
}
} }
ChatContent { 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 id: conversationView
anchors.fill: root ChatContent {}
anchors.leftMargin: root.isWide ? Config.sidebar.sizes.width / 2 : 0
chatData: root.currentConversation
} }
} }
+16 -13
View File
@@ -1,7 +1,6 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import ZShell.Config import ZShell.Config
import ZShell.Llm
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import qs.Components import qs.Components
@@ -31,7 +30,7 @@ Item {
Tabs { Tabs {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: ChatState.isWindow ? 0 : implicitHeight Layout.preferredHeight: ChatState.isWindow || !Config.llm.enabled ? 0 : implicitHeight
dashState: root.props dashState: root.props
nonAnimWidth: layout.width nonAnimWidth: layout.width
visible: height > 0 visible: height > 0
@@ -53,12 +52,10 @@ Item {
x: root.props.currentTab === 0 ? 0 : -root.width x: root.props.currentTab === 0 ? 0 : -root.width
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
Behavior on x { Behavior on x {
Anim { Anim {}
}
} }
CustomRect { CustomRect {
@@ -73,24 +70,29 @@ Item {
} }
} }
Item { Loader {
id: chatPage id: chatLoader
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.top: parent.top anchors.top: parent.top
implicitWidth: parent.width active: Config.llm.enabled
width: parent.width
property bool tre: true
sourceComponent: Item {
id: chatPage
opacity: root.props.currentTab === 1 ? 1 : 0 opacity: root.props.currentTab === 1 ? 1 : 0
visible: opacity > 0 visible: opacity > 0
x: root.props.currentTab === 0 ? root.width : 0 x: root.props.currentTab === 0 ? root.width : 0
z: 1 z: 1
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
Behavior on x { Behavior on x {
Anim { Anim {}
}
} }
CustomRect { CustomRect {
@@ -104,6 +106,7 @@ Item {
} }
} }
} }
}
CustomRect { CustomRect {
Layout.fillWidth: true Layout.fillWidth: true
@@ -110,7 +110,10 @@ ColumnLayout {
onClicked: { onClicked: {
root.visibilities.sidebar = false; root.visibilities.sidebar = false;
if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); 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: { onClicked: {
root.visibilities.sidebar = false; root.visibilities.sidebar = false;
if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); 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 { Behavior on implicitHeight {
Anim { Anim {}
}
} }
model: FileSystemModel { model: FileSystemModel {
nameFilters: ["recording_*.mp4"] nameFilters: ["recording_*.mp4"]
@@ -162,8 +167,7 @@ ColumnLayout {
opacity: list.count === 0 ? 1 : 0 opacity: list.count === 0 ? 1 : 0
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
sourceComponent: ColumnLayout { sourceComponent: ColumnLayout {
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
@@ -178,16 +182,13 @@ ColumnLayout {
text: "scan_delete" text: "scan_delete"
Behavior on Layout.preferredHeight { Behavior on Layout.preferredHeight {
Anim { Anim {}
}
} }
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
Behavior on scale { Behavior on scale {
Anim { Anim {}
}
} }
} }
@@ -203,16 +204,13 @@ ColumnLayout {
text: "scan_delete" text: "scan_delete"
Behavior on Layout.preferredWidth { Behavior on Layout.preferredWidth {
Anim { Anim {}
}
} }
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
Behavior on scale { Behavior on scale {
Anim { Anim {}
}
} }
} }
+17 -20
View File
@@ -73,20 +73,21 @@ Scope {
// mask: Region { item: inputPanel } // mask: Region { item: inputPanel }
Rectangle { CustomRect {
id: inputPanel id: inputPanel
anchors.centerIn: parent anchors.centerIn: parent
color: Colors.tPalette.m3surface color: Colors.tPalette.m3surface
implicitHeight: layout.childrenRect.height + 28 implicitHeight: layout.implicitHeight + layout.anchors.margins * 2
implicitWidth: layout.childrenRect.width + 32 implicitWidth: Math.max(layout.implicitWidth + layout.anchors.margins * 2, 450)
opacity: 0 opacity: 0
radius: Tokens.rounding.small * 2 radius: Tokens.rounding.small * 2
ColumnLayout { ColumnLayout {
id: layout id: layout
anchors.centerIn: parent anchors.fill: parent
anchors.margins: Tokens.padding.medium
RowLayout { RowLayout {
id: contentRow id: contentRow
@@ -130,7 +131,7 @@ Scope {
Layout.preferredWidth: Math.min(600, contentWidth) Layout.preferredWidth: Math.min(600, contentWidth)
font.bold: true font.bold: true
font.pointSize: 16 font.pointSize: 16
text: polkitAgent.flow?.message text: polkitAgent.flow?.message ?? ""
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
} }
@@ -147,8 +148,8 @@ Scope {
TextField { TextField {
id: passInput id: passInput
Layout.fillWidth: true
Layout.preferredHeight: 40 Layout.preferredHeight: 40
Layout.preferredWidth: contentColumn.implicitWidth
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
echoMode: polkitAgent.flow?.responseVisible ? TextInput.Normal : TextInput.Password echoMode: polkitAgent.flow?.responseVisible ? TextInput.Normal : TextInput.Password
placeholderText: polkitAgent.flow?.failed ? " Incorrect Password" : " Input Password" placeholderText: polkitAgent.flow?.failed ? " Incorrect Password" : " Input Password"
@@ -168,7 +169,7 @@ Scope {
id: showPassCheckbox id: showPassCheckbox
Layout.alignment: Qt.AlignLeft Layout.alignment: Qt.AlignLeft
checked: polkitAgent.flow?.responseVisible checked: polkitAgent.flow?.responseVisible ?? false
text: "Show Password" text: "Show Password"
onCheckedChanged: { onCheckedChanged: {
@@ -189,7 +190,8 @@ Scope {
clip: true clip: true
color: Colors.tPalette.m3surfaceContainerLow color: Colors.tPalette.m3surfaceContainerLow
implicitHeight: 0 implicitHeight: 0
radius: 16 implicitWidth: textDetailsColumn.implicitWidth + textDetailsColumn.anchors.margins * 2
radius: Tokens.rounding.medium
visible: true visible: true
Behavior on open { Behavior on open {
@@ -197,7 +199,8 @@ Scope {
Anim { Anim {
property: "implicitHeight" property: "implicitHeight"
target: detailsPanel target: detailsPanel
to: !detailsPanel.open ? textDetailsColumn.childrenRect.height + 16 : 0 to: !detailsPanel.open ? textDetailsColumn.implicitHeight + Tokens.padding.small * 2 : 0
type: Anim.DefaultEffects
} }
Anim { Anim {
@@ -205,12 +208,6 @@ Scope {
target: textDetailsColumn target: textDetailsColumn
to: !detailsPanel.open ? 1 : 0 to: !detailsPanel.open ? 1 : 0
} }
Anim {
property: "scale"
target: textDetailsColumn
to: !detailsPanel.open ? 1 : 0.9
}
} }
} }
@@ -218,10 +215,9 @@ Scope {
id: textDetailsColumn id: textDetailsColumn
anchors.fill: parent anchors.fill: parent
anchors.margins: 8 anchors.margins: Tokens.padding.small
opacity: 0 opacity: 0
scale: 0.9 spacing: Tokens.spacing.small
spacing: 8
CustomText { CustomText {
text: `actionId: ${polkitAgent.flow?.actionId}` text: `actionId: ${polkitAgent.flow?.actionId}`
@@ -239,16 +235,17 @@ Scope {
Layout.preferredWidth: contentRow.implicitWidth Layout.preferredWidth: contentRow.implicitWidth
spacing: 8 spacing: 8
IconTextButton { IconButton {
id: detailsButton id: detailsButton
Layout.alignment: Qt.AlignLeft Layout.alignment: Qt.AlignLeft
horizontalPadding: Tokens.padding.medium
icon: "info" icon: "info"
inactiveColor: Colors.palette.m3surfaceContainer inactiveColor: Colors.palette.m3surfaceContainer
inactiveOnColor: Colors.palette.m3onSurface inactiveOnColor: Colors.palette.m3onSurface
isRound: true isRound: true
shapeMorph: true shapeMorph: true
text: "Details" verticalPadding: Tokens.padding.medium
onClicked: { onClicked: {
panelWindow.detailsOpen = !panelWindow.detailsOpen; panelWindow.detailsOpen = !panelWindow.detailsOpen;
+11 -3
View File
@@ -15,14 +15,17 @@ Item {
required property Component content required property Component content
required property string header required property string header
property int horizontalContentMargin property int horizontalContentMargin
required property string icon property string icon
required property string label required property string label
property alias iconLabel: openButton.iconLabel
property alias rowButton: openButton
property string subtext: "" property string subtext: ""
property bool open property bool open
property real openHeight: Math.min(rootParent.height * 0.8, 600) property real openHeight: Math.min(rootParent.height * 0.8, 600)
property real openWidth: Math.min(rootParent.width * 0.8, 400) property real openWidth: Math.min(rootParent.width * 0.8, 400)
required property Item rootParent required property Item rootParent
property bool separateContent property bool separateContent
required property string settingAnchor
property bool first: false property bool first: false
property bool last: false property bool last: false
@@ -57,6 +60,7 @@ Item {
anchors.fill: parent anchors.fill: parent
enabled: false enabled: false
hoverEnabled: enabled hoverEnabled: enabled
preventStealing: true
parent: root.open ? root.rootParent : root parent: root.open ? root.rootParent : root
onClicked: root.open = false onClicked: root.open = false
@@ -77,7 +81,8 @@ Item {
backdrop.enabled: true backdrop.enabled: true
dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased
dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased
dialogBg.radius: Tokens.rounding.largeIncreased dialogBg.topRightRadius: Tokens.rounding.largeIncreased
dialogBg.topLeftRadius: Tokens.rounding.largeIncreased
dialogContent.opacity: 1 dialogContent.opacity: 1
dialogWrapper.height: root.openHeight dialogWrapper.height: root.openHeight
dialogWrapper.width: root.openWidth dialogWrapper.width: root.openWidth
@@ -105,7 +110,7 @@ Item {
} }
Anim { Anim {
properties: "opacity,radius,bottomLeftRadius,bottomRightRadius" properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
type: Anim.DefaultEffects type: Anim.DefaultEffects
} }
@@ -172,6 +177,7 @@ Item {
sourceComponent: MouseArea { sourceComponent: MouseArea {
onWheel: event => event.accepted = true onWheel: event => event.accepted = true
preventStealing: true
ColumnLayout { ColumnLayout {
anchors.bottomMargin: Tokens.padding.largeIncreased anchors.bottomMargin: Tokens.padding.largeIncreased
@@ -224,6 +230,7 @@ Item {
text: qsTr("Cancel") text: qsTr("Cancel")
type: TextButton.Text type: TextButton.Text
verticalPadding: Tokens.padding.small verticalPadding: Tokens.padding.small
stateLayer.preventStealing: true
onClicked: { onClicked: {
root.cancelled(); root.cancelled();
@@ -237,6 +244,7 @@ Item {
isRound: true isRound: true
text: root.acceptLabel text: root.acceptLabel
type: TextButton.Text type: TextButton.Text
stateLayer.preventStealing: true
verticalPadding: Tokens.padding.small verticalPadding: Tokens.padding.small
onClicked: { onClicked: {
@@ -10,6 +10,7 @@ DialogRowButton {
required property var model required property var model
property var selectedItem property var selectedItem
property var initialSelect
function keyFor(item: var): string { function keyFor(item: var): string {
return item.id; return item.id;
@@ -91,7 +92,14 @@ DialogRowButton {
} }
onOpenChanged: { onOpenChanged: {
if (open) if (open) {
root.rootParent.interactive = false;
if (!initialSelect)
selectedItem = null; 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 { function findAnchor(item: Item, anchor: string): Item {
if (!item) if (!item)
return null; return null;
if (item.settingAnchor !== undefined && item.settingAnchor === anchor) if (item.settingAnchor !== undefined && item.settingAnchor === anchor) // qmllint disable missing-property
return item; return item;
const kids = item.children; const kids = item.children;
for (let i = 0; i < kids.length; i++) { for (let i = 0; i < kids.length; i++) {
@@ -43,8 +43,8 @@ ColumnLayout {
function highlightAnchor(anchor: string): void { function highlightAnchor(anchor: string): void {
const row = findAnchor(contentChild, anchor); const row = findAnchor(contentChild, anchor);
if (row && row.flashHighlight !== undefined) if (row && row.flashHighlight !== undefined) // qmllint disable missing-property
row.flashHighlight(); row.flashHighlight(); // qmllint disable missing-property
} }
function scrollToAnchor(anchor: string): bool { function scrollToAnchor(anchor: string): bool {
@@ -61,8 +61,8 @@ ColumnLayout {
root.animateScroll = true; root.animateScroll = true;
flickable.contentY = target; flickable.contentY = target;
Qt.callLater(() => root.animateScroll = false); Qt.callLater(() => root.animateScroll = false);
if (row.flashHighlight !== undefined) if (row.flashHighlight !== undefined) // qmllint disable missing-property
row.flashHighlight(); row.flashHighlight(); // qmllint disable missing-property
return true; return true;
} }
@@ -154,11 +154,20 @@ ColumnLayout {
Layout.fillHeight: true Layout.fillHeight: true
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: -topMargin Layout.topMargin: -topMargin
bottomMargin: Tokens.padding.extraLarge bottomMargin: Tokens.padding.extraLarge
topMargin: Tokens.padding.large
fadeAmount: 0.1
contentHeight: root.contentChild?.implicitHeight ?? 0 contentHeight: root.contentChild?.implicitHeight ?? 0
contentItem.children: [root.contentChild] contentItem.children: [root.contentChild]
fadeAmount: 0.1
topMargin: Tokens.padding.large rebound: Transition {
Anim {
properties: "x,y"
type: Anim.DefaultEffects
}
}
Behavior on contentY { Behavior on contentY {
enabled: root.animateScroll enabled: root.animateScroll
+24 -2
View File
@@ -17,6 +17,7 @@ ConnectedRect {
property alias subtext: subLabel.text property alias subtext: subLabel.text
property alias text: label.text property alias text: label.text
property string trailingIcon property string trailingIcon
property string activeItem
signal clicked(event: MouseEvent) signal clicked(event: MouseEvent)
@@ -40,15 +41,16 @@ ConnectedRect {
spacing: Tokens.spacing.medium spacing: Tokens.spacing.medium
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
MaterialIcon { MaterialIcon {
id: iconLabel id: iconLabel
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
visible: root.icon !== ""
fill: 1 fill: 1
text: root.icon
font.pointSize: Tokens.font.size.normal font.pointSize: Tokens.font.size.normal
} }
@@ -79,6 +81,26 @@ ConnectedRect {
} }
} }
Loader {
active: root.activeItem && root.subtext
asynchronous: true
visible: active
Layout.fillHeight: true
sourceComponent: CustomRect {
color: Colors.palette.m3secondaryContainer
radius: Tokens.rounding.full
implicitWidth: text.implicitWidth + Tokens.padding.medium * 2
CustomText {
id: text
anchors.centerIn: parent
color: Colors.palette.m3onSecondaryContainer
text: root.activeItem
}
}
}
Loader { Loader {
active: root.trailingIcon active: root.trailingIcon
asynchronous: true asynchronous: true
+128
View File
@@ -0,0 +1,128 @@
pragma ComponentBehavior: Bound
import Quickshell
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import qs.Components
import qs.Services
DialogRowButton {
id: root
required property var model
property var selectedItem
property var initialSelect
acceptAllowed: !!selectedItem
horizontalContentMargin: -Tokens.padding.extraSmall
separateContent: true
content: Component {
ColumnLayout {
VerticalFadeListView {
bottomMargin: Tokens.padding.large
Layout.fillHeight: true
Layout.fillWidth: true
model: ScriptModel {
values: {
const s = searchField.text;
var regex = new RegExp(s, "i");
return root.model.filter(n => regex.test(n));
}
}
spacing: 0
topMargin: Tokens.padding.large
delegate: CustomRect {
id: item
required property var modelData
readonly property bool selected: root.selectedItem === modelData
anchors.left: ListView.view.contentItem.left
anchors.margins: 1 // Gets cut off for some reason without this
anchors.right: ListView.view.contentItem.right
color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0)
implicitHeight: label.implicitHeight + Tokens.padding.small * 2
radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium
Behavior on radius {
Anim {
type: Anim.SlowEffects
}
}
StateLayer {
id: stateLayer
onClicked: root.selectedItem = item.modelData
}
CustomText {
id: label
anchors.left: parent.left
anchors.margins: Tokens.padding.large
anchors.right: item.selected ? checkIcon.left : parent.right
anchors.rightMargin: item.selected ? Tokens.spacing.medium : anchors.margins
anchors.verticalCenter: parent.verticalCenter
color: item.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
elide: Text.ElideRight
font.pointSize: Tokens.font.size.smaller
text: item.modelData
}
MaterialIcon {
id: checkIcon
anchors.margins: Tokens.padding.large
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
color: Colors.palette.m3onTertiaryContainer
font.pointSize: Tokens.font.size.normal
opacity: item.selected ? 1 : 0
text: "check"
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
}
}
}
SearchBar {
id: searchField
Layout.fillWidth: true
bg.border.color: Colors.palette.m3outlineVariant
bg.color: Colors.tPalette.m3surfaceContainerLowest
clearIcon.font.pointSize: Tokens.font.size.large
clearIcon.padding: Tokens.padding.extraSmall
font.pointSize: Tokens.font.size.small
placeholderText: qsTr("Search...")
searchIcon.anchors.leftMargin: Tokens.padding.large
searchIcon.font.pointSize: Tokens.font.size.large
Behavior on bg.border.color {
CAnim {}
}
}
}
}
onOpenChanged: {
if (open) {
root.rootParent.interactive = false;
if (!initialSelect)
selectedItem = null;
else
selectedItem = initialSelect;
} else {
root.rootParent.interactive = true;
}
}
}
+246
View File
@@ -0,0 +1,246 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import ZShell.Blobs
import qs.Components
import ZShell.Config
import qs.Services
Item {
id: root
property bool acceptAllowed: true
required property string acceptLabel
required property Component content
required property string header
property int horizontalContentMargin
property string icon
required property string label
property alias iconLabel: openButton.iconText
property alias rowButton: openButton
property alias contentItem: dialogContent.item
property string subtext: ""
property bool open
property real openHeight: Math.min(rootParent.height * 0.8, 400)
property real openWidth: Math.min(rootParent.width * 0.8, 600)
required property Item rootParent
property bool first: false
property bool last: false
required property string settingAnchor
property color openColor: Colors.palette.m3surfaceContainerHighest
signal accepted
signal cancelled
function reparentWrapper(): void {
const newParent = open ? rootParent : root;
const pos = dialogWrapper.mapToItem(newParent, 0, 0);
dialogWrapper.parent = newParent;
dialogWrapper.x = pos.x;
dialogWrapper.y = pos.y;
}
Layout.fillWidth: true
implicitHeight: openButton.implicitHeight
z: open || dialogTransition.running ? 2 : 0
BlobGroup {
id: blobGroup
color: root.open ? root.openColor : Colors.tPalette.m3surfaceContainer
Behavior on color {
CAnim {}
}
}
MouseArea {
id: backdrop
anchors.fill: parent
enabled: false
hoverEnabled: enabled
preventStealing: true
parent: root.open ? root.rootParent : root
onClicked: root.open = false
}
Item {
id: dialogWrapper
height: openButton.implicitHeight
width: root.width
z: 1
states: State {
name: "open"
when: root.open
PropertyChanges {
backdrop.enabled: true
dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased
dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased
dialogBg.topRightRadius: Tokens.rounding.largeIncreased
dialogBg.topLeftRadius: Tokens.rounding.largeIncreased
dialogContent.opacity: 1
dialogWrapper.height: root.openHeight
dialogWrapper.width: root.openWidth
dialogWrapper.x: (root.rootParent.width - root.openWidth) / 2
dialogWrapper.y: (root.rootParent.height - root.openHeight) / 2
elevation.opacity: 1
openButton.opacity: 0
}
}
transitions: Transition {
id: dialogTransition
SequentialAnimation {
ScriptAction {
script: root.reparentWrapper()
}
Anim {
properties: "x,y"
}
}
PropertyAction {
property: "enabled"
}
Anim {
properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
type: Anim.DefaultEffects
}
Anim {
properties: "width,height"
}
}
Elevation {
id: elevation
anchors.fill: parent
bottomLeftRadius: dialogBg.bottomLeftRadius
bottomRightRadius: dialogBg.bottomRightRadius
level: 4
opacity: 0
radius: dialogBg.radius
transform: Matrix4x4 {
matrix: dialogBg.deformMatrix
}
}
BlobRect {
id: dialogBg
anchors.fill: parent
bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
bottomRightRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
deformScale: 0
group: blobGroup
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
}
TimeDialogToggle {
id: openButton
anchors.left: parent.left
anchors.right: parent.right
color: "transparent"
height: Math.min(implicitHeight, parent.height)
subtext: root.subtext
last: root.last
first: root.first
text: root.label
transform: Matrix4x4 {
matrix: dialogBg.deformMatrix
}
onClicked: root.open = true
}
Loader {
id: dialogContent
active: opacity > 0
anchors.fill: parent
asynchronous: false
opacity: 0
sourceComponent: MouseArea {
onWheel: event => event.accepted = true
preventStealing: true
clip: true
implicitWidth: innerLayout.implicitWidth + innerLayout.anchors.margins * 2
implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins + innerLayout.anchors.bottomMargin
ColumnLayout {
id: innerLayout
anchors.bottomMargin: Tokens.padding.largeIncreased
anchors.fill: parent
anchors.margins: Tokens.padding.extraLarge
spacing: 0
CustomText {
font.pointSize: Tokens.font.size.large
text: root.header
}
Loader {
Layout.fillWidth: true
Layout.leftMargin: root.horizontalContentMargin
Layout.topMargin: Tokens.spacing.medium
Layout.bottomMargin: Tokens.spacing.medium
Layout.rightMargin: root.horizontalContentMargin
sourceComponent: root.content
}
RowLayout {
Layout.alignment: Qt.AlignRight
spacing: Tokens.spacing.extraSmall
TextButton {
horizontalPadding: Tokens.padding.largeIncreased
isRound: true
text: qsTr("Cancel")
type: TextButton.Text
verticalPadding: Tokens.padding.small
stateLayer.preventStealing: true
onClicked: {
root.cancelled();
root.open = false;
}
}
TextButton {
enabled: root.acceptAllowed
horizontalPadding: Tokens.padding.largeIncreased
isRound: true
text: root.acceptLabel
type: TextButton.Text
stateLayer.preventStealing: true
verticalPadding: Tokens.padding.small
onClicked: {
root.accepted();
root.open = false;
}
}
}
}
}
transform: Matrix4x4 {
matrix: dialogBg.deformMatrix
}
}
}
}
@@ -0,0 +1,50 @@
pragma ComponentBehavior: Bound
import QtQuick
import ZShell.Config
import qs.Helpers
import qs.Components
import qs.Services
TimeDialogRow {
id: root
required property int start
required property int end
property int endCurrent
property int startCurrent
openColor: Colors.palette.m3surfaceContainer
openHeight: Math.min(rootParent.height * 0.8, contentItem?.implicitHeight ?? 0)
openWidth: Math.min(rootParent.width * 0.8, contentItem?.implicitWidth ?? 0)
function keyFor(item: var): string {
return item.id;
}
function labelFor(item: var): string {
return item.label;
}
horizontalContentMargin: -Tokens.padding.extraSmall
content: Component {
TimeInput {
id: timeContent
start: root.start
end: root.end
onEndEdit: value => root.endCurrent = value
onStartEdit: value => root.startCurrent = value
}
}
onOpenChanged: {
if (open) {
root.rootParent.interactive = false;
root.z = 5;
} else {
root.rootParent.interactive = true;
root.z = 1;
}
}
}
@@ -10,12 +10,14 @@ ConnectedRect {
property alias checked: switchButton.checked property alias checked: switchButton.checked
property int horizontalPadding: Tokens.padding.largeIncreased property int horizontalPadding: Tokens.padding.largeIncreased
required property Component popup
property alias subtext: subtext.text property alias subtext: subtext.text
property alias text: text.text property alias text: text.text
property alias icon: icon
property string iconText: "open_in_new"
property int verticalPadding: Tokens.padding.small property int verticalPadding: Tokens.padding.small
signal clicked(checked: bool) signal clicked(event: MouseEvent)
signal toggled(checked: bool)
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: layout.implicitHeight + verticalPadding * 2 implicitHeight: layout.implicitHeight + verticalPadding * 2
@@ -50,11 +52,11 @@ ConnectedRect {
anchors.right: switchButton.left anchors.right: switchButton.left
anchors.rightMargin: Tokens.spacing.medium anchors.rightMargin: Tokens.spacing.medium
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "open_in_new" text: root.iconText
} }
StateLayer { StateLayer {
onClicked: PopupManager.requestOpen(root.popup) onClicked: e => root.clicked(e)
} }
CustomSwitch { CustomSwitch {
@@ -64,6 +66,6 @@ ConnectedRect {
anchors.rightMargin: root.horizontalPadding anchors.rightMargin: root.horizontalPadding
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
onClicked: root.clicked(checked) onClicked: root.toggled(checked)
} }
} }
+41 -139
View File
@@ -6,15 +6,13 @@ import qs.Components
import qs.Helpers import qs.Helpers
import qs.Services import qs.Services
CustomClippingRect { ColumnLayout {
id: root id: root
required property var object required property int start
required property list<string> settings required property int end
property bool shouldBeActive: true signal endEdit(time: int)
signal startEdit(time: int)
signal applySettings(startTime: int, endTime: int)
signal close
function convertHour(timeValue: int): int { function convertHour(timeValue: int): int {
return Math.floor(timeValue / 60); return Math.floor(timeValue / 60);
@@ -28,24 +26,8 @@ CustomClippingRect {
return hour * 60 + minute; 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
ColumnLayout {
id: column
anchors.left: parent.left
anchors.margins: Tokens.padding.largeIncreased
anchors.right: parent.right
anchors.top: parent.top
spacing: Tokens.spacing.medium spacing: Tokens.spacing.medium
CustomText {
text: qsTr("Select time")
}
RowLayout { RowLayout {
CustomRect { CustomRect {
id: startHourRect id: startHourRect
@@ -64,16 +46,15 @@ CustomClippingRect {
radius: parent.radius - border.width radius: parent.radius - border.width
Behavior on border.width { Behavior on border.width {
Anim { Anim {}
}
} }
} }
TextFieldBase { TextFieldBase {
id: startHourField id: startHourField
function setConfigText(setting: string): string { function setConfigText(): string {
var val = root.convertHour(root.object[setting]); var val = root.convertHour(root.start);
if (val === 0) { if (val === 0) {
return "00"; return "00";
} }
@@ -90,7 +71,7 @@ CustomClippingRect {
font.pixelSize: 56 font.pixelSize: 56
font.weight: 400 font.weight: 400
horizontalAlignment: TextInput.AlignHCenter horizontalAlignment: TextInput.AlignHCenter
text: setConfigText(root.settings[1]) text: setConfigText()
verticalAlignment: TextInput.AlignVCenter verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => { Keys.onPressed: event => {
@@ -104,7 +85,7 @@ CustomClippingRect {
return; return;
} else if (event.key === Qt.Key_Escape) { } else if (event.key === Qt.Key_Escape) {
event.accepted = true; event.accepted = true;
startHourField.text = setConfigText(root.settings[1]); startHourField.text = setConfigText();
startHourField.focus = false; startHourField.focus = false;
} else if (event.key === Qt.Key_Return) { } else if (event.key === Qt.Key_Return) {
startHourField.focus = false; startHourField.focus = false;
@@ -145,16 +126,10 @@ CustomClippingRect {
event.accepted = true; event.accepted = true;
} }
onCursorPositionChanged: cursorPosition = 2 onCursorPositionChanged: cursorPosition = 2
onTextEdited: { onTextChanged: {
if (startHourField.text === "") var mins = parseInt(startMinuteField.text);
return; const hours = root.convertToMinutes(parseInt(startHourField.text));
var val = parseInt(startHourField.text); root.startEdit(hours + mins);
if (isNaN(val))
return;
val = Math.max(0, Math.min(23, val));
var newText = val.toString();
if (newText !== startHourField.text)
startHourField.text = newText;
} }
} }
} }
@@ -183,16 +158,15 @@ CustomClippingRect {
radius: parent.radius - border.width radius: parent.radius - border.width
Behavior on border.width { Behavior on border.width {
Anim { Anim {}
}
} }
} }
TextFieldBase { TextFieldBase {
id: startMinuteField id: startMinuteField
function setConfigText(setting: string): string { function setConfigText(): string {
var val = root.convertMinute(root.object[setting]); var val = root.convertMinute(root.start);
if (val === 0) { if (val === 0) {
return "00"; return "00";
} }
@@ -209,7 +183,7 @@ CustomClippingRect {
font.pixelSize: 56 font.pixelSize: 56
font.weight: 400 font.weight: 400
horizontalAlignment: TextInput.AlignHCenter horizontalAlignment: TextInput.AlignHCenter
text: setConfigText(root.settings[1]) text: setConfigText()
verticalAlignment: TextInput.AlignVCenter verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => { Keys.onPressed: event => {
@@ -223,7 +197,7 @@ CustomClippingRect {
return; return;
} else if (event.key === Qt.Key_Escape) { } else if (event.key === Qt.Key_Escape) {
event.accepted = true; event.accepted = true;
startMinuteField.text = setConfigText(root.settings[1]); startMinuteField.text = setConfigText();
startMinuteField.focus = false; startMinuteField.focus = false;
} else if (event.key === Qt.Key_Return) { } else if (event.key === Qt.Key_Return) {
startMinuteField.focus = false; startMinuteField.focus = false;
@@ -264,16 +238,10 @@ CustomClippingRect {
event.accepted = true; event.accepted = true;
} }
onCursorPositionChanged: cursorPosition = 2 onCursorPositionChanged: cursorPosition = 2
onTextEdited: { onTextChanged: {
if (startMinuteField.text === "") var mins = parseInt(startMinuteField.text);
return; const hours = root.convertToMinutes(parseInt(startHourField.text));
var val = parseInt(startMinuteField.text); root.startEdit(hours + mins);
if (isNaN(val))
return;
val = Math.max(0, Math.min(23, val));
var newText = val.toString();
if (newText !== startMinuteField.text)
startMinuteField.text = newText;
} }
} }
} }
@@ -301,16 +269,15 @@ CustomClippingRect {
radius: parent.radius - border.width radius: parent.radius - border.width
Behavior on border.width { Behavior on border.width {
Anim { Anim {}
}
} }
} }
TextFieldBase { TextFieldBase {
id: endHourField id: endHourField
function setConfigText(setting: string): string { function setConfigText(): string {
var val = root.convertHour(root.object[setting]); var val = root.convertHour(root.end);
if (val === 0) { if (val === 0) {
return "00"; return "00";
} }
@@ -327,7 +294,7 @@ CustomClippingRect {
font.pixelSize: 56 font.pixelSize: 56
font.weight: 400 font.weight: 400
horizontalAlignment: TextInput.AlignHCenter horizontalAlignment: TextInput.AlignHCenter
text: setConfigText(root.settings[2]) text: setConfigText()
verticalAlignment: TextInput.AlignVCenter verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => { Keys.onPressed: event => {
@@ -341,7 +308,7 @@ CustomClippingRect {
return; return;
} else if (event.key === Qt.Key_Escape) { } else if (event.key === Qt.Key_Escape) {
event.accepted = true; event.accepted = true;
endHourField.text = setConfigText(root.settings[2]); endHourField.text = setConfigText();
endHourField.focus = false; endHourField.focus = false;
} else if (event.key === Qt.Key_Return) { } else if (event.key === Qt.Key_Return) {
endHourField.focus = false; endHourField.focus = false;
@@ -382,16 +349,10 @@ CustomClippingRect {
event.accepted = true; event.accepted = true;
} }
onCursorPositionChanged: cursorPosition = 2 onCursorPositionChanged: cursorPosition = 2
onTextEdited: { onTextChanged: {
if (endHourField.text === "") var mins = parseInt(endMinuteField.text);
return; const hours = root.convertToMinutes(parseInt(endHourField.text));
var val = parseInt(endHourField.text); root.endEdit(hours + mins);
if (isNaN(val))
return;
val = Math.max(0, Math.min(23, val));
var newText = val.toString();
if (newText !== endHourField.text)
endHourField.text = newText;
} }
} }
} }
@@ -420,16 +381,15 @@ CustomClippingRect {
radius: parent.radius - border.width radius: parent.radius - border.width
Behavior on border.width { Behavior on border.width {
Anim { Anim {}
}
} }
} }
TextFieldBase { TextFieldBase {
id: endMinuteField id: endMinuteField
function setConfigText(setting: string): string { function setConfigText(): string {
var val = root.convertMinute(root.object[setting]); var val = root.convertMinute(root.end);
if (val === 0) { if (val === 0) {
return "00"; return "00";
} }
@@ -446,7 +406,7 @@ CustomClippingRect {
font.pixelSize: 56 font.pixelSize: 56
font.weight: 400 font.weight: 400
horizontalAlignment: TextInput.AlignHCenter horizontalAlignment: TextInput.AlignHCenter
text: setConfigText(root.settings[2]) text: setConfigText()
verticalAlignment: TextInput.AlignVCenter verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => { Keys.onPressed: event => {
@@ -460,7 +420,7 @@ CustomClippingRect {
return; return;
} else if (event.key === Qt.Key_Escape) { } else if (event.key === Qt.Key_Escape) {
event.accepted = true; event.accepted = true;
endMinuteField.text = setConfigText(root.settings[2]); endMinuteField.text = setConfigText();
endMinuteField.focus = false; endMinuteField.focus = false;
} else if (event.key === Qt.Key_Return) { } else if (event.key === Qt.Key_Return) {
endMinuteField.focus = false; endMinuteField.focus = false;
@@ -501,16 +461,10 @@ CustomClippingRect {
event.accepted = true; event.accepted = true;
} }
onCursorPositionChanged: cursorPosition = 2 onCursorPositionChanged: cursorPosition = 2
onTextEdited: { onTextChanged: {
if (endMinuteField.text === "") var mins = parseInt(endMinuteField.text);
return; const hours = root.convertToMinutes(parseInt(endHourField.text));
var val = parseInt(endMinuteField.text); root.endEdit(hours + mins);
if (isNaN(val))
return;
val = Math.max(0, Math.min(23, val));
var newText = val.toString();
if (newText !== endMinuteField.text)
endMinuteField.text = newText;
} }
} }
} }
@@ -527,56 +481,4 @@ CustomClippingRect {
text: qsTr("End") text: qsTr("End")
} }
} }
}
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
}
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);
}
}
}
}
} }
+9 -3
View File
@@ -12,6 +12,7 @@ CustomSwitch {
property alias last: bg.last property alias last: bg.last
property string settingAnchor property string settingAnchor
property string subtext property string subtext
property string iconText
function flashHighlight(): void { function flashHighlight(): void {
bg.flashHighlight(); bg.flashHighlight();
@@ -63,8 +64,7 @@ CustomSwitch {
text: root.text text: root.text
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
} }
@@ -81,9 +81,15 @@ CustomSwitch {
visible: root.subtext visible: root.subtext
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
} }
} }
MaterialIcon {
id: icon
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.iconText ?? ""
} }
} }
} }
+1 -6
View File
@@ -24,8 +24,7 @@ CustomClippingRect {
radius: Tokens.rounding.largeIncreased + Tokens.padding.small radius: Tokens.rounding.largeIncreased + Tokens.padding.small
Behavior on blobColor { Behavior on blobColor {
CAnim { CAnim {}
}
} }
TapHandler { TapHandler {
@@ -70,8 +69,4 @@ CustomClippingRect {
anchors.top: parent.top anchors.top: parent.top
sState: root.sState sState: root.sState
} }
PopupOverlay {
anchors.fill: parent
}
} }
+10 -17
View File
@@ -81,14 +81,10 @@ VerticalFadeFlickable {
topLeftRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall 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 topRightRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
RadiusBehavior on bottomLeftRadius { RadiusBehavior on bottomLeftRadius {}
} RadiusBehavior on bottomRightRadius {}
RadiusBehavior on bottomRightRadius { RadiusBehavior on topLeftRadius {}
} RadiusBehavior on topRightRadius {}
RadiusBehavior on topLeftRadius {
}
RadiusBehavior on topRightRadius {
}
StateLayer { StateLayer {
id: stateLayer id: stateLayer
@@ -262,14 +258,10 @@ VerticalFadeFlickable {
topRightRadius: layer.pressed ? Tokens.rounding.largeIncreased : isFirst ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall topRightRadius: layer.pressed ? Tokens.rounding.largeIncreased : isFirst ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
width: cardList.width width: cardList.width
RadiusBehavior on bottomLeftRadius { RadiusBehavior on bottomLeftRadius {}
} RadiusBehavior on bottomRightRadius {}
RadiusBehavior on bottomRightRadius { RadiusBehavior on topLeftRadius {}
} RadiusBehavior on topRightRadius {}
RadiusBehavior on topLeftRadius {
}
RadiusBehavior on topRightRadius {
}
ColumnLayout { ColumnLayout {
id: resultLayout id: resultLayout
@@ -319,7 +311,8 @@ VerticalFadeFlickable {
z: 1 z: 1
onClicked: { onClicked: {
root.sState.jumpToSetting(result.modelData.pageIdx, result.modelData.subPath, result.modelData.anchor); const target = result.modelData.targetSubPath;
root.sState.jumpToSetting(result.modelData.pageIdx, target.length > 0 ? target : result.modelData.subPath, target.length > 0 ? "" : result.modelData.anchor);
} }
} }
+4
View File
@@ -30,6 +30,10 @@ QtObject {
Component { Component {
WallpaperSelect {} WallpaperSelect {}
} }
Component {
ColorsFonts {}
}
} }
}, },
Component { Component {
+13
View File
@@ -1,6 +1,7 @@
import QtQuick import QtQuick
import qs.Components import qs.Components
import ZShell.Config import ZShell.Config
import qs.Services
Item { Item {
id: root id: root
@@ -41,6 +42,18 @@ Item {
objectName: "PageContainer" objectName: "PageContainer"
Component.onCompleted: root.loadPage(root.sState.currentPageIdx) Component.onCompleted: root.loadPage(root.sState.currentPageIdx)
// CustomRect {
// anchors.fill: parent
// color: Qt.alpha(Colors.palette.m3shadow, 0.3)
// opacity: root.sState.dimmed ? 1 : 0
// visible: opacity > 0
// z: 0
//
// Behavior on opacity {
// Anim {}
// }
// }
} }
Connections { Connections {
@@ -55,6 +55,10 @@ PageBase {
enabled: Object.keys(model).length > 0 enabled: Object.keys(model).length > 0
header: qsTr("Add new entry") header: qsTr("Add new entry")
icon: "add" icon: "add"
settingAnchor: "panels-bar-status-icons-add-item"
onOpenChanged: {
root.sState.dimmed = open;
}
last: true last: true
label: qsTr("Add entry") label: qsTr("Add entry")
model: { model: {
@@ -67,6 +67,20 @@ PageBase {
SectionHeader { SectionHeader {
first: true 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") text: qsTr("Appearance")
} }
@@ -75,15 +89,25 @@ PageBase {
acceptLabel: qsTr("Confirm") acceptLabel: qsTr("Confirm")
enabled: Object.keys(model).length > 0 enabled: Object.keys(model).length > 0
settingAnchor: "panels-sidebar-select-scheme"
separateContent: false
header: qsTr("Select scheme") header: qsTr("Select scheme")
first: true first: true
last: true last: true
icon: "add" icon: "format_paint"
iconLabel.font.pointSize: Tokens.font.size.large
rowButton.trailingIcon: "open_in_new"
onOpenChanged: {
root.sState.dimmed = open;
}
rowButton.activeItem: root.schemes.find(s => s.id === Config.llm.appearance.scheme).label
label: qsTr("Color scheme") label: qsTr("Color scheme")
subtext: qsTr("Select the color scheme used for code blocks") subtext: qsTr("Select the color scheme used for code blocks")
model: root.schemes model: root.schemes
rootParent: root.flickable rootParent: root.flickable
initialSelect: Config.llm.appearance.scheme
onAccepted: { onAccepted: {
if (!selectedItem) if (!selectedItem)
return; return;
@@ -88,6 +88,10 @@ PageBase {
enabled: Object.keys(model).length > 0 enabled: Object.keys(model).length > 0
header: qsTr("Add new entry") header: qsTr("Add new entry")
icon: "add" icon: "add"
onOpenChanged: {
root.sState.dimmed = open;
}
settingAnchor: "panels-sidebar-quick-toggles-add-item"
label: qsTr("Add entry") label: qsTr("Add entry")
last: true last: true
model: { model: {
@@ -116,6 +120,7 @@ PageBase {
NavRow { NavRow {
text: qsTr("AI chat") text: qsTr("AI chat")
settingAnchor: "panels-sidebar-llm"
icon: "robot_2" icon: "robot_2"
first: true first: true
last: true last: true
+2 -2
View File
@@ -202,12 +202,12 @@ PageBase {
} }
ToggleRow { ToggleRow {
checked: Config.general.color.smart checked: Config.colors.smart
settingAnchor: "services-smart-color-scheme" settingAnchor: "services-smart-color-scheme"
subtext: qsTr("Derive theme mode from the wallpaper") subtext: qsTr("Derive theme mode from the wallpaper")
text: qsTr("Smart color scheme") text: qsTr("Smart color scheme")
onToggled: Config.general.color.smart = checked onToggled: Config.colors.smart = checked
} }
SelectRow { SelectRow {
@@ -0,0 +1,327 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Io
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import qs.Paths
import qs.Services
import qs.Modules.Settings.Common
PageBase {
id: root
readonly property list<string> fonts: Qt.fontFamilies()
readonly property var schemes: [
{
id: "onedark",
label: qsTr("One Dark")
},
{
id: "nord",
label: qsTr("Nord")
},
{
id: "dracula",
label: qsTr("Dracula")
},
{
id: "darkgreen",
label: qsTr("Darkgreen")
},
{
id: "solarized",
label: qsTr("Solarized")
},
{
id: "rosepine",
label: qsTr("Rosepine")
},
{
id: "gruvbox",
label: qsTr("Gruvbox")
},
{
id: "catppuccin",
label: qsTr("Catppuccin")
},
{
id: "tokyonight",
label: qsTr("Tokyo Night")
},
{
id: "shadotheme",
label: qsTr("Shadotheme")
}
]
isSubPage: true
title: qsTr("Colors & font")
ColumnLayout {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
spacing: Tokens.spacing.extraSmall / 2
width: root.cappedWidth
SectionHeader {
first: true
text: qsTr("Colors")
}
ToggleRow {
text: qsTr("Dynamic colors")
first: true
subtext: qsTr("Automatic color scheme based on wallpaper is %1").arg(Config.colors.schemeGen ? "enabled" : "disabled")
checked: Config.colors.schemeGen
settingAnchor: "colors-fonts-dynamic-colors"
onToggled: {
Config.colors.schemeGen = checked;
if (checked)
Colors.setMode(Config.colors.mode);
}
}
DialogSelectButton {
settingAnchor: "colors-fonts-preset-scheme"
model: root.schemes
acceptLabel: qsTr("Confirm")
enabled: !Config.colors.schemeGen
header: qsTr("Select scheme")
rowButton.activeItem: root.schemes.find(s => {
var regex = new RegExp(Config.colors.presets.name, "i");
return regex.test(s.id);
}).label ?? ""
label: qsTr("Preset scheme")
subtext: qsTr("Select preset color scheme")
rootParent: root.flickable
FileView {
id: view
path: `${Paths.state}/presets.json`
watchChanges: true
onFileChanged: reload()
JsonAdapter {
id: adapter
property var presets
}
}
initialSelect: Config.colors.presets.name.toLowerCase()
onAccepted: {
if (!selectedItem)
return;
const s = selectedItem.toLowerCase();
let v = "";
let a = "";
const existingVariant = Config.colors.presets.variant;
const existingAccent = Config.colors.presets.accent;
const variantKeys = Object.keys(adapter.presets[s].variants);
if (existingVariant !== "" && variantKeys.some(v => v === existingVariant))
v = existingVariant;
if (v !== "" && existingAccent !== "" && adapter.presets[s].variants[v]?.accents.some(a => a === existingAccent))
a = existingAccent;
if (a !== "")
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]);
else if (v !== "")
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`]);
else
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${variantKeys[0]}`]);
}
}
DialogSelectButton {
settingAnchor: "colors-fonts-preset-variant"
// qmlformat off
model: {
const v = adapter.presets[Config.colors.presets.name].variants;
const map = Object.keys(v).map(v => ({
id: v,
label: qsTr("%1%2")
.arg(v.charAt(0).toUpperCase())
.arg(v.slice(1).toLowerCase())
}));
return map;
}
// qmlformat on
acceptLabel: qsTr("Confirm")
enabled: !Config.colors.schemeGen && model.length > 1
header: qsTr("Select variant")
rowButton.activeItem: {
const v = Config.colors.presets.variant ?? "";
if (v === "")
return "";
return qsTr("%1%2").arg(v.charAt(0).toUpperCase()).arg(v.slice(1).toLowerCase());
}
label: qsTr("Variant")
subtext: qsTr("Select preset variant")
rootParent: root.flickable
initialSelect: Config.colors.presets.variant
onAccepted: {
if (!selectedItem)
return;
const v = selectedItem.toLowerCase();
const s = Config.colors.presets.name;
const existingAccent = Config.colors.presets.accent;
let a = "";
if (existingAccent !== "" && adapter.presets[s].variants[v]?.accents.some(a => a === existingAccent))
a = existingAccent;
if (a !== "")
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]);
else
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`]);
}
}
DialogSelectButton {
settingAnchor: "colors-fonts-preset-accent"
// qmlformat off
model: {
const a = adapter.presets[Config.colors.presets.name].variants[Config.colors.presets.variant]?.accents ?? [];
if (a.length === 0)
return a;
const map = a.map(a => ({
id: a,
label: qsTr("%1%2")
.arg(a.charAt(0).toUpperCase())
.arg(a.slice(1).toLowerCase())
}));
return map;
}
// qmlformat on
acceptLabel: qsTr("Confirm")
enabled: !Config.colors.schemeGen && model.length > 1
header: qsTr("Select accent")
rowButton.activeItem: {
const a = Config.colors.presets.accent ?? "";
if (a === "")
return "";
return qsTr("%1%2").arg(a.charAt(0).toUpperCase()).arg(a.slice(1).toLowerCase());
}
label: qsTr("Accent")
last: true
subtext: qsTr("Select preset accent")
rootParent: root.flickable
initialSelect: Config.colors.presets.accent
onAccepted: {
if (!selectedItem)
return;
const a = selectedItem.toLowerCase();
const v = Config.colors.presets.variant;
const s = Config.colors.presets.name;
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]);
}
}
SectionHeader {
text: qsTr("Fonts")
}
SearchPopup {
id: selectSansFont
settingAnchor: "colors-fonts-sans-font"
model: root.fonts
acceptLabel: qsTr("Confirm")
enabled: Object.keys(model).length > 0
separateContent: false
header: qsTr("Select font")
first: true
rowButton.activeItem: root.fonts.find(s => {
var regex = new RegExp(Config.appearance.font.family.sans, "i");
return regex.test(s);
}) ?? ""
label: qsTr("Sans font")
subtext: qsTr("Select desired sans font")
rootParent: root.flickable
initialSelect: Config.appearance.font.family.sans
onAccepted: {
if (!selectedItem)
return;
Config.appearance.font.family.sans = selectedItem;
}
}
SearchPopup {
id: selectMonoFont
acceptLabel: qsTr("Confirm")
enabled: Object.keys(model).length > 0
settingAnchor: "colors-fonts-mono-font"
separateContent: false
header: qsTr("Select font")
rowButton.activeItem: root.fonts.find(s => {
var regex = new RegExp(Config.appearance.font.family.mono, "i");
return regex.test(s);
}) ?? ""
label: qsTr("Mono font")
subtext: qsTr("Select desired mono font")
model: root.fonts
rootParent: root.flickable
initialSelect: Config.appearance.font.family.mono
onAccepted: {
if (!selectedItem)
return;
Config.appearance.font.family.mono = selectedItem;
}
}
SearchPopup {
id: selectClockFont
acceptLabel: qsTr("Confirm")
enabled: Object.keys(model).length > 0
separateContent: false
settingAnchor: "colors-fonts-clock-font"
header: qsTr("Select font")
last: true
rowButton.activeItem: root.fonts.find(s => {
var regex = new RegExp(Config.appearance.font.family.clock, "i");
return regex.test(s);
}) ?? ""
label: qsTr("Clock font")
subtext: qsTr("Select desired clock font")
model: root.fonts
rootParent: root.flickable
initialSelect: Config.appearance.font.family.clock
onAccepted: {
if (!selectedItem)
return;
Config.appearance.font.family.clock = selectedItem;
}
}
}
}
+100 -46
View File
@@ -33,8 +33,14 @@ PageBase {
} }
} }
IconTextButton { ButtonRow {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
spacing: Tokens.spacing.small
IconTextButton {
property string settingAnchor: "style-wallpapers"
enabled: Config.background.enabled enabled: Config.background.enabled
horizontalPadding: Tokens.padding.extraLarge horizontalPadding: Tokens.padding.extraLarge
icon: "wallpaper" icon: "wallpaper"
@@ -47,6 +53,23 @@ PageBase {
onClicked: root.sState.openSubPage(1) // Wallpaper page 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 { ToggleRow {
checked: Config.background.enabled checked: Config.background.enabled
first: true first: true
@@ -76,85 +99,116 @@ PageBase {
onToggled: Colors.setMode(checked ? "dark" : "light") onToggled: Colors.setMode(checked ? "dark" : "light")
} }
OverlayRow { TimeDialogSelect {
id: darkMode
readonly property string endTime: { readonly property string endTime: {
var d = new Date(0, 0, 0, 0, 0, 0, 0); var d = new Date(0, 0, 0, 0, 0, 0, 0);
d.setMinutes(Config.general.color.scheduleDarkEnd); d.setMinutes(Config.colors.scheduleDarkEnd);
return Qt.formatTime(d, "hh:mm AP"); return Qt.formatTime(d, "hh:mm AP");
} }
readonly property string startTime: { readonly property string startTime: {
var d = new Date(0, 0, 0, 0, 0, 0, 0); var d = new Date(0, 0, 0, 0, 0, 0, 0);
d.setMinutes(Config.general.color.scheduleDarkStart); d.setMinutes(Config.colors.scheduleDarkStart);
return Qt.formatTime(d, "hh:mm AP"); return Qt.formatTime(d, "hh:mm AP");
} }
checked: Config.general.color.scheduleDark rowButton.checked: Config.colors.scheduleDark
rowButton.onToggled: Config.colors.scheduleDark = rowButton.checked
start: Config.colors.scheduleDarkStart
end: Config.colors.scheduleDarkEnd
first: true first: true
last: true
settingAnchor: "style-schedule-dark-mode" settingAnchor: "style-schedule-dark-mode"
acceptLabel: qsTr("Apply")
rootParent: root.flickable
header: qsTr("Select time")
subtext: qsTr("Dark mode will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) subtext: qsTr("Dark mode will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime)
text: qsTr("Schedule dark mode") label: qsTr("Schedule dark mode")
popup: Component { onOpenChanged: {
TimeInput { root.sState.dimmed = open;
object: Config.general.color }
settings: ["scheduleDark", "scheduleDarkStart", "scheduleDarkEnd"]
onApplySettings: (start, end) => { onAccepted: {
Config.general.color.scheduleDarkStart = start; Config.colors.scheduleDarkStart = startCurrent;
Config.general.color.scheduleDarkEnd = end; Config.colors.scheduleDarkEnd = endCurrent;
ModeScheduler.checkStartup(); ModeScheduler.checkStartup();
PopupManager.requestClose();
}
onClose: PopupManager.requestClose()
} }
} }
onClicked: value => { TimeDialogSelect {
Config.general.color.scheduleDark = value;
}
}
OverlayRow {
id: hyprsunset
readonly property string endTime: { readonly property string endTime: {
var d = new Date(0, 0, 0, 0, 0, 0, 0); var d = new Date(0, 0, 0, 0, 0, 0, 0);
d.setMinutes(Config.general.color.scheduleHyprsunsetEnd); d.setMinutes(Config.display.nightlight.scheduleEnd);
return Qt.formatTime(d, "hh:mm AP"); return Qt.formatTime(d, "hh:mm AP");
} }
readonly property string startTime: { readonly property string startTime: {
var d = new Date(0, 0, 0, 0, 0, 0, 0); var d = new Date(0, 0, 0, 0, 0, 0, 0);
d.setMinutes(Config.general.color.scheduleHyprsunsetStart); d.setMinutes(Config.display.nightlight.scheduleStart);
return Qt.formatTime(d, "hh:mm AP"); return Qt.formatTime(d, "hh:mm AP");
} }
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing rowButton.checked: Config.display.nightlight.schedule
checked: Config.general.color.scheduleHyprsunset rowButton.onToggled: Config.display.nightlight.schedule = rowButton.checked
last: true start: Config.display.nightlight.scheduleStart
settingAnchor: "style-schedule-hyprsunset" end: Config.display.nightlight.scheduleEnd
subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) first: true
text: qsTr("Schedule hyprsunset") 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")
popup: Component { onOpenChanged: {
TimeInput { root.sState.dimmed = open;
object: Config.general.color }
settings: ["scheduleHyprsunset", "scheduleHyprsunsetStart", "scheduleHyprsunsetEnd"]
onApplySettings: (start, end) => { onAccepted: {
Config.general.color.scheduleHyprsunsetStart = start; Config.display.nightlight.scheduleStart = startCurrent;
Config.general.color.scheduleHyprsunsetEnd = end; Config.display.nightlight.scheduleEnd = endCurrent;
Hyprsunset.checkStartup(); Hyprsunset.checkStartup();
PopupManager.requestClose();
}
onClose: PopupManager.requestClose()
} }
} }
onClicked: value => { ToggleRow {
Config.general.color.scheduleHyprsunset = value; Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
checked: Config.display.nightlight.useNative
settingAnchor: "display-nightlight-useNative"
text: qsTr("Use native")
subtext: qsTr("Use native shader nightlight for more features")
onToggled: Config.display.nightlight.useNative = checked
} }
SpinRow {
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
from: 2000
settingAnchor: "display-nightlight-temp"
stepSize: 100
subtext: qsTr("How warm/orange the screen will be. Lower is warmer (Kelvin)")
text: qsTr("Temperature")
to: 6500
value: Config.display.nightlight.temp
onMoved: v => Config.display.nightlight.temp = v
}
SpinRow {
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
from: 0
settingAnchor: "display-nightlight-fade-duration"
enabled: Config.display.nightlight.useNative
last: true
stepSize: 1
subtext: qsTr("How long it takes for the gradual nightlight to finish (s)")
text: qsTr("Fade duration")
to: 60 * 5
value: Config.display.nightlight.fadeDuration / 1000
onMoved: v => Config.display.nightlight.fadeDuration = v * 1000
} }
} }
} }
-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 pragma Singleton
import "../../scripts/fzf.js" as Fzf import "../../scripts/fzf.js" as Fzf
import "../../scripts/settings-indexer.js" as SettingsIndexer
import QtQuick import QtQuick
import Quickshell import Quickshell
import ZShell import ZShell
import ZShell.Config import ZShell.Config
import qs.Paths
Singleton { Singleton {
id: root id: root
property var fzfFinder: null property var inverted: ({})
property var ranking: ({})
readonly property var highlightCache: ({ readonly property var highlightCache: ({
"search": "", "search": "",
"pattern": null "pattern": null
}) })
property var inverted: ({}) property var fzfFinder: null
property var ranking: ({}) readonly property string cachePath: Paths.cache + "/settings-index.json"
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;
}
function query(search: string): list<QtObject> { function query(search: string): list<QtObject> {
const tokens = root.tokenize(search); const tokens = root.tokenize(search);
@@ -70,7 +28,7 @@ Singleton {
const scores = ({}); const scores = ({});
const hitCounts = ({}); const hitCounts = ({});
for (const token of tokens) { for (const token of tokens) {
const matches = root.lookup(token); const matches = root.lookup(token); // { id: weight }
for (const id in matches) { for (const id in matches) {
scores[id] = (scores[id] ?? 0) + matches[id]; scores[id] = (scores[id] ?? 0) + matches[id];
hitCounts[id] = (hitCounts[id] ?? 0) + 1; hitCounts[id] = (hitCounts[id] ?? 0) + 1;
@@ -103,13 +61,76 @@ Singleton {
return out; 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 { function tokenize(text: string): var {
return text.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length > 0); 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: { Component.onCompleted: {
try { try {
const data = JSON.parse(ZUtils.settingsIndex()); const data = root.loadIndex();
entries.model = data.entries; entries.model = data.entries;
root.inverted = data.inverted ?? {}; root.inverted = data.inverted ?? {};
root.ranking = data.ranking ?? {}; root.ranking = data.ranking ?? {};
@@ -122,6 +143,7 @@ Singleton {
limit: 25 limit: 25
}); });
} catch (e) { } catch (e) {
console.warn("SettingsSearcher: failed to build settings index:", e);
entries.model = []; entries.model = [];
root.inverted = {}; root.inverted = {};
root.ranking = {}; root.ranking = {};
@@ -132,22 +154,25 @@ Singleton {
Variants { Variants {
id: entries id: entries
SettingEntry { SettingEntry {}
}
} }
component SettingEntry: QtObject { 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 crumbIcons: modelData.crumbIcons
readonly property var crumbLabels: modelData.crumbLabels 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 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 string togglePath: modelData.togglePath ?? ""
readonly property bool isToggle: togglePath.length > 0
readonly property bool toggleValue: { readonly property bool toggleValue: {
if (!isToggle) if (!isToggle)
return false; return false;
+1
View File
@@ -16,6 +16,7 @@ QtObject {
property string searchText property string searchText
property DesktopEntry selectedApp property DesktopEntry selectedApp
property BluetoothDevice selectedBtDevice property BluetoothDevice selectedBtDevice
property bool dimmed
property string selectedWallpaperCategory property string selectedWallpaperCategory
property list<int> subPageIdxStack property list<int> subPageIdxStack
+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) 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 qml_module(ZShell
URI ZShell URI ZShell
SOURCES SOURCES
@@ -60,8 +8,6 @@ qml_module(ZShell
toaster.hpp toaster.cpp toaster.hpp toaster.cpp
qalculator.hpp qalculator.cpp qalculator.hpp qalculator.cpp
zutils.hpp zutils.cpp zutils.hpp zutils.cpp
RESOURCES
"${SETTINGS_INDEX_JSON}"
LIBRARIES LIBRARIES
Qt::Gui Qt::Gui
Qt::Quick Qt::Quick
@@ -69,9 +15,13 @@ qml_module(ZShell
Qt::Sql Qt::Sql
Qt::DBus Qt::DBus
PkgConfig::Qalculate 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(Models)
add_subdirectory(Internal) add_subdirectory(Internal)
@@ -80,3 +30,11 @@ add_subdirectory(Components)
add_subdirectory(Blobs) add_subdirectory(Blobs)
add_subdirectory(Config) add_subdirectory(Config)
add_subdirectory(Llm) add_subdirectory(Llm)
pkg_check_modules(HYPRLAND_PROBE hyprland)
if(HYPRLAND_PROBE_FOUND)
add_subdirectory(HyprPlugins)
else()
message(STATUS
"hyprland development headers not found; skipping zshell-nightlight plugin")
endif()
+1
View File
@@ -5,6 +5,7 @@ qml_module(ZShell-components
wavyline.hpp wavyline.cpp wavyline.hpp wavyline.cpp
buttonrow.hpp buttonrow.cpp buttonrow.hpp buttonrow.cpp
carouselview.hpp carouselview.cpp carouselview.hpp carouselview.cpp
wheelinverter.hpp
LIBRARIES LIBRARIES
Qt::Quick Qt::Quick
) )
@@ -0,0 +1,90 @@
#pragma once
#include <QQuickItem>
#include <QQuickWindow>
#include <QWheelEvent>
#include <QCoreApplication>
#include <QPointer>
#include <QDebug>
namespace ZShell::components {
class WheelInverter : public QQuickItem {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(
QQuickItem* target READ target WRITE setTarget NOTIFY targetChanged)
public:
using QQuickItem::QQuickItem;
QQuickItem* target() const { return m_target; }
void setTarget(QQuickItem* target) {
if (m_target == target) return;
if (m_target) m_target->removeEventFilter(this);
m_target = target;
if (m_target) m_target->installEventFilter(this);
emit targetChanged();
}
protected:
class InvertedWheelEvent : public QWheelEvent {
public:
using QWheelEvent::QWheelEvent;
bool invertedByWheelInverter = true;
};
bool eventFilter(QObject* watched, QEvent* event) override {
if (watched != m_target || event->type() != QEvent::Wheel)
return QQuickItem::eventFilter(watched, event);
auto* wheel = static_cast<QWheelEvent*>(event);
const bool inverted = dynamic_cast<InvertedWheelEvent*>(wheel) !=
nullptr;
if (inverted) return false;
auto* window = m_target ? m_target->window() : nullptr;
if (!window) {
qInfo() << "[WheelInverter] no window";
return false;
}
const QPointF windowPos =
window->mapFromGlobal(wheel->globalPosition());
auto* invertedEvent = new InvertedWheelEvent(
windowPos,
wheel->globalPosition(),
-wheel->pixelDelta(),
-wheel->angleDelta(),
wheel->buttons(),
wheel->modifiers(),
wheel->phase(),
wheel->inverted(),
wheel->source(),
wheel->pointingDevice());
invertedEvent->setTimestamp(wheel->timestamp());
QCoreApplication::postEvent(window, invertedEvent);
return true;
}
signals:
void targetChanged();
private:
QPointer<QQuickItem> m_target;
};
} // namespace ZShell::components
+2
View File
@@ -5,6 +5,7 @@ qml_module(ZShell-config
configobject.hpp configobject.cpp configobject.hpp configobject.cpp
configlist.hpp configlist.cpp configlist.hpp configlist.cpp
config.hpp config.cpp config.hpp config.cpp
migration.hpp migration.cpp
anim.hpp anim.cpp anim.hpp anim.cpp
tokens.hpp tokens.hpp
appearance.hpp appearance.hpp
@@ -13,6 +14,7 @@ qml_module(ZShell-config
clipboard.hpp clipboard.hpp
colors.hpp colors.hpp
dashboard.hpp dashboard.hpp
display.hpp
dock.hpp dock.hpp
general.hpp general.hpp
launcher.hpp launcher.hpp
+7
View File
@@ -22,6 +22,13 @@ class Colors : public ConfigObject {
CONFIG_SUBOBJECT(Presets, presets) CONFIG_SUBOBJECT(Presets, presets)
CFG_PROPERTY(QString, schemeType, QStringLiteral("fidelity")) CFG_PROPERTY(QString, schemeType, QStringLiteral("fidelity"))
CFG_PROPERTY(bool, scheduleDark, false)
CFG_PROPERTY(int, scheduleDarkEnd, 600)
CFG_PROPERTY(int, scheduleDarkStart, 1140)
CFG_PROPERTY(bool, schemeGen, true)
CFG_PROPERTY(bool, neovimColors, false)
CFG_PROPERTY(QString, mode, QStringLiteral("dark"))
CFG_PROPERTY(bool, smart, false)
public: public:
explicit Colors(QObject* parent = nullptr) explicit Colors(QObject* parent = nullptr)
+14 -7
View File
@@ -6,11 +6,13 @@
#include "clipboard.hpp" #include "clipboard.hpp"
#include "colors.hpp" #include "colors.hpp"
#include "dashboard.hpp" #include "dashboard.hpp"
#include "display.hpp"
#include "dock.hpp" #include "dock.hpp"
#include "general.hpp" #include "general.hpp"
#include "launcher.hpp" #include "launcher.hpp"
#include "llm.hpp" #include "llm.hpp"
#include "lock.hpp" #include "lock.hpp"
#include "migration.hpp"
#include "notifs.hpp" #include "notifs.hpp"
#include "osd.hpp" #include "osd.hpp"
#include "screenshot.hpp" #include "screenshot.hpp"
@@ -44,6 +46,7 @@ Config::Config(QObject* parent)
, m_clipboard(new Clipboard(this)) , m_clipboard(new Clipboard(this))
, m_colors(new Colors(this)) , m_colors(new Colors(this))
, m_dashboard(new Dashboard(this)) , m_dashboard(new Dashboard(this))
, m_display(new Display(this))
, m_dock(new Dock(this)) , m_dock(new Dock(this))
, m_general(new General(this)) , m_general(new General(this))
, m_launcher(new Launcher(this)) , m_launcher(new Launcher(this))
@@ -91,8 +94,7 @@ Config* Config::instance() {
} }
Config* Config::create(QQmlEngine*, QJSEngine*) { Config* Config::create(QQmlEngine*, QJSEngine*) {
if (!s_instance) if (!s_instance) s_instance = new Config();
s_instance = new Config();
return s_instance; return s_instance;
} }
@@ -111,11 +113,14 @@ void Config::loadSync() {
QJsonObject before; QJsonObject before;
bool existed = false; bool existed = false;
bool migrated = false;
if (f.open(QIODevice::ReadOnly)) { if (f.open(QIODevice::ReadOnly)) {
const auto doc = QJsonDocument::fromJson(f.readAll()); const auto doc = QJsonDocument::fromJson(f.readAll());
if (doc.isObject()) { if (doc.isObject()) {
before = doc.object(); const QJsonObject raw = doc.object();
before = ConfigMigrations::apply(raw);
migrated = before != raw;
existed = true; existed = true;
} else { } else {
qInfo() << "Config: existing config at" << filePath() qInfo() << "Config: existing config at" << filePath()
@@ -130,7 +135,7 @@ void Config::loadSync() {
m_loading = false; m_loading = false;
const auto after = toJson().toObject(); const auto after = toJson().toObject();
if (!existed || after != before) saveNow(); if (!existed || migrated || after != before) saveNow();
} }
void Config::updateWatch() { void Config::updateWatch() {
@@ -176,16 +181,18 @@ void Config::loadAsync() {
if (f.open(QIODevice::ReadOnly)) { if (f.open(QIODevice::ReadOnly)) {
const auto doc = QJsonDocument::fromJson(f.readAll()); const auto doc = QJsonDocument::fromJson(f.readAll());
const bool valid = doc.isObject(); const bool valid = doc.isObject();
const QJsonObject before = valid ? doc.object() : QJsonObject(); const QJsonObject raw = valid ? doc.object() : QJsonObject();
const QJsonObject before = ConfigMigrations::apply(raw);
const bool migrated = valid && before != raw;
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
this, this,
[this, valid, before]() { [this, valid, migrated, before]() {
loadFromJson(QJsonValue(before)); loadFromJson(QJsonValue(before));
m_loading = false; m_loading = false;
const auto after = toJson().toObject(); const auto after = toJson().toObject();
if (!valid || after != before) saveNow(); if (!valid || migrated || after != before) saveNow();
if (m_reloadPending) m_reloadTimer.start(); if (m_reloadPending) m_reloadTimer.start();
}, },
+3
View File
@@ -21,6 +21,7 @@ class Bar;
class Clipboard; class Clipboard;
class Colors; class Colors;
class Dashboard; class Dashboard;
class Display;
class Dock; class Dock;
class General; class General;
class Launcher; class Launcher;
@@ -44,6 +45,7 @@ class Config : public ConfigObject {
Q_MOC_INCLUDE("clipboard.hpp") Q_MOC_INCLUDE("clipboard.hpp")
Q_MOC_INCLUDE("colors.hpp") Q_MOC_INCLUDE("colors.hpp")
Q_MOC_INCLUDE("dashboard.hpp") Q_MOC_INCLUDE("dashboard.hpp")
Q_MOC_INCLUDE("display.hpp")
Q_MOC_INCLUDE("dock.hpp") Q_MOC_INCLUDE("dock.hpp")
Q_MOC_INCLUDE("general.hpp") Q_MOC_INCLUDE("general.hpp")
Q_MOC_INCLUDE("launcher.hpp") Q_MOC_INCLUDE("launcher.hpp")
@@ -62,6 +64,7 @@ class Config : public ConfigObject {
CONFIG_SUBOBJECT(Clipboard, clipboard) CONFIG_SUBOBJECT(Clipboard, clipboard)
CONFIG_SUBOBJECT(Colors, colors) CONFIG_SUBOBJECT(Colors, colors)
CONFIG_SUBOBJECT(Dashboard, dashboard) CONFIG_SUBOBJECT(Dashboard, dashboard)
CONFIG_SUBOBJECT(Display, display)
CONFIG_SUBOBJECT(Dock, dock) CONFIG_SUBOBJECT(Dock, dock)
CONFIG_SUBOBJECT(General, general) CONFIG_SUBOBJECT(General, general)
CONFIG_SUBOBJECT(Launcher, launcher) CONFIG_SUBOBJECT(Launcher, launcher)
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include "configobject.hpp"
#include <qqmlintegration.h>
namespace ZShell::config {
class Nightlight : public ConfigObject {
Q_OBJECT
QML_ANONYMOUS
CFG_PROPERTY(bool, schedule, true)
CFG_PROPERTY(int, scheduleStart, 1200)
CFG_PROPERTY(int, scheduleEnd, 570)
CFG_PROPERTY(bool, useNative, true)
CFG_PROPERTY(int, fadeDuration, 2000)
CFG_PROPERTY(int, temp, 2600)
public:
explicit Nightlight(QObject* parent = nullptr) : ConfigObject(parent) {}
};
class Display : public ConfigObject {
Q_OBJECT
QML_ANONYMOUS
CONFIG_SUBOBJECT(Nightlight, nightlight)
public:
explicit Display(QObject* parent = nullptr)
: ConfigObject(parent), m_nightlight(new Nightlight(this)) {}
};
} // namespace ZShell::config
-22
View File
@@ -91,26 +91,6 @@ class Battery : public ConfigObject {
explicit Battery(QObject* parent = nullptr) : ConfigObject(parent) {} explicit Battery(QObject* parent = nullptr) : ConfigObject(parent) {}
}; };
class ColorSettings : public ConfigObject {
Q_OBJECT
QML_ANONYMOUS
CFG_PROPERTY(int, hyprsunsetTemp, 2600)
CFG_PROPERTY(QString, mode, QStringLiteral("dark"))
CFG_PROPERTY(bool, neovimColors, false)
CFG_PROPERTY(bool, scheduleDark, false)
CFG_PROPERTY(int, scheduleDarkEnd, 600)
CFG_PROPERTY(int, scheduleDarkStart, 1140)
CFG_PROPERTY(bool, scheduleHyprsunset, true)
CFG_PROPERTY(int, scheduleHyprsunsetEnd, 570)
CFG_PROPERTY(int, scheduleHyprsunsetStart, 1200)
CFG_PROPERTY(bool, schemeGeneration, true)
CFG_PROPERTY(bool, smart, false)
public:
explicit ColorSettings(QObject* parent = nullptr) : ConfigObject(parent) {}
};
class Idle : public ConfigObject { class Idle : public ConfigObject {
Q_OBJECT Q_OBJECT
QML_ANONYMOUS QML_ANONYMOUS
@@ -135,7 +115,6 @@ class General : public ConfigObject {
CONFIG_SUBOBJECT(Apps, apps) CONFIG_SUBOBJECT(Apps, apps)
CONFIG_SUBOBJECT(Battery, battery) CONFIG_SUBOBJECT(Battery, battery)
CONFIG_SUBOBJECT(ColorSettings, color)
CFG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss")) CFG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss"))
CFG_PROPERTY(bool, desktopIcons, true) CFG_PROPERTY(bool, desktopIcons, true)
CONFIG_SUBOBJECT(Idle, idle) CONFIG_SUBOBJECT(Idle, idle)
@@ -151,7 +130,6 @@ class General : public ConfigObject {
: ConfigObject(parent) : ConfigObject(parent)
, m_apps(new Apps(this)) , m_apps(new Apps(this))
, m_battery(new Battery(this)) , m_battery(new Battery(this))
, m_color(new ColorSettings(this))
, m_idle(new Idle(this)) {} , m_idle(new Idle(this)) {}
}; };
+1
View File
@@ -24,6 +24,7 @@ class Llm : public ConfigObject {
CFG_PROPERTY(QString, model, "") CFG_PROPERTY(QString, model, "")
CFG_PROPERTY(double, temperature, 0.7) CFG_PROPERTY(double, temperature, 0.7)
CFG_PROPERTY(bool, tools, true) CFG_PROPERTY(bool, tools, true)
CFG_PROPERTY(bool, enabled, true)
CONFIG_SUBOBJECT(LlmAppearance, appearance) CONFIG_SUBOBJECT(LlmAppearance, appearance)
public: 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 chat.hpp chat.cpp
chatstore.hpp chatstore.cpp chatstore.hpp chatstore.cpp
codehighlighter.hpp codehighlighter.cpp codehighlighter.hpp codehighlighter.cpp
filetool.hpp filetool.cpp
generation.hpp generation.cpp generation.hpp generation.cpp
llmclient.hpp llmclient.cpp llmclient.hpp llmclient.cpp
markdownblock.hpp markdownblock.hpp
+19 -18
View File
@@ -1,6 +1,7 @@
#include "chat.hpp" #include "chat.hpp"
#include "config.hpp" #include "config.hpp"
#include "filetool.hpp"
#include "llm.hpp" #include "llm.hpp"
#include "llmclient.hpp" #include "llmclient.hpp"
#include "webfetchtool.hpp" #include "webfetchtool.hpp"
@@ -10,12 +11,14 @@
namespace ZShell::llm { namespace ZShell::llm {
Chat::Chat(QObject* parent) Chat::Chat(QObject* parent)
: QObject(parent), m_store(new ChatStore(this)), m_client(new LlmClient(this)) { : QObject(parent)
if (!config::Config::instance()) , m_store(new ChatStore(this))
new config::Config(); , m_client(new LlmClient(this)) {
if (!config::Config::instance()) new config::Config();
m_store->setLlmClient(m_client); m_store->setLlmClient(m_client);
m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
m_client->tools()->registerTool(new FileReadTool(m_client->tools()));
const auto* llm = config::Config::instance()->llm(); const auto* llm = config::Config::instance()->llm();
m_client->setEndpoint(llm->endpoint()); m_client->setEndpoint(llm->endpoint());
@@ -37,17 +40,14 @@ Chat::Chat(QObject* parent)
}); });
connect(m_client, &LlmClient::busyChanged, this, [this]() { connect(m_client, &LlmClient::busyChanged, this, [this]() {
// A fresh run supersedes the previous error.
if (m_client->busy() && !m_lastError.isEmpty()) { if (m_client->busy() && !m_lastError.isEmpty()) {
m_lastError.clear(); m_lastError.clear();
Q_EMIT lastErrorChanged(); Q_EMIT lastErrorChanged();
} }
Q_EMIT busyChanged(); Q_EMIT busyChanged();
}); });
connect( connect(m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged);
m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged); connect(m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
connect(
m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
connect( connect(
m_client, m_client,
&LlmClient::availableModelsChanged, &LlmClient::availableModelsChanged,
@@ -97,8 +97,8 @@ Chat::Chat(QObject* parent)
&LlmClient::iconSuggested, &LlmClient::iconSuggested,
this, this,
[this](ChatSession* session, const QString& icon) { [this](ChatSession* session, const QString& icon) {
qInfo() << "Chat: applying generated icon" << session->id() qInfo() << "Chat: applying generated icon" << session->id() << icon
<< icon << "(was" << session->icon() << ")"; << "(was" << session->icon() << ")";
session->setIcon(icon); session->setIcon(icon);
m_store->saveMeta(session); m_store->saveMeta(session);
}); });
@@ -138,11 +138,11 @@ QString Chat::streamingChatId() const {
return m_client->streamingChatId(); return m_client->streamingChatId();
} }
Chat* Chat::s_instance = nullptr; Chat* Chat::s_instance = nullptr;
Chat* Chat::create(QQmlEngine*, QJSEngine*) { Chat* Chat::create(QQmlEngine*, QJSEngine*) {
if (!s_instance) if (!s_instance) s_instance = new Chat();
s_instance = new Chat();
return s_instance; return s_instance;
} }
@@ -151,8 +151,7 @@ void Chat::stop() {
} }
void Chat::dismissError() { void Chat::dismissError() {
if (m_lastError.isEmpty()) if (m_lastError.isEmpty()) return;
return;
m_lastError.clear(); m_lastError.clear();
Q_EMIT lastErrorChanged(); Q_EMIT lastErrorChanged();
} }
@@ -161,12 +160,14 @@ void Chat::refreshModels() {
m_client->refreshModels(); m_client->refreshModels();
} }
void Chat::refreshFromServer() {
m_client->refreshFromServer();
}
void Chat::selectModel(const QString& id) { void Chat::selectModel(const QString& id) {
if (id.isEmpty()) if (id.isEmpty()) return;
return;
m_client->setModel(id); m_client->setModel(id);
if (auto* config = config::Config::instance()) if (auto* config = config::Config::instance()) config->llm()->set_model(id);
config->llm()->set_model(id);
} }
} // namespace ZShell::llm } // namespace ZShell::llm
+10 -6
View File
@@ -14,9 +14,6 @@ namespace ZShell::llm {
class LlmClient; class LlmClient;
// QML-facing facade. Persistence lives in ChatStore, network streaming in
// LlmClient; this class only wires them together and exposes the
// application-wide state.
class Chat : public QObject { class Chat : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
@@ -25,12 +22,18 @@ class Chat : public QObject {
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged) Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged)
Q_PROPERTY(QString model READ model NOTIFY modelChanged) Q_PROPERTY(QString model READ model NOTIFY modelChanged)
Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged) Q_PROPERTY(
QStringList availableModels READ availableModels NOTIFY
availableModelsChanged)
Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged) Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged)
Q_PROPERTY(bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY toolsEnabledChanged) Q_PROPERTY(
bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY
toolsEnabledChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT) Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT)
Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged) Q_PROPERTY(
QString streamingChatId READ streamingChatId NOTIFY
streamingChatIdChanged)
public: public:
explicit Chat(QObject* parent = nullptr); explicit Chat(QObject* parent = nullptr);
@@ -49,6 +52,7 @@ class Chat : public QObject {
Q_INVOKABLE void stop(); Q_INVOKABLE void stop();
Q_INVOKABLE void dismissError(); Q_INVOKABLE void dismissError();
Q_INVOKABLE void refreshModels(); Q_INVOKABLE void refreshModels();
Q_INVOKABLE void refreshFromServer();
Q_INVOKABLE void selectModel(const QString& id); Q_INVOKABLE void selectModel(const QString& id);
static Chat* create(QQmlEngine*, QJSEngine*); static Chat* create(QQmlEngine*, QJSEngine*);
+49 -102
View File
@@ -37,24 +37,16 @@ QString segmentTypeName(LlmSegment::Type type) {
} }
LlmSegment::Type segmentTypeFromName(const QString& name) { LlmSegment::Type segmentTypeFromName(const QString& name) {
if (name == QLatin1String("tool_call")) if (name == QLatin1String("tool_call")) return LlmSegment::Type::ToolCall;
return LlmSegment::Type::ToolCall; if (name == QLatin1String("content")) return LlmSegment::Type::Content;
if (name == QLatin1String("content"))
return LlmSegment::Type::Content;
return LlmSegment::Type::Reasoning; return LlmSegment::Type::Reasoning;
} }
// A null QString binds as SQL NULL, which violates the NOT NULL columns;
// DEFAULT only applies to omitted columns, not explicit NULLs.
QString sqlText(const QString& value) { QString sqlText(const QString& value) {
if (value.isNull()) if (value.isNull()) return QStringLiteral("");
return QStringLiteral("");
return value; return value;
} }
// Plain data for one session's messages, fetched on a worker thread
// and turned into the QObject tree on the GUI thread. Messages are
// ordered as the model displays them (newest first).
struct SegmentRow { struct SegmentRow {
QString type; QString type;
QString text; QString text;
@@ -82,14 +74,13 @@ struct MessageRow {
} // namespace } // namespace
ChatStore::ChatStore(QObject* parent) ChatStore::ChatStore(QObject* parent)
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) { : QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
openDb(); openDb();
load(); load();
} }
ChatStore::~ChatStore() { ChatStore::~ChatStore() {
if (m_connectionName.isEmpty()) if (m_connectionName.isEmpty()) return;
return;
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
db.close(); db.close();
QSqlDatabase::removeDatabase(m_connectionName); QSqlDatabase::removeDatabase(m_connectionName);
@@ -119,8 +110,7 @@ void ChatStore::openDb() {
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS sessions (\n" "CREATE TABLE IF NOT EXISTS sessions (\n"
" id TEXT PRIMARY KEY,\n" " id TEXT PRIMARY KEY,\n"
" title TEXT NOT NULL DEFAULT '',\n" " title TEXT NOT NULL DEFAULT '',\n"
@@ -132,8 +122,7 @@ void ChatStore::openDb() {
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS messages (\n" "CREATE TABLE IF NOT EXISTS messages (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" session_id TEXT NOT NULL REFERENCES sessions (id) " " session_id TEXT NOT NULL REFERENCES sessions (id) "
@@ -144,8 +133,7 @@ void ChatStore::openDb() {
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS generations (\n" "CREATE TABLE IF NOT EXISTS generations (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" message_id INTEGER NOT NULL REFERENCES messages " " message_id INTEGER NOT NULL REFERENCES messages "
@@ -156,8 +144,7 @@ void ChatStore::openDb() {
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral(
"CREATE TABLE IF NOT EXISTS segments (\n" "CREATE TABLE IF NOT EXISTS segments (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" generation_id INTEGER NOT NULL REFERENCES generations " " generation_id INTEGER NOT NULL REFERENCES generations "
@@ -200,8 +187,7 @@ QVariantList ChatStore::values() const {
} }
ChatSession* ChatStore::at(int index) const { ChatSession* ChatStore::at(int index) const {
if (index < 0 || index >= m_sessions.size()) if (index < 0 || index >= m_sessions.size()) return nullptr;
return nullptr;
return m_sessions.at(index); return m_sessions.at(index);
} }
@@ -238,8 +224,7 @@ void ChatStore::remove(ChatSession* chat) {
} }
void ChatStore::removeSession(ChatSession* session) { void ChatStore::removeSession(ChatSession* session) {
if (!session || !m_sessions.contains(session)) if (!session || !m_sessions.contains(session)) return;
return;
const QList<ChatSession*> before = m_sessions; const QList<ChatSession*> before = m_sessions;
Q_EMIT sessionRemoved(session); Q_EMIT sessionRemoved(session);
{ {
@@ -269,8 +254,7 @@ void ChatStore::clear() {
ChatSession* ChatStore::sessionById(const QString& id) { ChatSession* ChatStore::sessionById(const QString& id) {
for (auto* session : m_sessions) for (auto* session : m_sessions)
if (session->id() == id) if (session->id() == id) return session;
return session;
return nullptr; return nullptr;
} }
@@ -279,25 +263,21 @@ void ChatStore::setLlmClient(LlmClient* client) {
} }
void ChatStore::persist(ChatSession* session) { void ChatStore::persist(ChatSession* session) {
if (!session || !m_sessions.contains(session)) if (!session || !m_sessions.contains(session)) return;
return;
if (!session->isLoaded()) { if (!session->isLoaded()) {
// Saving now would persist an incomplete model and wipe the
// stored history; run it again when the load lands.
m_pendingPersists.insert(session); m_pendingPersists.insert(session);
return; return;
} }
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
if (!saveSession(session)) if (!saveSession(session)) return;
return;
sortAndNotify(); sortAndNotify();
} }
void ChatStore::saveMeta(ChatSession* session) { void ChatStore::saveMeta(ChatSession* session) {
if (!session || !m_sessions.contains(session)) if (!session || !m_sessions.contains(session)) return;
return;
QSqlQuery query(db()); QSqlQuery query(db());
query.prepare("UPDATE sessions SET title = :title, icon = :icon " query.prepare(
"UPDATE sessions SET title = :title, icon = :icon "
"WHERE id = :id"); "WHERE id = :id");
query.bindValue(":title", sqlText(session->title())); query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon())); query.bindValue(":icon", sqlText(session->icon()));
@@ -338,18 +318,18 @@ bool ChatStore::saveSession(ChatSession* session) {
"INSERT INTO messages (session_id, role, timestamp) " "INSERT INTO messages (session_id, role, timestamp) "
"VALUES (:id, :role, :timestamp)"); "VALUES (:id, :role, :timestamp)");
QSqlQuery generationInsert(handle); QSqlQuery generationInsert(handle);
ok = ok && generationInsert.prepare( ok = ok &&
generationInsert.prepare(
"INSERT INTO generations (message_id, timestamp, is_active) " "INSERT INTO generations (message_id, timestamp, is_active) "
"VALUES (:mid, :timestamp, :is_active)"); "VALUES (:mid, :timestamp, :is_active)");
QSqlQuery segmentInsert(handle); QSqlQuery segmentInsert(handle);
ok = ok && segmentInsert.prepare( ok = ok &&
segmentInsert.prepare(
"INSERT INTO segments (generation_id, type, text, name, " "INSERT INTO segments (generation_id, type, text, name, "
"tool_call_id, arguments, result, status, elapsed_ms, " "tool_call_id, arguments, result, status, elapsed_ms, "
"timestamp) VALUES (:gid, :type, :text, :name, " "timestamp) VALUES (:gid, :type, :text, :name, "
":tool_call_id, :arguments, :result, :status, :elapsed_ms, " ":tool_call_id, :arguments, :result, :status, :elapsed_ms, "
":timestamp)"); ":timestamp)");
// The model holds messages most recent first; the database keeps
// natural rowid order, so iterate from the oldest row up.
const auto* model = session->messagesModel(); const auto* model = session->messagesModel();
for (int row = model->rowCount() - 1; ok && row >= 0; --row) { for (int row = model->rowCount() - 1; ok && row >= 0; --row) {
const auto* message = model->at(row); const auto* message = model->at(row);
@@ -395,13 +375,12 @@ bool ChatStore::saveSession(ChatSession* session) {
":tool_call_id", sqlText(segment->toolCallId())); ":tool_call_id", sqlText(segment->toolCallId()));
segmentInsert.bindValue( segmentInsert.bindValue(
":arguments", sqlText(segment->arguments())); ":arguments", sqlText(segment->arguments()));
segmentInsert.bindValue(":result", sqlText(segment->result())); segmentInsert.bindValue(
":result", sqlText(segment->result()));
segmentInsert.bindValue( segmentInsert.bindValue(
":status", static_cast<int>(segment->status())); ":status", static_cast<int>(segment->status()));
segmentInsert.bindValue( segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs());
":elapsed_ms", segment->elapsedMs()); segmentInsert.bindValue(":timestamp", segment->timestamp());
segmentInsert.bindValue(
":timestamp", segment->timestamp());
if (!segmentInsert.exec()) { if (!segmentInsert.exec()) {
ok = false; ok = false;
qWarning() << "ChatStore: saveSession" << id qWarning() << "ChatStore: saveSession" << id
@@ -414,53 +393,47 @@ bool ChatStore::saveSession(ChatSession* session) {
} }
} }
if (!ok || !handle.commit()) { if (!ok || !handle.commit()) {
qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back"; qWarning() << "ChatStore: saveSession" << id
<< "commit failed, rolling back";
handle.rollback(); handle.rollback();
ok = false; ok = false;
qWarning() << "ChatStore: failed to save session" << session->id() << ":" qWarning() << "ChatStore: failed to save session" << session->id()
<< handle.lastError().text(); << ":" << handle.lastError().text();
} }
return ok; return ok;
} }
void ChatStore::loadMessagesInto(ChatSession* session) { void ChatStore::loadMessagesInto(ChatSession* session) {
if (!session) if (!session) return;
return;
const QString sessionId = session->id(); const QString sessionId = session->id();
const QString path = m_dbPath; const QString path = m_dbPath;
// SQL on a worker thread (its own connection; QSqlDatabase objects QThreadPool::globalInstance()->start([store = QPointer<ChatStore>(this),
// are thread-affine). Rows come back as plain data. session =
QThreadPool::globalInstance()->start( QPointer<ChatSession>(session),
[store = QPointer<ChatStore>(this), sessionId,
session = QPointer<ChatSession>(session), sessionId, path]() { path]() {
QList<MessageRow> rows; QList<MessageRow> rows;
const QString connName = QUuid::createUuid().toString(); const QString connName = QUuid::createUuid().toString();
{ {
QSqlDatabase db = QSqlDatabase::addDatabase( QSqlDatabase db =
QStringLiteral("QSQLITE"), connName); QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName);
db.setDatabaseName(path); db.setDatabaseName(path);
if (db.open()) { if (db.open()) {
// Tolerate the GUI thread writing while we read.
QSqlQuery busy(db); QSqlQuery busy(db);
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000")); busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
// Newest first so the model receives rows in
// display order.
QSqlQuery query(db); QSqlQuery query(db);
query.prepare( query.prepare(
"SELECT id, role, timestamp FROM messages " "SELECT id, role, timestamp FROM messages "
"WHERE session_id = :id ORDER BY rowid DESC"); "WHERE session_id = :id ORDER BY rowid DESC");
query.bindValue(":id", sessionId); query.bindValue(":id", sessionId);
if (!query.exec()) { if (!query.exec()) {
qWarning() qWarning() << "ChatStore: failed to load messages for"
<< "ChatStore: failed to load messages for" << sessionId << ":" << query.lastError().text();
<< sessionId << ":"
<< query.lastError().text();
} else { } else {
while (query.next()) { while (query.next()) {
const int messageId = query.value(0).toInt(); const int messageId = query.value(0).toInt();
MessageRow message; MessageRow message;
message.user = message.user = query.value(1).toString() ==
query.value(1).toString() ==
QLatin1String("user"); QLatin1String("user");
message.timestamp = query.value(2).toLongLong(); message.timestamp = query.value(2).toLongLong();
QSqlQuery generationQuery(db); QSqlQuery generationQuery(db);
@@ -483,8 +456,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
"timestamp FROM segments WHERE " "timestamp FROM segments WHERE "
"generation_id = :gid ORDER BY rowid"); "generation_id = :gid ORDER BY rowid");
segmentQuery.bindValue( segmentQuery.bindValue(
":gid", ":gid", generationQuery.value(0).toInt());
generationQuery.value(0).toInt());
if (segmentQuery.exec()) { if (segmentQuery.exec()) {
while (segmentQuery.next()) { while (segmentQuery.next()) {
SegmentRow segment; SegmentRow segment;
@@ -530,32 +502,21 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
} }
db.close(); db.close();
} else { } else {
qWarning() qWarning() << "ChatStore: failed to open database for load:"
<< "ChatStore: failed to open database for load:"
<< db.lastError().text(); << 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); 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( QMetaObject::invokeMethod(
QCoreApplication::instance(), QCoreApplication::instance(),
[store, session, rows = std::move(rows)]() mutable { [store, session, rows = std::move(rows)]() mutable {
ChatStore* st = store; ChatStore* st = store;
ChatSession* s = session; ChatSession* s = session;
if (!st || !s) if (!st || !s) return;
return;
// Rows fetched from disk; newest first.
auto* model = s->model(); auto* model = s->model();
if (!model) if (!model) return;
return;
QList<ChatMessage*> messages; QList<ChatMessage*> messages;
for (const MessageRow& row : rows) { for (const MessageRow& row : rows) {
auto* message = model->createMessage( auto* message = model->createMessage(
@@ -585,14 +546,11 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
segment->restore(segmentRow.elapsedMs); segment->restore(segmentRow.elapsedMs);
generation->addSegment(segment); generation->addSegment(segment);
} }
if (generationRow.active) if (generationRow.active) activeIndex = i;
activeIndex = i;
} }
message->setActiveGeneration(activeIndex); message->setActiveGeneration(activeIndex);
messages.append(message); messages.append(message);
} }
// Rows added live while the load was in flight are
// newer than anything on disk; keep them in front.
if (model->rowCount() > 0) { if (model->rowCount() > 0) {
QList<ChatMessage*> live = messages; QList<ChatMessage*> live = messages;
for (int r = 0; r < model->rowCount(); ++r) for (int r = 0; r < model->rowCount(); ++r)
@@ -602,16 +560,9 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
if (!messages.isEmpty() || model->rowCount() > 0) if (!messages.isEmpty() || model->rowCount() > 0)
s->adoptMessages(messages); s->adoptMessages(messages);
// Mark loaded only once the model holds both the
// fetched history and the rows added live while the
// load ran, so a deferred startGeneration (triggered
// by loaded()) builds its context from the complete
// conversation.
s->markLoaded(); s->markLoaded();
if (s->takeClearPending()) { if (s->takeClearPending()) {
// Cleared while the load was in flight; drop
// everything now that the model is populated.
s->clear(); s->clear();
} else if (st->m_pendingPersists.remove(s)) { } else if (st->m_pendingPersists.remove(s)) {
st->persist(s); st->persist(s);
@@ -647,22 +598,18 @@ void ChatStore::sortAndNotify() {
m_sessions.begin(), m_sessions.begin(),
m_sessions.end(), m_sessions.end(),
[](const ChatSession* a, const ChatSession* b) { [](const ChatSession* a, const ChatSession* b) {
if (a->pinned() != b->pinned()) if (a->pinned() != b->pinned()) return a->pinned() > b->pinned();
return a->pinned() > b->pinned();
return a->updatedAtMs() > b->updatedAtMs(); return a->updatedAtMs() > b->updatedAtMs();
}); });
notify(before); notify(before);
} }
void ChatStore::notify(const QList<ChatSession*>& before) { void ChatStore::notify(const QList<ChatSession*>& before) {
if (before.size() != m_sessions.size()) if (before.size() != m_sessions.size()) Q_EMIT countChanged();
Q_EMIT countChanged();
bool same = before.size() == m_sessions.size(); bool same = before.size() == m_sessions.size();
for (int i = 0; same && i < m_sessions.size(); ++i) for (int i = 0; same && i < m_sessions.size(); ++i)
if (before.at(i) != m_sessions.at(i)) if (before.at(i) != m_sessions.at(i)) same = false;
same = false; if (!same) Q_EMIT valuesChanged();
if (!same)
Q_EMIT valuesChanged();
} }
} // namespace ZShell::llm } // namespace ZShell::llm
-5
View File
@@ -39,9 +39,6 @@ class ChatStore : public QObject {
void persist(ChatSession* session); void persist(ChatSession* session);
void saveMeta(ChatSession* session); void saveMeta(ChatSession* session);
// Loads the session's messages from the database. The SQL runs on a
// worker thread; the object tree is built and the model updated on
// the GUI thread when it arrives (ChatSession::loaded).
void loadMessagesInto(ChatSession* session); void loadMessagesInto(ChatSession* session);
Q_SIGNALS: Q_SIGNALS:
@@ -61,8 +58,6 @@ class ChatStore : public QObject {
LlmClient* m_llmClient = nullptr; LlmClient* m_llmClient = nullptr;
QString m_connectionName; QString m_connectionName;
QString m_dbPath; QString m_dbPath;
// Sessions whose persist() ran before their messages finished
// loading; persisted once the load lands.
QSet<ChatSession*> m_pendingPersists; QSet<ChatSession*> m_pendingPersists;
[[nodiscard]] QSqlDatabase db() const; [[nodiscard]] QSqlDatabase db() const;
+142 -106
View File
@@ -21,7 +21,6 @@ namespace ZShell::llm {
namespace hl { namespace hl {
// Role ids; 0 means "no color".
enum Role : uint8_t { enum Role : uint8_t {
None = 0, None = 0,
Comment, Comment,
@@ -43,29 +42,43 @@ enum Role : uint8_t {
const char* roleName(Role role) { const char* roleName(Role role) {
switch (role) { switch (role) {
case Comment: return "comment"; case Comment:
case String: return "string"; return "comment";
case StringKey: return "string.key"; case String:
case Number: return "number"; return "string";
case Constant: return "constant"; case StringKey:
case Keyword: return "keyword"; return "string.key";
case Type: return "type"; case Number:
case Function: return "function"; return "number";
case Method: return "method"; case Constant:
case Macro: return "macro"; return "constant";
case Preproc: return "preproc"; case Keyword:
case Operator: return "operator"; return "keyword";
case Property: return "property"; case Type:
case Label: return "label"; return "type";
case Attribute: return "attribute"; case Function:
default: return ""; return "function";
case Method:
return "method";
case Macro:
return "macro";
case Preproc:
return "preproc";
case Operator:
return "operator";
case Property:
return "property";
case Label:
return "label";
case Attribute:
return "attribute";
default:
return "";
} }
} }
using LanguageFn = const TSLanguage* (*)(); using LanguageFn = const TSLanguage* (*)();
// Grammar registry generated by CMake from the installed grammars and
// their highlight queries (see CMakeLists.txt).
const QHash<QString, CodeHighlighter::Grammar>& grammars() { const QHash<QString, CodeHighlighter::Grammar>& grammars() {
static const QHash<QString, CodeHighlighter::Grammar> grammars = [] { static const QHash<QString, CodeHighlighter::Grammar> grammars = [] {
QHash<QString, CodeHighlighter::Grammar> map; QHash<QString, CodeHighlighter::Grammar> map;
@@ -89,8 +102,6 @@ const QHash<QString, CodeHighlighter::Grammar>& grammars() {
CodeHighlighter* CodeHighlighter::s_instance = nullptr; CodeHighlighter* CodeHighlighter::s_instance = nullptr;
const QHash<QString, QString>& CodeHighlighter::aliases() { const QHash<QString, QString>& CodeHighlighter::aliases() {
// Language tags as written in code fences (and common variants) to
// grammar id.
static const QHash<QString, QString> aliases = [] { static const QHash<QString, QString> aliases = [] {
QHash<QString, QString> map; QHash<QString, QString> map;
map.insert("c", "c"); map.insert("c", "c");
@@ -145,49 +156,38 @@ const QHash<QString, QString>& CodeHighlighter::aliases() {
uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) { uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) {
const QString n = QString::fromUtf8(name, length); const QString n = QString::fromUtf8(name, length);
if (n == "comment") if (n == "comment") return hl::Role::Comment;
return hl::Role::Comment;
if (n.startsWith("string")) if (n.startsWith("string"))
return n == "string.special.key" ? hl::Role::StringKey : hl::Role::String; return n == "string.special.key" ? hl::Role::StringKey
if (n == "escape" || n == "regexp") : hl::Role::String;
return hl::Role::String; if (n == "escape" || n == "regexp") return hl::Role::String;
if (n.startsWith("number")) if (n.startsWith("number")) return hl::Role::Number;
return hl::Role::Number; if (n.startsWith("constant") || n == "boolean" || n == "bool" ||
if (n.startsWith("constant") || n == "boolean" || n == "bool" n.startsWith("character"))
|| n.startsWith("character"))
return hl::Role::Constant; return hl::Role::Constant;
if (n.startsWith("keyword")) if (n.startsWith("keyword")) return hl::Role::Keyword;
return hl::Role::Keyword; if (n == "type" || n.startsWith("type.")) return hl::Role::Type;
if (n == "type" || n.startsWith("type.")) if (n.startsWith("namespace") || n.startsWith("module") ||
n == "support.type" || n == "support.namespace")
return hl::Role::Type; return hl::Role::Type;
if (n.startsWith("namespace") || n.startsWith("module") if (n.startsWith("function") || n == "constructor" ||
|| n == "support.type" || n == "support.namespace") n.startsWith("support.function"))
return hl::Role::Type;
if (n.startsWith("function") || n == "constructor"
|| n.startsWith("support.function"))
return hl::Role::Function; return hl::Role::Function;
if (n == "method" || n == "method.builtin") if (n == "method" || n == "method.builtin") return hl::Role::Method;
return hl::Role::Method; if (n.startsWith("macro")) return hl::Role::Macro;
if (n.startsWith("macro")) if (n.startsWith("preproc")) return hl::Role::Preproc;
return hl::Role::Macro; if (n == "operator" || n == "punctuation.operator" ||
if (n.startsWith("preproc")) n.startsWith("operator.") || n.startsWith("punctuation"))
return hl::Role::Preproc;
if (n == "operator" || n == "punctuation.operator" || n.startsWith("operator.")
|| n.startsWith("punctuation"))
return hl::Role::Operator; return hl::Role::Operator;
if (n == "property" || n == "field" || n.startsWith("property.")) if (n == "property" || n == "field" || n.startsWith("property."))
return hl::Role::Property; return hl::Role::Property;
if (n == "label") if (n == "label") return hl::Role::Label;
return hl::Role::Label;
if (n.startsWith("attribute") || n == "annotation") if (n.startsWith("attribute") || n == "annotation")
return hl::Role::Attribute; return hl::Role::Attribute;
// HTML tag names, CSS variables and friends.
if (n == "tag" || (n.startsWith("tag.") && n != "tag.delimiter")) if (n == "tag" || (n.startsWith("tag.") && n != "tag.delimiter"))
return hl::Role::Keyword; return hl::Role::Keyword;
if (n.startsWith("variable")) if (n.startsWith("variable")) return hl::Role::Constant;
return hl::Role::Constant; if (n.startsWith("support")) return hl::Role::Function;
if (n.startsWith("support"))
return hl::Role::Function;
return hl::Role::None; return hl::Role::None;
} }
@@ -195,26 +195,85 @@ const char* CodeHighlighter::roleName(uint8_t role) {
return hl::roleName(static_cast<hl::Role>(role)); return hl::roleName(static_cast<hl::Role>(role));
} }
QString CodeHighlighter::resolveId(const QString& language) {
const QString tag = language.trimmed().toLower();
const QString alias = aliases().value(tag);
return alias.isEmpty() ? tag : alias; // unknown tags = grammar id
}
QString CodeHighlighter::cacheKey(const QString& id, const QString& code) {
return id + QLatin1Char('\x01') + QString::number(code.size()) +
QLatin1Char('\x01') + QString::number(qHash(code));
}
QVariantList CodeHighlighter::lookupSpans(
const QString& code, const QString& language) const {
if (code.isEmpty()) return {};
const QString key = cacheKey(resolveId(language), code);
QMutexLocker locker(&m_cacheMutex);
const auto it = m_spanCache.constFind(key);
if (it == m_spanCache.constEnd() || it->code != code) return {};
const qsizetype pos = m_spanCacheOrder.indexOf(key);
if (pos >= 0) m_spanCacheOrder.move(pos, m_spanCacheOrder.size() - 1);
return it->spans;
}
void CodeHighlighter::storeSpans(
const QString& code,
const QString& language,
const QVariantList& spans) const {
if (spans.isEmpty() || code.isEmpty()) return;
static constexpr int kMaxEntries = 32;
static constexpr int kMaxBytes = 1024 * 1024;
const QString key = cacheKey(resolveId(language), code);
const int bytes = static_cast<int>(code.toUtf8().size());
QMutexLocker locker(&m_cacheMutex);
auto it = m_spanCache.find(key);
if (it != m_spanCache.end()) {
m_spanCacheBytes -= static_cast<int>(it->code.toUtf8().size());
m_spanCache.erase(it);
m_spanCacheOrder.removeAll(key);
}
while (m_spanCacheOrder.size() >= kMaxEntries ||
m_spanCacheBytes + bytes > kMaxBytes) {
if (m_spanCacheOrder.isEmpty()) break;
const QString oldest = m_spanCacheOrder.takeFirst();
m_spanCacheBytes -=
static_cast<int>(m_spanCache.value(oldest).code.toUtf8().size());
m_spanCache.remove(oldest);
}
m_spanCache.insert(key, SpanCacheEntry{code, spans});
m_spanCacheOrder.append(key);
m_spanCacheBytes += bytes;
}
void CodeHighlighter::highlight( void CodeHighlighter::highlight(
const QString& code, const QString& language, QObject* target, int token) { const QString& code, const QString& language, QObject* target, int token) {
QThreadPool::globalInstance()->start([this, target, token, code, language]() { QPointer<QObject> targetGuard(target);
const QVariantList cached = lookupSpans(code, language);
if (!cached.isEmpty()) {
QMetaObject::invokeMethod(
targetGuard,
"onHighlightSpans",
Qt::DirectConnection,
Q_ARG(QVariant, token),
Q_ARG(QVariant, cached));
return;
}
QThreadPool::globalInstance()->start(
[this, target, token, code, language]() {
const QVariantList spans = doHighlight(code, language); const QVariantList spans = doHighlight(code, language);
// The target item may be long gone by now (delegates are storeSpans(code, language, spans);
// recreated constantly while chats load); a destroyed target is
// simply skipped. Deliver through the app instance (never
// destroyed) and re-check there: posting to `target` from the
// pool thread would race with its destruction.
QPointer<QObject> guard(target); QPointer<QObject> guard(target);
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
QCoreApplication::instance(), QCoreApplication::instance(),
[guard, token, spans]() { [guard, token, spans]() {
if (!guard) if (!guard) return;
return;
// QML functions are only invokable by their generic
// QVariant overload, so pass untyped arguments.
QMetaObject::invokeMethod( QMetaObject::invokeMethod(
guard, "onHighlightSpans", guard,
Q_ARG(QVariant, token), Q_ARG(QVariant, spans)); "onHighlightSpans",
Q_ARG(QVariant, token),
Q_ARG(QVariant, spans));
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
}); });
@@ -223,40 +282,28 @@ void CodeHighlighter::highlight(
QVariantList CodeHighlighter::doHighlight( QVariantList CodeHighlighter::doHighlight(
const QString& code, const QString& language) const { const QString& code, const QString& language) const {
QVariantList spans; QVariantList spans;
if (code.isEmpty()) if (code.isEmpty()) return spans;
return spans;
const QString tag = language.trimmed().toLower(); const QString id = resolveId(language);
const QString id = [&] {
const QString alias = aliases().value(tag);
return alias.isEmpty() ? tag : alias; // unknown tags = grammar id
}();
const Grammar& grammar = hl::grammars().value(id); const Grammar& grammar = hl::grammars().value(id);
if (grammar.libs.empty()) if (grammar.libs.empty()) return spans;
return spans;
// Guard against pathological blocks; highlighting is best-effort.
static constexpr size_t kMaxBytes = 512 * 1024; static constexpr size_t kMaxBytes = 512 * 1024;
const QByteArray utf8 = code.toUtf8(); const QByteArray utf8 = code.toUtf8();
if (static_cast<size_t>(utf8.size()) > kMaxBytes) if (static_cast<size_t>(utf8.size()) > kMaxBytes) return spans;
return spans;
const TSLanguage* lang = nullptr; const TSLanguage* lang = nullptr;
TSQuery* query = nullptr; TSQuery* query = nullptr;
{ {
QMutexLocker locker(&m_stateMutex); QMutexLocker locker(&m_stateMutex);
auto& state = m_states[id]; auto& state = m_states[id];
// A missing library is retriable (it may be installed while the
// shell runs); an ABI mismatch on every candidate is not. Cache
// successes and permanent failures; leave retriable misses out.
if (!state || (!state->lang && !state->bad)) { if (!state || (!state->lang && !state->bad)) {
std::shared_ptr<State> fresh = std::make_shared<State>(); std::shared_ptr<State> fresh = std::make_shared<State>();
bool abiMismatch = false; bool abiMismatch = false;
for (size_t i = 0; i < grammar.libs.size(); ++i) { for (size_t i = 0; i < grammar.libs.size(); ++i) {
void* lib = dlopen(grammar.libs[i].c_str(), void* lib =
RTLD_NOW | RTLD_LOCAL); dlopen(grammar.libs[i].c_str(), RTLD_NOW | RTLD_LOCAL);
if (!lib) if (!lib) continue;
continue;
auto* symbol = reinterpret_cast<hl::LanguageFn>( auto* symbol = reinterpret_cast<hl::LanguageFn>(
dlsym(lib, grammar.symbols[i].c_str())); dlsym(lib, grammar.symbols[i].c_str()));
if (!symbol) { if (!symbol) {
@@ -276,7 +323,6 @@ QVariantList CodeHighlighter::doHighlight(
break; break;
} }
if (fresh->lang) { if (fresh->lang) {
// Candidates in priority order; first that compiles wins.
for (const char* source : grammar.queries) { for (const char* source : grammar.queries) {
TSQueryError errorType = TSQueryErrorNone; TSQueryError errorType = TSQueryErrorNone;
uint32_t errorOffset = 0; uint32_t errorOffset = 0;
@@ -286,21 +332,17 @@ QVariantList CodeHighlighter::doHighlight(
static_cast<uint32_t>(std::strlen(source)), static_cast<uint32_t>(std::strlen(source)),
&errorOffset, &errorOffset,
&errorType); &errorType);
if (!candidate) if (!candidate) continue;
continue;
fresh->query = candidate; fresh->query = candidate;
break; break;
} }
if (!fresh->query) if (!fresh->query) fresh->bad = true;
fresh->bad = true;
} else if (abiMismatch) { } else if (abiMismatch) {
fresh->bad = true; fresh->bad = true;
} }
if (fresh->lang || fresh->bad) if (fresh->lang || fresh->bad) state = std::move(fresh);
state = std::move(fresh);
} }
if (!state || state->bad || !state->lang) if (!state || state->bad || !state->lang) return spans;
return spans;
lang = static_cast<const TSLanguage*>(state->lang); lang = static_cast<const TSLanguage*>(state->lang);
query = static_cast<TSQuery*>(state->query); query = static_cast<TSQuery*>(state->query);
} }
@@ -317,13 +359,9 @@ QVariantList CodeHighlighter::doHighlight(
TSQueryCursor* cursor = ts_query_cursor_new(); TSQueryCursor* cursor = ts_query_cursor_new();
ts_query_cursor_exec(cursor, query, ts_tree_root_node(tree)); ts_query_cursor_exec(cursor, query, ts_tree_root_node(tree));
// Per-byte winner table: captures arrive in document order and later
// captures overwrite earlier ones (tree-sitter highlight convention).
const uint32_t size = static_cast<uint32_t>(utf8.size()); const uint32_t size = static_cast<uint32_t>(utf8.size());
std::vector<uint8_t> kinds(size, 0); std::vector<uint8_t> kinds(size, 0);
// QML slices the code by UTF-16 code unit, so spans must be in code
// units, not bytes. cu[b] = code units before byte b.
std::vector<uint32_t> cu(size + 1, 0); std::vector<uint32_t> cu(size + 1, 0);
for (uint32_t b = 0; b < size; ++b) { for (uint32_t b = 0; b < size; ++b) {
cu[b + 1] = cu[b]; cu[b + 1] = cu[b];
@@ -333,9 +371,9 @@ QVariantList CodeHighlighter::doHighlight(
else if (c < 0xC0) else if (c < 0xC0)
; // continuation byte ; // continuation byte
else if (c < 0xF0) else if (c < 0xF0)
cu[b + 1] += 1; // 2/3-byte lead -> BMP -> one unit cu[b + 1] += 1;
else else
cu[b + 1] += 2; // 4-byte lead -> surrogate pair cu[b + 1] += 2;
} }
TSQueryMatch match; TSQueryMatch match;
@@ -343,14 +381,13 @@ QVariantList CodeHighlighter::doHighlight(
while (ts_query_cursor_next_capture(cursor, &match, &captureIndex)) { while (ts_query_cursor_next_capture(cursor, &match, &captureIndex)) {
const TSQueryCapture& capture = match.captures[captureIndex]; const TSQueryCapture& capture = match.captures[captureIndex];
uint32_t nameLength = 0; uint32_t nameLength = 0;
const char* name = ts_query_capture_name_for_id(query, capture.index, &nameLength); const char* name =
ts_query_capture_name_for_id(query, capture.index, &nameLength);
const uint8_t role = roleFor(name, nameLength); const uint8_t role = roleFor(name, nameLength);
if (role == 0) if (role == 0) continue;
continue;
const uint32_t start = ts_node_start_byte(capture.node); const uint32_t start = ts_node_start_byte(capture.node);
const uint32_t end = ts_node_end_byte(capture.node); const uint32_t end = ts_node_end_byte(capture.node);
if (end <= start || end > size) if (end <= start || end > size) continue;
continue;
std::fill(kinds.begin() + start, kinds.begin() + end, role); std::fill(kinds.begin() + start, kinds.begin() + end, role);
} }
@@ -378,8 +415,7 @@ QVariantList CodeHighlighter::doHighlight(
} }
CodeHighlighter* CodeHighlighter::create(QQmlEngine*, QJSEngine*) { CodeHighlighter* CodeHighlighter::create(QQmlEngine*, QJSEngine*) {
if (!s_instance) if (!s_instance) s_instance = new CodeHighlighter();
s_instance = new CodeHighlighter();
return s_instance; return s_instance;
} }
+27 -41
View File
@@ -15,34 +15,6 @@ class QJSEngine;
namespace ZShell::llm { namespace ZShell::llm {
// Syntax highlighting for LLM code blocks via tree-sitter.
//
// The tree-sitter runtime is linked. Grammar libraries are dlopen()'d
// lazily, so a missing grammar degrades that language to plain text
// instead of breaking the build or the app. At configure time CMake
// discovers installed grammars (system packages plus the parsers
// Neovim's nvim-treesitter installs) and pairs each with highlight
// queries (vendored, Neovim's, or fetched from
// tree-sitter/highlighting); both are embedded in the generated
// highlight-queries.hpp.
//
// For each grammar the first loadable (ABI-compatible) library wins
// and the first query that compiles against it wins, so a version
// skew between a library and its query degrades gracefully.
//
// The fence language the LLM wrote (```cpp, ```python, ...) is mapped
// to a grammar through an alias table; tags not in the table are used
// as grammar ids as-is.
//
// highlight() parses the code off the GUI thread and delivers a list of
// span maps by calling target's "onHighlightSpans(token, spans)" method
// (on the GUI thread):
// { "start": int, "length": int, "kind": QString }
// where kind is a semantic role (keyword, string, comment, number,
// function, type, ...) that QML maps to theme colors. An empty list
// means "no highlighting" (unknown language or grammar not installed).
// token is passed back unchanged so the caller can drop results for
// superseded code; a destroyed target is simply skipped.
class CodeHighlighter : public QObject { class CodeHighlighter : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
@@ -50,39 +22,53 @@ class CodeHighlighter : public QObject {
public: public:
Q_INVOKABLE void highlight( Q_INVOKABLE void highlight(
const QString& code, const QString& language, QObject* target, int token); const QString& code,
const QString& language,
QObject* target,
int token);
static CodeHighlighter* create(QQmlEngine*, QJSEngine*); static CodeHighlighter* create(QQmlEngine*, QJSEngine*);
struct Grammar { struct Grammar {
// Library candidates in priority order (system package, then
// Neovim copies); libs[i] pairs with symbols[i].
std::vector<std::string> libs; std::vector<std::string> libs;
std::vector<std::string> symbols; std::vector<std::string> symbols;
// Candidate query sources in priority order; first that
// compiles against the loaded grammar wins.
std::vector<const char*> queries; std::vector<const char*> queries;
}; };
private: private:
struct State { struct State {
bool bad = false; // permanent failure, do not retry bool bad = false;
void* lib = nullptr; void* lib = nullptr;
const void* lang = nullptr; // const TSLanguage* const void* lang = nullptr;
void* query = nullptr; // TSQuery* void* query = nullptr;
}; };
[[nodiscard]] static const QHash<QString, QString>& aliases(); [[nodiscard]] static const QHash<QString, QString>& aliases();
// Maps a tree-sitter capture name to a role index (0 = unstyled).
[[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length); [[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length);
[[nodiscard]] static const char* roleName(uint8_t role); [[nodiscard]] static const char* roleName(uint8_t role);
// The parsing work; runs on worker threads, so the per-language [[nodiscard]] static QString resolveId(const QString& language);
// state must be initialized under m_stateMutex and is shared as an [[nodiscard]] static QString cacheKey(
// immutable object afterwards. const QString& id, const QString& code);
[[nodiscard]] QVariantList doHighlight(const QString& code, const QString& language) const; [[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;
QVariantList spans;
};
mutable QHash<QString, std::shared_ptr<const State>> m_states; mutable QHash<QString, std::shared_ptr<const State>> m_states;
mutable QMutex m_stateMutex; mutable QMutex m_stateMutex;
mutable QHash<QString, SpanCacheEntry> m_spanCache;
mutable QStringList m_spanCacheOrder;
mutable int m_spanCacheBytes = 0;
mutable QMutex m_cacheMutex;
static CodeHighlighter* s_instance; static CodeHighlighter* s_instance;
}; };
+153
View File
@@ -0,0 +1,153 @@
#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,
int outputBudgetChars,
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));
int budget = inlineBudgetChars();
if (outputBudgetChars > 0) budget = qMin(budget, outputBudgetChars);
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;
}
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
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include "tool.hpp"
namespace ZShell::llm {
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,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) override;
};
} // namespace ZShell::llm
+71 -60
View File
@@ -1,26 +1,27 @@
#include "generation.hpp" #include "generation.hpp"
#include "llmclient.hpp"
#include "messagemodel.hpp"
#include "session.hpp"
#include <QDateTime> #include <QDateTime>
namespace ZShell::llm { namespace ZShell::llm {
ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent) ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent)
: QObject(parent), m_timestamp(timestamp) { : QObject(parent), m_timestamp(timestamp) {
m_timer.setParent(this); m_timer.setParent(this);
m_timer.setInterval(500); m_timer.setInterval(500);
m_timer.setTimerType(Qt::CoarseTimer); m_timer.setTimerType(Qt::CoarseTimer);
connect(&m_timer, &QTimer::timeout, this, [this]() { connect(&m_timer, &QTimer::timeout, this, [this]() {
bool anyRunning = false; bool anyRunning = false;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (!segment->running()) if (!segment->running()) continue;
continue;
anyRunning = true; anyRunning = true;
segment->elapsedMsChanged(); segment->elapsedMsChanged();
} }
if (anyRunning) if (anyRunning) Q_EMIT elapsedMsChanged();
Q_EMIT elapsedMsChanged(); if (!anyRunning && !m_streaming) m_timer.stop();
if (!anyRunning && !m_streaming)
m_timer.stop();
}); });
} }
@@ -47,11 +48,9 @@ QString ChatGeneration::reasoning() const {
} }
bool ChatGeneration::reasoningActive() const { bool ChatGeneration::reasoningActive() const {
if (!m_streaming) if (!m_streaming) return false;
return false; if (!content().isEmpty()) return false;
if (!content().isEmpty()) return !hasRunningTool() && !hasPendingTool();
return false;
return !hasRunningTool();
} }
qint64 ChatGeneration::reasoningElapsedMs() const { qint64 ChatGeneration::reasoningElapsedMs() const {
@@ -81,44 +80,74 @@ qint64 ChatGeneration::toolsElapsedMs() const {
int ChatGeneration::toolCallCount() const { int ChatGeneration::toolCallCount() const {
int count = 0; int count = 0;
for (const auto* segment : m_segments) for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall) if (segment->type() == LlmSegment::Type::ToolCall) ++count;
++count;
return count; return count;
} }
bool ChatGeneration::hasRunningTool() const { bool ChatGeneration::hasRunningTool() const {
for (const auto* segment : m_segments) for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall && if (segment->type() == LlmSegment::Type::ToolCall && segment->running())
segment->running())
return true; return true;
return false; return false;
} }
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() { void ChatGeneration::updateReasoningActive() {
const bool active = reasoningActive(); const bool active = reasoningActive();
if (m_reasoningActive == active) if (m_reasoningActive == active) return;
return;
m_reasoningActive = active; m_reasoningActive = active;
Q_EMIT reasoningActiveChanged(); Q_EMIT reasoningActiveChanged();
} }
void ChatGeneration::setContent(const QString& value) { void ChatGeneration::setContent(const QString& value) {
// Replaces the entire answer: the first content segment takes the
// new text, any later content bursts are cleared.
LlmSegment* first = nullptr; LlmSegment* first = nullptr;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content) if (segment->type() != LlmSegment::Type::Content) continue;
continue;
if (!first) if (!first)
first = segment; first = segment;
else else
segment->setText(QString()); segment->setText(QString());
} }
if (!first) { if (!first) {
// Do not materialize an empty content segment (e.g. the assistant if (value.isEmpty()) return;
// placeholder created before the stream starts).
if (value.isEmpty())
return;
first = new LlmSegment( first = new LlmSegment(
LlmSegment::Type::Content, LlmSegment::Type::Content,
QDateTime::currentMSecsSinceEpoch(), QDateTime::currentMSecsSinceEpoch(),
@@ -129,8 +158,7 @@ void ChatGeneration::setContent(const QString& value) {
} }
void ChatGeneration::appendContent(const QString& piece) { void ChatGeneration::appendContent(const QString& piece) {
if (piece.isEmpty()) if (piece.isEmpty()) return;
return;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (segment->type() == LlmSegment::Type::Reasoning && if (segment->type() == LlmSegment::Type::Reasoning &&
segment->running()) segment->running())
@@ -140,24 +168,20 @@ void ChatGeneration::appendContent(const QString& piece) {
} }
void ChatGeneration::appendReasoning(const QString& piece) { void ChatGeneration::appendReasoning(const QString& piece) {
if (piece.isEmpty()) if (piece.isEmpty()) return;
return;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (segment->type() == LlmSegment::Type::Content && if (segment->type() == LlmSegment::Type::Content && segment->running())
segment->running())
segment->close(); segment->close();
} }
openReasoningSegment()->appendText(piece); openReasoningSegment()->appendText(piece);
} }
void ChatGeneration::setStreaming(bool value) { void ChatGeneration::setStreaming(bool value) {
if (m_streaming == value) if (m_streaming == value) return;
return;
m_streaming = value; m_streaming = value;
Q_EMIT streamingChanged(); Q_EMIT streamingChanged();
if (value) { if (value) {
if (!m_timer.isActive()) if (!m_timer.isActive()) m_timer.start();
m_timer.start();
} else { } else {
closeOpenSegments(); closeOpenSegments();
m_timer.stop(); m_timer.stop();
@@ -168,13 +192,10 @@ void ChatGeneration::setStreaming(bool value) {
LlmSegment* ChatGeneration::openContentSegment() { LlmSegment* ChatGeneration::openContentSegment() {
for (auto* segment : m_segments) for (auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::Content && if (segment->type() == LlmSegment::Type::Content && segment->running())
segment->running())
return segment; return segment;
auto* segment = new LlmSegment( auto* segment = new LlmSegment(
LlmSegment::Type::Content, LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), this);
QDateTime::currentMSecsSinceEpoch(),
this);
segment->begin(); segment->begin();
addSegment(segment); addSegment(segment);
return segment; return segment;
@@ -186,9 +207,7 @@ LlmSegment* ChatGeneration::openReasoningSegment() {
segment->running()) segment->running())
return segment; return segment;
auto* segment = new LlmSegment( auto* segment = new LlmSegment(
LlmSegment::Type::Reasoning, LlmSegment::Type::Reasoning, QDateTime::currentMSecsSinceEpoch(), this);
QDateTime::currentMSecsSinceEpoch(),
this);
segment->begin(); segment->begin();
addSegment(segment); addSegment(segment);
return segment; return segment;
@@ -198,12 +217,10 @@ LlmSegment* ChatGeneration::beginToolCall(
const QString& name, const QString& toolCallId) { const QString& name, const QString& toolCallId) {
closeOpenSegments(); closeOpenSegments();
auto* segment = new LlmSegment( auto* segment = new LlmSegment(
LlmSegment::Type::ToolCall, LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this);
QDateTime::currentMSecsSinceEpoch(),
this);
segment->setName(name); segment->setName(name);
segment->setToolCallId(toolCallId); segment->setToolCallId(toolCallId);
segment->setStatus(LlmSegment::Status::Running); segment->setStatus(LlmSegment::Status::Pending);
segment->begin(); segment->begin();
addSegment(segment); addSegment(segment);
Q_EMIT toolStateChanged(); Q_EMIT toolStateChanged();
@@ -211,27 +228,22 @@ LlmSegment* ChatGeneration::beginToolCall(
} }
void ChatGeneration::addSegment(LlmSegment* segment) { void ChatGeneration::addSegment(LlmSegment* segment) {
if (!segment || m_segments.contains(segment)) if (!segment || m_segments.contains(segment)) return;
return;
segment->setParent(this); segment->setParent(this);
connect( connect(segment, &LlmSegment::textChanged, this, [this, segment]() {
segment, &LlmSegment::textChanged, this, [this, segment]() {
if (segment->type() == LlmSegment::Type::Reasoning) if (segment->type() == LlmSegment::Type::Reasoning)
Q_EMIT reasoningChanged(); Q_EMIT reasoningChanged();
else if (segment->type() == LlmSegment::Type::Content) else if (segment->type() == LlmSegment::Type::Content)
Q_EMIT contentChanged(); Q_EMIT contentChanged();
updateReasoningActive(); updateReasoningActive();
}); });
connect( connect(segment, &LlmSegment::statusChanged, this, [this]() {
segment, &LlmSegment::statusChanged, this, [this]() {
Q_EMIT toolStateChanged(); Q_EMIT toolStateChanged();
}); });
connect( connect(segment, &LlmSegment::resultChanged, this, [this]() {
segment, &LlmSegment::resultChanged, this, [this]() {
Q_EMIT toolStateChanged(); Q_EMIT toolStateChanged();
}); });
connect( connect(segment, &LlmSegment::runningChanged, this, [this]() {
segment, &LlmSegment::runningChanged, this, [this]() {
Q_EMIT elapsedMsChanged(); Q_EMIT elapsedMsChanged();
Q_EMIT toolStateChanged(); Q_EMIT toolStateChanged();
updateReasoningActive(); updateReasoningActive();
@@ -242,8 +254,7 @@ void ChatGeneration::addSegment(LlmSegment* segment) {
void ChatGeneration::closeOpenSegments() { void ChatGeneration::closeOpenSegments() {
for (auto* segment : m_segments) for (auto* segment : m_segments)
if (segment->running()) if (segment->running()) segment->close();
segment->close();
updateReasoningActive(); updateReasoningActive();
} }
+31 -22
View File
@@ -6,43 +6,48 @@
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QTimer> #include <QTimer>
#include <QVariantList>
#include <QtQml> #include <QtQml>
namespace ZShell::llm { namespace ZShell::llm {
// One attempt at answering a message: a chronologically ordered list class LlmClient;
// of segments. Content bursts, reasoning bursts and tool calls all
// appear in the order the model produced them; a new content (or
// reasoning) segment starts whenever the model switches between them.
// Together with the attempt's aggregate state.
class ChatGeneration : public QObject { class ChatGeneration : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
QML_UNCREATABLE("Chat generations are managed by ChatMessage") QML_UNCREATABLE("Chat generations are managed by ChatMessage")
Q_PROPERTY(QString content READ content WRITE setContent NOTIFY contentChanged) Q_PROPERTY(
QString content READ content WRITE setContent NOTIFY contentChanged)
Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged) Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged)
Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged) Q_PROPERTY(
Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged) qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY
elapsedMsChanged)
Q_PROPERTY(
qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged) Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged)
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged) Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged)
Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged) Q_PROPERTY(
bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged)
Q_PROPERTY(bool hasRunningTool READ hasRunningTool NOTIFY toolStateChanged) Q_PROPERTY(bool hasRunningTool READ hasRunningTool NOTIFY toolStateChanged)
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT) Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
Q_PROPERTY( Q_PROPERTY(
QList<ZShell::llm::LlmSegment*> segments READ segments QList<ZShell::llm::LlmSegment*> segments READ segments NOTIFY
NOTIFY segmentsChanged) segmentsChanged)
Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged) Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged)
Q_PROPERTY(
bool toolApprovalPending READ toolApprovalPending NOTIFY
toolApprovalPendingChanged)
Q_PROPERTY(
QVariantList pendingToolCalls READ pendingToolCalls NOTIFY
toolApprovalPendingChanged)
public: public:
explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr);
// All content bursts, joined (the model's full answer).
[[nodiscard]] QString content() const; [[nodiscard]] QString content() const;
// Every reasoning burst, joined.
[[nodiscard]] QString reasoning() const; [[nodiscard]] QString reasoning() const;
// True while the model is thinking: streaming, no content yet, and
// no tool call in flight.
[[nodiscard]] bool reasoningActive() const; [[nodiscard]] bool reasoningActive() const;
[[nodiscard]] qint64 reasoningElapsedMs() const; [[nodiscard]] qint64 reasoningElapsedMs() const;
[[nodiscard]] qint64 contentElapsedMs() const; [[nodiscard]] qint64 contentElapsedMs() const;
@@ -52,24 +57,25 @@ class ChatGeneration : public QObject {
[[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; } [[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; }
[[nodiscard]] int toolCallCount() const; [[nodiscard]] int toolCallCount() const;
[[nodiscard]] bool hasRunningTool() 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 setContent(const QString& value);
void appendContent(const QString& piece); void appendContent(const QString& piece);
void appendReasoning(const QString& piece); void appendReasoning(const QString& piece);
void setStreaming(bool value); void setStreaming(bool value);
// The in-flight content segment, or a fresh one. A new content
// segment starts whenever the model resumes writing after reasoning
// or a tool call.
[[nodiscard]] LlmSegment* openContentSegment(); [[nodiscard]] LlmSegment* openContentSegment();
// The in-flight reasoning segment, or a fresh one.
[[nodiscard]] LlmSegment* openReasoningSegment(); [[nodiscard]] LlmSegment* openReasoningSegment();
// Creates and appends a running tool-call segment.
[[nodiscard]] LlmSegment* beginToolCall( [[nodiscard]] LlmSegment* beginToolCall(
const QString& name, const QString& toolCallId); const QString& name, const QString& toolCallId);
// Appends a segment created by the persistence layer.
void addSegment(LlmSegment* segment); void addSegment(LlmSegment* segment);
// Stops the clocks of every in-flight segment.
void closeOpenSegments(); void closeOpenSegments();
Q_SIGNALS: Q_SIGNALS:
@@ -80,14 +86,17 @@ class ChatGeneration : public QObject {
void streamingChanged(); void streamingChanged();
void toolStateChanged(); void toolStateChanged();
void segmentsChanged(); void segmentsChanged();
void toolApprovalPendingChanged();
private: private:
void updateReasoningActive(); void updateReasoningActive();
LlmClient* client() const;
QTimer m_timer; QTimer m_timer;
QList<LlmSegment*> m_segments; QList<LlmSegment*> m_segments;
bool m_reasoningActive = false; bool m_reasoningActive = false;
bool m_streaming = false; bool m_streaming = false;
bool m_approvalPending = false;
qint64 m_timestamp; qint64 m_timestamp;
}; };
File diff suppressed because it is too large Load Diff
+16 -26
View File
@@ -10,6 +10,7 @@
#include <QPointer> #include <QPointer>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QTimer>
#include <functional> #include <functional>
@@ -23,9 +24,6 @@ class ChatSession;
class LlmSegment; class LlmSegment;
class LlmTool; class LlmTool;
// The only component that talks to the LLM server: owns the network
// manager, the in-flight streaming state, the SSE parsing and the
// tool-calling loop.
class LlmClient : public QObject { class LlmClient : public QObject {
Q_OBJECT Q_OBJECT
@@ -45,7 +43,8 @@ class LlmClient : public QObject {
void setModel(const QString& value); void setModel(const QString& value);
void setTemperature(double value); void setTemperature(double value);
void setContextSize(int size); void setContextSize(int size);
void probeContextSize(); void probeContextSize(std::function<void()> done = {});
Q_INVOKABLE void refreshFromServer();
[[nodiscard]] bool busy() const { return m_busy; } [[nodiscard]] bool busy() const { return m_busy; }
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); } [[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
@@ -53,19 +52,15 @@ class LlmClient : public QObject {
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; } [[nodiscard]] QString streamingChatId() const { return m_streamingChatId; }
[[nodiscard]] ChatSession* streamingSession() const { return m_active; } [[nodiscard]] ChatSession* streamingSession() const { return m_active; }
// Streams a new assistant reply into `target` (the active generation
// of a message of `session`). The context sent to the model is every
// message of the session that is older than the target message.
// Tool calls made by the model are executed and fed back
// transparently until the model produces its final answer.
void startGeneration(ChatSession* session, ChatGeneration* target); void startGeneration(ChatSession* session, ChatGeneration* target);
void stop(); void stop();
void endStream(); void endStream();
// Clears the session's conversation once the current stream ends.
void clearOnFinish(ChatSession* session); void clearOnFinish(ChatSession* session);
// A session is about to be destroyed; drop any state pointing at it.
void sessionRemoved(ChatSession* session); void sessionRemoved(ChatSession* session);
void approveTools();
void denyTools();
void refreshModels(); void refreshModels();
void requestTitle(ChatSession* session, const QString& userText); void requestTitle(ChatSession* session, const QString& userText);
void requestIcon(ChatSession* session, const QString& userText); void requestIcon(ChatSession* session, const QString& userText);
@@ -91,23 +86,15 @@ class LlmClient : public QObject {
bool seen = false; bool seen = false;
}; };
// Sends one streaming round: context + transcript so far.
void sendRound(); void sendRound();
// The session context for a round, oldest first, ending just before
// `stopBeforeRow` (the message of the generation being streamed).
QJsonArray buildContextMessages( QJsonArray buildContextMessages(
ChatSession* session, int stopBeforeRow) const; ChatSession* session, int stopBeforeRow, int extraReserveTokens) const;
void applyToolCallDelta(const QJsonObject& call); void applyToolCallDelta(const QJsonObject& call);
// One round's stream ended; either ends the turn or executes the
// requested tool calls and sends the next round. Runs at most once
// per round ([DONE] and the reply's finished signal both reach it).
void roundFinished(); void roundFinished();
// Dispatches every call of the round; tools run concurrently.
void executeAllCalls(); void executeAllCalls();
// All results in: appends the tool messages (in call order) and
// sends the next round.
void flushCallResults(); void flushCallResults();
// Ends the current turn gracefully and persists the session. void setApprovalPending(bool value);
void finishPendingCalls(const QString& resultText);
void finishTurn(); void finishTurn();
void fail(const QString& message); void fail(const QString& message);
void handleLine(const QByteArray& line); void handleLine(const QByteArray& line);
@@ -120,7 +107,8 @@ class LlmClient : public QObject {
const QString& systemPrompt, const QString& systemPrompt,
const QString& userText, const QString& userText,
std::function<void(QString result)> onResult); std::function<void(QString result)> onResult);
static QString completionsPath(const QString& endpoint, const QString& subpath); static QString completionsPath(
const QString& endpoint, const QString& subpath);
static QString serverErrorMessage( static QString serverErrorMessage(
const QByteArray& body, const QString& fallback); const QByteArray& body, const QString& fallback);
@@ -128,8 +116,9 @@ class LlmClient : public QObject {
ToolRegistry* m_tools = nullptr; ToolRegistry* m_tools = nullptr;
QNetworkReply* m_reply = nullptr; QNetworkReply* m_reply = nullptr;
QByteArray m_buffer; QByteArray m_buffer;
ChatSession* m_active = nullptr; QTimer m_refreshTimer;
ChatGeneration* m_streaming = nullptr; QPointer<ChatSession> m_active;
QPointer<ChatGeneration> m_streaming;
QPointer<ChatSession> m_pendingClear; QPointer<ChatSession> m_pendingClear;
bool m_busy = false; bool m_busy = false;
QString m_streamingChatId; QString m_streamingChatId;
@@ -139,7 +128,6 @@ class LlmClient : public QObject {
double m_temperature = 0.7; double m_temperature = 0.7;
int m_contextSize = 0; int m_contextSize = 0;
// State of the multi-round tool loop of the current turn.
QJsonArray m_transcript; QJsonArray m_transcript;
QList<ToolCallBuilder> m_callBuilders; QList<ToolCallBuilder> m_callBuilders;
struct ToolCallResult { struct ToolCallResult {
@@ -153,6 +141,8 @@ class LlmClient : public QObject {
qsizetype m_reasoningMark = 0; qsizetype m_reasoningMark = 0;
bool m_roundDone = false; bool m_roundDone = false;
bool m_toolPhase = false; bool m_toolPhase = false;
bool m_approvalPending = false;
bool m_contextRetryUsed = false;
int m_pendingCalls = 0; int m_pendingCalls = 0;
static constexpr int kMaxToolRounds = 12; static constexpr int kMaxToolRounds = 12;
}; };
-3
View File
@@ -5,9 +5,6 @@
namespace ZShell::llm { namespace ZShell::llm {
// Marker class exposing the LlmMarkdown block type enum to QML
// (LlmMarkdown.Type.*). Blocks themselves are value maps produced by
// MarkdownParser and stored on LlmSegment.
class LlmMarkdown : public QObject { class LlmMarkdown : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT

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