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 78988700be async parsing + tex caching 2026-08-26 02:06:40 +02:00
zach 3946541f87 async parsing + tex caching 2026-08-26 02:06:10 +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
141 changed files with 9713 additions and 3436 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 {}
}
} }
} }
+62 -78
View File
@@ -1,32 +1,37 @@
pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import QtQuick.Templates import QtQuick.Templates
import qs.Helpers import qs.Helpers
import ZShell.Config import ZShell.Config
import qs.Services
ScrollBar { ScrollBar {
id: root id: root
required property Flickable flickable
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
readonly property real axisContentPos: isHorizontal ? (reversed ? flickable.contentX : (flickable.contentX - flickable.originX)) : (reversed ? flickable.contentY : (flickable.contentY - flickable.originY))
readonly property real axisLength: isHorizontal ? root.width : root.height
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize) readonly property real 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)
required property Flickable flickable readonly property real nonAnimHeight: root.axisSize / root.axisContentSize
readonly property real nonAnimHeight: flickable.height / flickable.contentHeight readonly property real nonAnimY: root.axisContentPos / root.axisContentSize
readonly property real nonAnimY: flickable.contentY / flickable.contentHeight
readonly property real rawTravel: Math.max(0, 1 - root.nonAnimHeight) readonly property real rawTravel: Math.max(0, 1 - root.nonAnimHeight)
readonly property bool reversed: flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop readonly property bool reversed: isHorizontal ? (flickable instanceof ListView && flickable.layoutDirection === Qt.RightToLeft) : (flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop)
property bool shouldBeActive property bool shouldBeActive
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
implicitWidth: Tokens.padding.extraSmall * 2 implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2
implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0
contentItem: Item { contentItem: Item {}
}
Behavior on position { Behavior on position {
enabled: !fullMouse.pressed enabled: !fullMouse.pressed
Anim { Anim {}
}
} }
onHoveredChanged: { onHoveredChanged: {
@@ -47,54 +52,27 @@ ScrollBar {
target: root.flickable target: root.flickable
} }
CustomClippingRect { Loader {
anchors.bottom: parent.bottom anchors.fill: parent
anchors.right: parent.right active: root.size < 1
anchors.top: parent.top sourceComponent: root.isHorizontal ? horizontalTrack : verticalTrack
implicitWidth: handle.implicitWidth }
radius: Tokens.rounding.full
CustomRect { Component {
id: handle id: verticalTrack
anchors.right: parent.right VerticalScrollBarTrack {
color: Colors.palette.m3secondary scrollBar: root
implicitHeight: root.height * root.effectiveSize mouseArea: fullMouse
implicitWidth: fullMouse.pressed || fullMouse.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall }
opacity: { }
if (!root.enabled)
return 0;
if (root.size === 1)
return 0;
if (fullMouse.pressed)
return 1;
if (fullMouse.containsMouse)
return 0.8;
if (root.policy === ScrollBar.AlwaysOn || root.shouldBeActive)
return 0.6;
return 0;
}
radius: Tokens.rounding.full
y: root.reversed ? root.height * (1 + root.nonAnimY) * root.travelScale : root.height * root.nonAnimY * root.travelScale
Behavior on implicitWidth { Component {
Anim { id: horizontalTrack
}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
MouseArea { HorizontalScrollBarTrack {
id: mouse scrollBar: root
mouseArea: fullMouse
acceptedButtons: Qt.NoButton
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
}
} }
} }
@@ -111,25 +89,40 @@ ScrollBar {
property real pressOffset: 0 property real pressOffset: 0
function contentYFromThumbTop(thumbTop) { function contentPosFromThumbStart(thumbStart) {
var visualPos = root.effectiveTravel > 0 ? thumbTop / root.travelScale : 0; var visualPos = root.effectiveTravel > 0 ? thumbStart / root.travelScale : 0;
return root.reversed ? (visualPos - 1) * root.axisContentSize : visualPos * root.axisContentSize;
return root.reversed ? (visualPos - 1) * root.flickable.contentHeight : visualPos * root.flickable.contentHeight;
} }
function updateFromEvent(event) { function updateFromEvent(event) {
var posInTrack = event.y / root.height; var eventPos = root.isHorizontal ? event.x : event.y;
var thumbTop = posInTrack - pressOffset; var posInTrack = eventPos / root.axisLength;
thumbTop = Math.max(0, Math.min(root.effectiveTravel, thumbTop)); var thumbStart = posInTrack - pressOffset;
thumbStart = Math.max(0, Math.min(root.effectiveTravel, thumbStart));
root.flickable.contentY = contentYFromThumbTop(thumbTop); var newPos = contentPosFromThumbStart(thumbStart);
if (root.isHorizontal)
root.flickable.contentX = newPos + (root.flickable.originX + (root.reversed ? root.flickable.contentWidth : 0));
else
root.flickable.contentY = newPos + (root.flickable.originY + (root.reversed ? root.flickable.contentHeight : 0));
} }
function visualThumbTop() { function visualThumbStart() {
const visualPos = root.reversed ? (1 + root.nonAnimY) : root.nonAnimY; const visualPos = root.reversed ? (1 + root.nonAnimY) : root.nonAnimY;
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
@@ -140,24 +133,15 @@ ScrollBar {
updateFromEvent(event); updateFromEvent(event);
} }
onPressed: event => { onPressed: event => {
var currentTop = visualThumbTop(); var currentStart = visualThumbStart();
var currentBottom = currentTop + root.effectiveSize; var currentEnd = currentStart + root.effectiveSize;
var clickPos = event.y / root.height; var eventPos = root.isHorizontal ? event.x : event.y;
var clickPos = eventPos / root.axisLength;
var clickedInsideThumb = clickPos >= currentTop && clickPos <= currentBottom; var clickedInsideThumb = clickPos >= currentStart && clickPos <= currentEnd;
pressOffset = clickedInsideThumb ? (clickPos - currentStart) : root.effectiveSize / 2;
if (clickedInsideThumb) {
pressOffset = clickPos - currentTop;
} else {
pressOffset = root.effectiveSize / 2;
}
updateFromEvent(event); updateFromEvent(event);
} }
onWheel: event => {
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;
}
} }
} }
+56
View File
@@ -0,0 +1,56 @@
import QtQuick
import ZShell.Config
import qs.Services
CustomClippingRect {
id: track
required property var scrollBar
required property var mouseArea
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
implicitHeight: handle.implicitHeight
radius: Tokens.rounding.full
CustomRect {
id: handle
anchors.bottom: parent.bottom
color: Colors.palette.m3secondary
implicitWidth: track.scrollBar.width * track.scrollBar.effectiveSize
implicitHeight: track.mouseArea.pressed || track.mouseArea.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall
opacity: {
if (!track.scrollBar.enabled)
return 0;
if (track.scrollBar.size === 1)
return 0;
if (track.mouseArea.pressed)
return 1;
if (track.mouseArea.containsMouse)
return 0.8;
if (track.scrollBar.policy === CustomScrollBar.AlwaysOn || track.scrollBar.shouldBeActive)
return 0.6;
return 0;
}
radius: Tokens.rounding.full
x: track.scrollBar.reversed ? track.scrollBar.width * (1 + track.scrollBar.nonAnimY) * track.scrollBar.travelScale : track.scrollBar.width * track.scrollBar.nonAnimY * track.scrollBar.travelScale
Behavior on implicitHeight {
Anim {}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
MouseArea {
acceptedButtons: Qt.NoButton
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
}
}
}
+86
View File
@@ -0,0 +1,86 @@
import QtQuick
import QtQuick.Templates
import ZShell.Config
import qs.Services
TextArea {
id: root
color: Colors.palette.m3onSurface
cursorVisible: !readOnly
font.pointSize: Tokens.font.size.small
implicitHeight: contentHeight + topPadding + bottomPadding
implicitWidth: contentWidth + leftPadding + rightPadding
placeholderTextColor: Colors.palette.m3onSurfaceVariant // No anim cause placeholder is custom
renderType: TextArea.NativeRendering
selectedTextColor: color
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
verticalAlignment: TextInput.AlignVCenter
Behavior on color {
CAnim {}
}
Behavior on selectionColor {
CAnim {}
}
cursorDelegate: Item {}
CustomRect {
id: cursor
property bool disableBlink
color: Colors.palette.m3primary
implicitHeight: root.cursorRectangle.height
implicitWidth: 1.5
radius: Tokens.rounding.largeIncreased
x: root.cursorRectangle.x
y: root.cursorRectangle.y
Behavior on x {
Anim {
duration: Tokens.anim.durations.expressiveFastEffects
easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1]
}
}
Behavior on opacity {
Anim {
type: Anim.StandardSmall
}
}
Connections {
function onCursorPositionChanged(): void {
if (root.activeFocus && root.cursorVisible) {
cursor.opacity = 1;
cursor.disableBlink = true;
enableBlink.restart();
}
}
target: root
}
Timer {
id: enableBlink
interval: 500
onTriggered: cursor.disableBlink = false
}
Timer {
interval: 500
repeat: true
running: root.activeFocus && root.cursorVisible && !cursor.disableBlink
triggeredOnStart: true
onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1
}
Binding {
cursor.opacity: 0
when: !root.activeFocus || !root.cursorVisible
}
}
}
+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)
+56
View File
@@ -0,0 +1,56 @@
import QtQuick
import ZShell.Config
import qs.Services
CustomClippingRect {
id: track
required property var scrollBar
required property var mouseArea
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.top: parent.top
implicitWidth: handle.implicitWidth
radius: Tokens.rounding.full
CustomRect {
id: handle
anchors.right: parent.right
color: Colors.palette.m3secondary
implicitHeight: track.scrollBar.height * track.scrollBar.effectiveSize
implicitWidth: track.mouseArea.pressed || track.mouseArea.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall
opacity: {
if (!track.scrollBar.enabled)
return 0;
if (track.scrollBar.size === 1)
return 0;
if (track.mouseArea.pressed)
return 1;
if (track.mouseArea.containsMouse)
return 0.8;
if (track.scrollBar.policy === CustomScrollBar.AlwaysOn || track.scrollBar.shouldBeActive)
return 0.6;
return 0;
}
radius: Tokens.rounding.full
y: track.scrollBar.reversed ? track.scrollBar.height * (1 + track.scrollBar.nonAnimY) * track.scrollBar.travelScale : track.scrollBar.height * track.scrollBar.nonAnimY * track.scrollBar.travelScale
Behavior on implicitWidth {
Anim {}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
MouseArea {
acceptedButtons: Qt.NoButton
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
hoverEnabled: true
}
}
}
+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 {
+9 -13
View File
@@ -44,7 +44,7 @@ WidgetBase {
} }
required property GridLayout loader required property GridLayout loader
required property Wrapper popouts required property Wrapper popouts
readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.small * 2 : verticalColumn.implicitHeight + Tokens.padding.small * 2 readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2
required property PersistentProperties visibilities required property PersistentProperties visibilities
color: visibilities.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer color: visibilities.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer
@@ -57,14 +57,13 @@ WidgetBase {
anchors.centerIn: parent anchors.centerIn: parent
color: root.contentColor color: root.contentColor
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
height: implicitHeight height: implicitHeight
text: Time.dateStr text: Time.dateStr
visible: root.horizontal visible: root.horizontal
Behavior on color { Behavior on color {
CAnim { CAnim {}
}
} }
} }
@@ -84,12 +83,11 @@ WidgetBase {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
color: root.contentColor color: root.contentColor
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
text: Qt.formatDateTime(Time.date, modelData) text: Qt.formatDateTime(Time.date, modelData)
Behavior on color { Behavior on color {
CAnim { CAnim {}
}
} }
} }
} }
@@ -114,7 +112,7 @@ WidgetBase {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
color: root.contentColor color: root.contentColor
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
text: { text: {
if (modelData.includes("h")) if (modelData.includes("h"))
return Time.hourStr; return Time.hourStr;
@@ -126,8 +124,7 @@ WidgetBase {
} }
Behavior on color { Behavior on color {
CAnim { CAnim {}
}
} }
} }
} }
@@ -135,13 +132,12 @@ WidgetBase {
CustomText { CustomText {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
color: root.contentColor color: root.contentColor
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
text: Qt.formatDateTime(Time.date, "AP") text: Qt.formatDateTime(Time.date, "AP")
visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0 visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0
Behavior on color { Behavior on color {
CAnim { CAnim {}
}
} }
} }
} }
@@ -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
+5 -4
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
@@ -30,12 +31,12 @@ Scope {
Quickshell.execDetached(action); Quickshell.execDetached(action);
} }
LidWatcher { LidWatcher {
onAboutToSleep: root.lock.lock.locked = true onAboutToSleep: root.lock.lock.locked = true
} }
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,58 +149,106 @@ 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
clip: true
fadeAmount: 0.05
fadeThreshold: Tokens.padding.medium
model: root.chatData.messagesModel
spacing: 0
verticalLayoutDirection: VerticalFadeListView.BottomToTop
add: Transition { sourceComponent: VerticalFadeListView {
Anim { id: list
from: list.width
property: "x" property bool userScrolledUp: false
to: 0 property real lastCHeight: 0.0
function scrollToBottom(): void {
scrollAnim.start();
} }
}
delegate: MessageDelegate {} cacheBuffer: Math.max(height * 20, 0)
displaced: Transition { clip: true
Anim { fadeAmount: 0.05
property: "y" fadeThreshold: Tokens.padding.medium
model: root.chatData.messagesModel
spacing: 0
rotation: 180
add: Transition {
Anim {
from: -100
property: "y"
}
} }
}
move: Transition { 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 {
Anim {
property: "y"
}
}
move: Transition {
Anim {
property: "y"
}
}
Component.onCompleted: {
positionViewAtBeginning();
}
onAtYBeginningChanged: {
if (atYBeginning)
userScrolledUp = false;
}
onContentHeightChanged: {
if (userScrolledUp && Chat.busy) {
const delta = contentHeight - lastCHeight;
contentY += delta;
}
lastCHeight = contentHeight;
}
onMovingChanged: {
if (moving)
userScrolledUp = !atYBeginning;
}
Anim { Anim {
property: "y" id: scrollAnim
property: "contentY"
target: list
type: Anim.DefaultEffects
to: list.originY
}
WheelInverter {
target: list
} }
} }
Component.onCompleted: positionViewAtBeginning() // Trigger the load even before we're ready to show the list.
onAtYEndChanged: { Component.onCompleted: if (root.chatData)
if (atYEnd) root.chatData.messagesModel
userScrolledUp = false;
}
onContentHeightChanged: {
if (!userScrolledUp && atYEnd)
root.scrollToBottom();
}
onCountChanged: {
if (!userScrolledUp)
root.scrollToBottom();
}
onMovingChanged: {
if (moving)
userScrolledUp = !atYEnd;
}
Anim {
id: scrollAnim
property: "contentY"
target: list
type: Anim.DefaultEffects
}
} }
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,12 +304,21 @@ 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
onAccepted: root.send(text) Component.onCompleted: root.focusInput()
Keys.onPressed: e => {
if (e.key == Qt.Key_Return) {
if (!(e.modifiers & Qt.ShiftModifier)) {
root.send(text);
e.accepted = true;
}
}
}
onSendPressed: root.send(text) onSendPressed: root.send(text)
} }
} }
@@ -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,31 +114,73 @@ CustomClippingRect {
} }
} }
TextEditBase { Item {
id: titleText id: titleWrapper
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: readOnly ? 0 : Tokens.padding.large
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2 anchors.right: parent.right
font.pointSize: Tokens.font.size.normal implicitHeight: titleText.implicitHeight + Tokens.padding.small * 2
text: root.modelData.title
readOnly: true
Behavior on anchors.leftMargin { layer.enabled: true
Anim {} layer.effect: OpacityMask {
} maskSource: fadeMask
Behavior on anchors.topMargin {
Anim {}
} }
onEditingFinished: root.modelData.title = text TextFieldBase {
onReadOnlyChanged: { id: titleText
if (!readOnly) {
this.forceActiveFocus(); anchors.left: parent.left
cursorPosition = text.length; anchors.leftMargin: readOnly ? 0 : Tokens.padding.large
} else { anchors.top: parent.top
root.forceActiveFocus(); anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2
font.pointSize: Tokens.font.size.normal
text: root.modelData.title || qsTr("New chat")
readOnly: true
color: Colors.palette.m3onSurface
Behavior on anchors.leftMargin {
Anim {}
}
Behavior on anchors.topMargin {
Anim {}
}
onEditingFinished: root.modelData.title = text
onReadOnlyChanged: {
if (!readOnly) {
this.forceActiveFocus();
cursorPosition = text.length;
} else {
root.forceActiveFocus();
}
}
}
}
CustomRect {
id: fadeMask
// anchors.top: titleText.top
// anchors.bottom: titleText.bottom
// anchors.left: titleText.left
// anchors.right: parent.right
anchors.fill: titleWrapper
layer.enabled: true
visible: false
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
color: Qt.rgba(1, 1, 1, 1.0)
position: 0.85
}
GradientStop {
color: Qt.rgba(1, 1, 1, 0)
position: 1.0
} }
} }
} }
@@ -151,7 +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 {
@@ -3,7 +3,7 @@ import ZShell.Config
import qs.Components import qs.Components
import qs.Services import qs.Services
TextFieldBase { TextAreaBase {
id: root id: root
readonly property alias bg: bg readonly property alias bg: bg
@@ -15,7 +15,7 @@ TextFieldBase {
leftPadding: Tokens.padding.extraLarge leftPadding: Tokens.padding.extraLarge
rightPadding: sendIcon.width + sendIcon.anchors.rightMargin + Tokens.spacing.small rightPadding: sendIcon.width + sendIcon.anchors.rightMargin + Tokens.spacing.small
topPadding: Tokens.padding.large topPadding: Tokens.padding.large
wrapMode: TextFieldBase.Wrap wrapMode: TextAreaBase.Wrap
background: CustomRect { background: CustomRect {
id: bg id: bg
@@ -31,7 +31,7 @@ TextFieldBase {
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 id: stack
active: !ChatState.isWindow || root.width <= (ChatState.screen.width / 4)
anchors.fill: parent anchors.fill: parent
sourceComponent: StackView {
id: stack
}
} }
Loader {
id: sidebarViewLoader
active: ChatState.isWindow && root.width > (ChatState.screen.width / 4)
anchors.fill: parent
sourceComponent: SidebarView {}
}
// IconButton {
// anchors.right: parent.right
// icon: "close"
// visible: !ChatState.isWindow
//
// onClicked: {
// Detach.create();
// Visibilities.getForActive().sidebar = false;
// }
// }
Component { Component {
id: chatList id: chatList
@@ -68,8 +42,8 @@ Item {
onDeleteChatRequest: chat => { onDeleteChatRequest: chat => {
Chat.chats.remove(chat); Chat.chats.remove(chat);
} }
onLoadChatRequest: chat => { onLoadChatRequest: (chat, index) => {
stackLoader.item?.push(chatContent, { stack.push(chatContent, {
"chatData": chat "chatData": chat
}); });
ChatState.inChat = true; ChatState.inChat = true;
@@ -77,7 +51,7 @@ Item {
} }
onNewChatRequest: { onNewChatRequest: {
const data = Chat.chats.insert(); const data = Chat.chats.insert();
stackLoader.item?.push(chatContent, { stack.push(chatContent, {
"chatData": data "chatData": data
}); });
ChatState.inChat = true; ChatState.inChat = true;
@@ -91,7 +65,7 @@ Item {
ChatContent { ChatContent {
onRequestClose: { onRequestClose: {
stackLoader.item?.pop(); stack.pop();
ChatState.inChat = false; ChatState.inChat = false;
} }
} }
@@ -3,12 +3,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,6 @@
import QtQuick import QtQuick
import QtQuick.Effects
import QtQuick.Shapes
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell import Quickshell
import ZShell.Config import ZShell.Config
@@ -6,63 +8,70 @@ 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
required property string code required property string code
property bool copied: false property bool copied: false
property color codeColor: Colors.palette.m3onSurfaceVariant property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh
property color codeBackgroundColor: Colors.palette.m3surfaceContainerLow
property color codeHeaderColor: Colors.palette.m3outline property color codeHeaderColor: Colors.palette.m3outline
property color codeAccentColor: Colors.palette.m3primary
// Highlighter spans for the current code; refreshed when the code or
// its language changes.
property var codeSpans: [] property var codeSpans: []
property int highlightToken: 0
function refresh() { function refresh() {
codeSpans = CodeHighlighter.highlight(root.code, root.language); const token = ++root.highlightToken;
CodeHighlighter.highlight(root.code, root.language, root, token);
}
function onHighlightSpans(token, spans) {
if (token !== root.highlightToken)
return;
codeSpans = spans;
} }
function roleColor(kind) { function roleColor(kind) {
var s = CodeColors.active;
switch (kind) { switch (kind) {
case "comment": case "comment":
return Colors.palette.m3outline; return s.comment;
case "string": case "string":
return Colors.palette.m3tertiary; return s.string;
case "string.key": case "string.key":
return Colors.palette.m3secondary; return s.stringKey;
case "number": case "number":
case "constant": case "constant":
return Colors.palette.m3tertiaryFixed; return s.number;
case "keyword": case "keyword":
return root.codeAccentColor; return s.keyword;
case "type": case "type":
return Colors.palette.m3secondary; return s.type;
case "function": case "function":
return s.functions;
case "method": case "method":
return Colors.palette.m3onSurface; return s.method ?? s.functions;
case "macro": case "macro":
return s.macro;
case "preproc": case "preproc":
return Colors.palette.m3secondaryContainer; return s.preproc ?? s.macro;
case "operator": case "operator":
return s.operator ?? s.normal;
case "property": case "property":
return root.codeColor; return s.property ?? s.normal;
case "label": case "label":
return s.label;
case "attribute": case "attribute":
return Colors.palette.m3tertiaryFixedDim; return s.attribute ?? s.label;
default: default:
return root.codeColor; return s.normal;
} }
} }
function escapeHtml(text) { function escapeHtml(text): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
} }
// Wraps the code in <font color> tags following the highlighter spans. function highlightedHtml(code, spans): string {
function highlightedHtml(code, spans) {
let out; let out;
if (!spans.length) { if (!spans.length) {
out = escapeHtml(code); out = escapeHtml(code);
@@ -71,44 +80,31 @@ 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));
} }
// RichText collapses HTML whitespace: break newlines, and protect return out.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, "&nbsp;")).replace(/\n/g, "<br>");
// line-leading indentation with nbsp (internal spaces stay normal
// so long lines can still wrap).
return out
.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, "&nbsp;"))
.replace(/\n/g, "<br>");
} }
implicitWidth: headerRow.implicitWidth + headerRow.anchors.leftMargin + headerRow.anchors.rightMargin implicitWidth: headerRow.implicitWidth + headerRow.anchors.leftMargin + headerRow.anchors.rightMargin
implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeText.implicitHeight + codeText.anchors.margins * 2 implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeRect.implicitHeight + codeRect.anchors.margins + codeRect.anchors.topMargin
color: root.codeBackgroundColor color: root.codeBackgroundColor
radius: Tokens.rounding.medium radius: Tokens.rounding.medium
onLanguageChanged: refresh() onLanguageChanged: {
onCodeChanged: refresh() codeSpans = [];
refresh();
CustomText {
id: codeText
anchors.left: parent.left
anchors.top: headerRow.bottom
anchors.right: parent.right
anchors.margins: Tokens.padding.small
color: root.codeColor
font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small
textFormat: Text.RichText
text: root.highlightedHtml(root.code, root.codeSpans)
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
} }
onCodeChanged: refresh()
RowLayout { RowLayout {
id: headerRow id: headerRow
@@ -121,11 +117,74 @@ CustomClippingRect {
anchors.rightMargin: Tokens.padding.small anchors.rightMargin: Tokens.padding.small
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
Item {
id: iconItem
readonly property string iconPath: CodeIcons.path(root.language)
Layout.fillHeight: true
implicitWidth: height
Shape {
id: shape
anchors.centerIn: parent
visible: iconItem.iconPath !== ""
preferredRendererType: Shape.CurveRenderer
scale: Math.min(langText.implicitHeight / height, langText.implicitHeight / width)
ShapePath {
strokeColor: "transparent"
fillColor: Colors.palette.m3tertiary
fillRule: ShapePath.WindingFill
PathSvg {
path: iconItem.iconPath
}
}
}
Shape {
id: fallbackShape
anchors.centerIn: parent
anchors.verticalCenterOffset: -1
visible: iconItem.iconPath === ""
preferredRendererType: Shape.CurveRenderer
scale: Math.min(langText.implicitHeight / height, langText.implicitHeight / width)
ShapePath {
strokeColor: Colors.palette.m3tertiary
strokeWidth: 16
capStyle: ShapePath.RoundCap
joinStyle: ShapePath.RoundJoin
fillColor: "transparent"
PathSvg {
path: "M 40 64 L 112 128 L 40 192"
}
}
ShapePath {
strokeColor: Colors.palette.m3tertiary
strokeWidth: 16
capStyle: ShapePath.RoundCap
fillColor: "transparent"
PathSvg {
path: "M 120 192 L 216 192"
}
}
}
}
CustomText { CustomText {
id: langText
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
color: root.codeHeaderColor color: root.codeHeaderColor
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
text: root.language text: CodeIcons.name(root.language)
visible: root.language.length > 0 visible: root.language.length > 0
} }
@@ -137,6 +196,7 @@ CustomClippingRect {
icon: root.copied ? "check" : "content_copy" icon: root.copied ? "check" : "content_copy"
inactiveColor: "transparent" inactiveColor: "transparent"
inactiveOnColor: root.codeHeaderColor inactiveOnColor: root.codeHeaderColor
label.animate: true
type: IconButton.Text type: IconButton.Text
onClicked: { onClicked: {
@@ -154,4 +214,63 @@ CustomClippingRect {
} }
} }
} }
CustomClippingRect {
id: codeRect
anchors.left: parent.left
anchors.top: headerRow.bottom
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.topMargin: Tokens.padding.small
radius: root.radius - anchors.margins
anchors.margins: Tokens.padding.extraSmall
implicitWidth: codeText.implicitWidth + codeFlick.anchors.margins * 2
implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2
color: CodeColors.active.bg
CustomText {
id: code
anchors.top: parent.top
anchors.bottom: parent.bottom
x: implicitWidth * (0 - codeFlick.visibleArea.xPosition) + Tokens.padding.small
anchors.margins: Tokens.padding.small
text: root.highlightedHtml(root.code, root.codeSpans)
textFormat: Text.RichText
color: CodeColors.active.normal
layer.enabled: true
clip: false
font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small
}
Flickable {
id: codeFlick
anchors.fill: parent
anchors.margins: Tokens.padding.small
CustomScrollBar.horizontal: CustomScrollBar {
flickable: codeFlick
parent: codeFlick.parent
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
}
TextAreaBase.flickable: TextAreaBase {
id: codeText
color: CodeColors.active.normal
layer.enabled: true
leftInset: Tokens.padding.small
clip: false
font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small
textFormat: Text.RichText
text: code.text
readOnly: true
}
}
}
} }
@@ -0,0 +1,231 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
import ZShell.Config
Singleton {
id: root
readonly property JsonObject schemes: JsonObject {
readonly property Scheme oneDark: Scheme {
comment: "#5c6370"
string: "#98c379"
stringKey: "#e06c75"
number: "#d19a66"
keyword: "#c678dd"
type: "#e5c07b"
functions: "#61afef"
method: "#61afef"
macro: "#98c379"
preproc: "#abb2bf"
operator: "#abb2bf"
property: "#abb2bf"
label: "#e06c75"
attribute: "#d19a66"
bg: "#282c34"
normal: "#abb2bf"
}
readonly property Scheme nord: Scheme {
comment: "#4c566a"
string: "#a3be8c"
stringKey: "#ebcb8b"
number: "#b48ead"
keyword: "#81a1c1"
type: "#8fbcbb"
functions: "#88c0d0"
method: "#88c0d0"
macro: "#5e81ac"
preproc: "#5e81ac"
operator: "#81a1c1"
property: "#d8dee9"
label: "#d08770"
attribute: "#d8dee9"
bg: "#2e3440"
normal: "#d8dee9"
}
readonly property Scheme dracula: Scheme {
comment: "#6272a4"
string: "#f1fa8c"
stringKey: "#f8f8f2"
number: "#ffb86c"
keyword: "#ff79c6"
type: "#8be9fd"
functions: "#50fa7b"
method: "#50fa7b"
macro: "#ff79c6"
preproc: "#ff79c6"
operator: "#f8f8f2"
property: "#f8f8f2"
label: "#6272a4"
attribute: "#8be9fd"
bg: "#282a36"
normal: "#f8f8f2"
}
readonly property Scheme githubDark: Scheme {
comment: "#8b949e"
string: "#a5d6ff"
stringKey: "#79c0ff"
number: "#79c0ff"
keyword: "#ff7b72"
type: "#ffa657"
functions: "#d2a8ff"
method: "#d2a8ff"
macro: "#ff7b72"
preproc: "#79c0ff"
operator: "#ff7b72"
property: "#79c0ff"
label: "#7ee787"
attribute: "#7ee787"
bg: "#0d1117"
normal: "#c9d1d9"
}
readonly property Scheme solarizedDark: Scheme {
comment: "#586e75"
string: "#2aa198"
stringKey: "#268bd2"
number: "#2aa198"
keyword: "#859900"
type: "#b58900"
functions: "#268bd2"
method: "#268bd2"
macro: "#cb4b16"
preproc: "#cb4b16"
operator: "#859900"
property: "#268bd2"
label: "#6c71c4"
attribute: "#657b83"
bg: "#002b36"
normal: "#839496"
}
readonly property Scheme monokai: Scheme {
comment: "#75715e"
string: "#e6db74"
stringKey: "#f8f8f2"
number: "#ae81ff"
keyword: "#f92672"
type: "#a6e22e"
functions: "#a6e22e"
method: "#a6e22e"
macro: "#a6e22e"
preproc: "#f92672"
operator: "#f92672"
property: "#fda5ff"
label: "#f92672"
attribute: "#a6e22e"
bg: "#272822"
normal: "#f8f8f2"
}
readonly property Scheme gruvboxDark: Scheme {
comment: "#928374"
string: "#b8bb26"
stringKey: "#ebdbb2"
number: "#d3869b"
keyword: "#fb4934"
type: "#fabd2f"
functions: "#b8bb26"
method: "#b8bb26"
macro: "#8ec07c"
preproc: "#8ec07c"
operator: "#ebdbb2"
property: "#83a598"
label: "#fb4934"
attribute: "#8ec07c"
bg: "#1d2021"
normal: "#ebdbb2"
}
readonly property Scheme catppuccinMocha: Scheme {
comment: "#9399b2"
string: "#a6e3a1"
stringKey: "#b4befe"
number: "#fab387"
keyword: "#cba6f7"
type: "#f9e2af"
functions: "#89b4fa"
method: "#89b4fa"
macro: "#cba6f7"
preproc: "#f5c2e7"
operator: "#89dceb"
property: "#b4befe"
label: "#74c7ec"
attribute: "#f9e2af"
bg: "#1e1e2e"
normal: "#cdd6f4"
}
readonly property Scheme tokyoNight: Scheme {
comment: "#565f89"
string: "#9ece6a"
stringKey: "#73daca"
number: "#ff9e64"
keyword: "#9d7cd8"
type: "#2ac3de"
functions: "#7aa2f7"
method: "#7aa2f7"
macro: "#7dcfff"
preproc: "#7dcfff"
operator: "#89ddff"
property: "#73daca"
label: "#7aa2f7"
attribute: "#73daca"
bg: "#1a1b26"
normal: "#c0caf5"
}
readonly property Scheme ayuDark: Scheme {
comment: "#5a6673"
string: "#aad94c"
stringKey: "#aad94c"
number: "#d2a6ff"
keyword: "#ff8f40"
type: "#59c2ff"
functions: "#ffb454"
method: "#ffb454"
macro: "#59c2ff"
preproc: "#ff8f40"
operator: "#f29668"
property: "#f07178"
label: "#59c2ff"
attribute: "#ffb454"
bg: "#0a0e14"
normal: "#bfbdb6"
}
readonly property Scheme palenight: Scheme {
comment: "#697098"
string: "#c3e88d"
stringKey: "#82b1ff"
number: "#f78c6c"
keyword: "#ff5370"
type: "#ffcb6b"
functions: "#82b1ff"
method: "#82b1ff"
macro: "#c792ea"
preproc: "#ffcb6b"
operator: "#89ddff"
property: "#c3e88d"
label: "#c792ea"
attribute: "#ffcb6b"
bg: "#292d3e"
normal: "#bfc7d5"
}
}
readonly property var active: schemes[Config.llm.appearance.scheme]
component Scheme: JsonObject {
property color comment: "#697098"
property color string: "#c3e88d"
property color stringKey: "#82b1ff"
property color number: "#f78c6c"
property color keyword: "#ff5370"
property color type: "#ffcb6b"
property color functions: "#82b1ff"
property color method: "#82b1ff"
property color macro: "#c792ea"
property color preproc: "#ffcb6b"
property color operator: "#89ddff"
property color property: "#c3e88d"
property color label: "#c792ea"
property color attribute: "#ffcb6b"
property color bg: "#292d3e"
property color normal: "#bfc7d5"
}
}
File diff suppressed because one or more lines are too long
@@ -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 + Tokens.padding.medium * 2 implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2
anchors.right: root.isUser ? parent.right : undefined 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;
@@ -92,10 +134,10 @@ Item {
id: blocks id: blocks
visible: !root.isUser visible: !root.isUser
anchors.topMargin: Tokens.padding.medium
anchors.top: parent.top anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.margins: Tokens.padding.medium
blocks: root.segment.markdown blocks: root.segment.markdown
} }
} }
@@ -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 {}
}
} }
} }
@@ -5,28 +5,28 @@ import qs.Components
import qs.Services import qs.Services
ColumnLayout { ColumnLayout {
id: root id: root
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
Item { Item {
Layout.fillHeight: true Layout.fillHeight: true
} }
MaterialIcon { MaterialIcon {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3outlineVariant color: Colors.tPalette.m3outlineVariant
font.pointSize: 36 font.pointSize: 36
text: "chat" text: "chat"
} }
CustomText { CustomText {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant color: Colors.tPalette.m3onSurfaceVariant
text: qsTr("No messages yet") text: qsTr("Send a message to get started")
} }
Item { Item {
Layout.fillHeight: true Layout.fillHeight: true
} }
} }
@@ -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"
@@ -29,6 +31,7 @@ Column {
language: modelData.language language: modelData.language
code: modelData.code code: modelData.code
anchors.right: parent.right anchors.right: parent.right
anchors.margins: Tokens.spacing.small
anchors.left: parent.left anchors.left: parent.left
} }
} }
@@ -42,24 +45,27 @@ Column {
latex: modelData.latex latex: modelData.latex
anchors.right: parent.right anchors.right: parent.right
anchors.left: parent.left anchors.left: parent.left
anchors.margins: Tokens.padding.medium
} }
} }
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
anchors.right: parent.right anchors.right: parent.right
anchors.left: parent.left anchors.left: parent.left
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;
@@ -71,16 +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
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,89 +10,376 @@ 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;
LoadingIndicator { if (expanded)
id: spinnerReasoning return expand;
else if (pendingApproval)
return approval;
else
return collapsed;
}
anchors.centerIn: expandBtn Behavior on implicitHeight {
implicitSize: collapsedText.implicitHeight Anim {
opacity: root.isActive ? 1 : 0 type: Anim.DefaultEffects
visible: opacity > 0
Behavior on opacity {
Anim {}
} }
} }
IconButton { RowLayout {
id: expandBtn id: layout
anchors.left: parent.left
anchors.verticalCenter: collapsedText.verticalCenter
font.pointSize: Tokens.font.size.large
anchors.topMargin: 0
icon: "keyboard_arrow_down"
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
opacity: root.isActive ? 0 : 1
rotation: root.expanded ? 180 : 0
type: IconButton.Text
Behavior on rotation {
Anim {}
}
onClicked: root.expanded = !root.expanded
}
CustomText {
id: collapsedText
anchors.left: expandBtn.right
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: 0 anchors.left: parent.left
color: Colors.palette.m3outline anchors.right: parent.right
font.pointSize: Tokens.font.size.small
text: { Item {
if (root.isActive) { implicitHeight: expandBtn.implicitHeight
if (root.lastSegment.type === LlmSegment.Type.ToolCall) implicitWidth: expandBtn.implicitWidth
return qsTr("Using %1...").arg(root.lastSegment.name);
return qsTr("Thinking..."); LoadingIndicator {
id: spinnerReasoning
implicitSize: collapsedText.implicitHeight
anchors.centerIn: parent
opacity: root.isActive ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {}
}
}
IconButton {
id: expandBtn
font.pointSize: Tokens.font.size.large
icon: "keyboard_arrow_down"
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
anchors.centerIn: parent
opacity: root.isActive ? 0 : 1
enabled: opacity > 0
rotation: root.expanded ? 180 : 0
type: IconButton.Text
Behavior on rotation {
Anim {}
}
onClicked: root.expanded = !root.expanded
} }
return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1));
} }
Behavior on opacity { CustomText {
Anim {} id: collapsedText
color: Colors.palette.m3outline
font.pointSize: Tokens.font.size.small
Layout.fillWidth: true
Layout.leftMargin: Tokens.spacing.medium
text: {
if (root.isActive) {
if (root.pendingApproval)
return qsTr("Waiting for approval...");
if (root.lastSegment.type === LlmSegment.Type.ToolCall)
return qsTr("Using %1...").arg(root.lastSegment.name);
return qsTr("Thinking...");
}
return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1));
}
Behavior on opacity {
Anim {}
}
} }
} }
CustomClippingRect { 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 {} Anim {
} type: Anim.DefaultEffects
list.highlight: CustomRect {
color: Colors.palette.m3primary
implicitHeight: sidebar.list.currentItem?.implicitHeight ?? 0
implicitWidth: sidebar.list.width
radius: Tokens.rounding.medium
x: sidebar.list.currentItem?.chat.x ?? 0
y: sidebar.list.currentItem?.y ?? 0
Behavior on y {
Anim {
duration: Tokens.anim.durations.small
easing: Tokens.anim.expressiveDefaultEffects
}
} }
} }
model: ScriptModel { model: ScriptModel {
values: Chat.chats.values values: Chat.chats.values
} }
anchors.onRightChanged: {
if (anchors.right === undefined)
implicitWidth = 20;
}
onLoadChatRequest: (chat, index) => { onLoadChatRequest: (chat, index) => {
sidebar.list.currentIndex = index; ChatState.currentIdx = index;
root.openConversation(chat); ChatState.chatSession = chat;
}
onDeleteChatRequest: chat => {
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.top: parent.top anchors.bottomMargin: invertedRect.borderBottom
child: conversationView anchors.rightMargin: invertedRect.borderRight
radius: invertedRect.radius
children: [contentArea, dim]
} }
ChatContent { CustomRect {
id: dim
anchors.fill: parent
color: Colors.palette.m3shadow
opacity: ChatState.narrowSidebarExpanded && !root.isWide ? 0.3 : 0
z: 1
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
Item {
id: contentArea
property int leftMargin: sidebar.implicitWidth
z: 0
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 - anchors.margins * 2
states: State {
name: "wide"
when: !root.isWide
PropertyChanges {
contentArea.leftMargin: root.iconSize
}
}
transitions: Transition {
to: "wide"
Anim {
type: Anim.DefaultEffects
property: "leftMargin"
}
}
ChatHost {
id: convHost
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
implicitWidth: Math.max(Math.min(parent.width, 800), Config.sidebar.sizes.width)
clip: true
onLoadingChanged: if (!loading)
root.chatOpen = true
}
ColumnLayout {
id: noChatLayout
anchors.centerIn: parent
opacity: ChatState.chatSession ? 0 : 1
visible: opacity > 0
Behavior on opacity {
Anim {}
}
MaterialIcon {
Layout.alignment: Qt.AlignCenter
text: "chat_bubble_off"
font.pointSize: Tokens.font.size.extraLarge
color: Colors.tPalette.m3onSurfaceVariant
}
CustomText {
Layout.alignment: Qt.AlignCenter
text: qsTr("Start or create a chat")
color: Colors.tPalette.m3onSurfaceVariant
}
}
MouseArea {
id: dimArea
anchors.fill: parent
preventStealing: true
enabled: !root.isWide && ChatState.narrowSidebarExpanded
cursorShape: undefined
onClicked: {
const x = mouseX;
const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2;
if (x > invertedRect.borderLeft - margin)
ChatState.narrowSidebarExpanded = false;
}
HoverHandler {
cursorShape: dimArea.enabled ? Qt.ArrowCursor : undefined
enabled: dimArea.enabled
}
}
}
Binding {
target: ChatState
property: "windowIsWide"
value: root.showList
}
Binding {
target: ChatState
property: "isWide"
value: root.isWide
}
Binding {
target: ChatState
value: contentArea.leftMargin === root.iconSize
property: "settledSlim"
}
Binding {
target: ChatState
value: contentArea.leftMargin === sidebar.implicitWidth
property: "settledWide"
}
Component {
id: conversationView id: conversationView
anchors.fill: root ChatContent {}
anchors.leftMargin: root.isWide ? Config.sidebar.sizes.width / 2 : 0
chatData: root.currentConversation
} }
} }
+28 -25
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,33 +70,39 @@ 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
opacity: root.props.currentTab === 1 ? 1 : 0 width: parent.width
visible: opacity > 0 property bool tre: true
x: root.props.currentTab === 0 ? root.width : 0
z: 1
Behavior on opacity { sourceComponent: Item {
Anim { id: chatPage
opacity: root.props.currentTab === 1 ? 1 : 0
visible: opacity > 0
x: root.props.currentTab === 0 ? root.width : 0
z: 1
Behavior on opacity {
Anim {}
} }
}
Behavior on x { Behavior on x {
Anim { Anim {}
} }
}
CustomRect { CustomRect {
anchors.fill: parent
color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small
ChatPanel {
anchors.fill: parent anchors.fill: parent
color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small
ChatPanel {
anchors.fill: parent
}
} }
} }
} }
@@ -110,7 +110,10 @@ ColumnLayout {
onClicked: { onClicked: {
root.visibilities.sidebar = false; root.visibilities.sidebar = false;
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]);
else
Quickshell.execDetached([...Config.general.apps.playback, recording.modelData.path]);
} }
} }
@@ -120,7 +123,10 @@ ColumnLayout {
onClicked: { onClicked: {
root.visibilities.sidebar = false; root.visibilities.sidebar = false;
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]);
else
Quickshell.execDetached([...Config.general.apps.explorer, recording.modelData.path]);
} }
} }
} }
@@ -135,8 +141,7 @@ ColumnLayout {
} }
} }
Behavior on implicitHeight { 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;
+23 -10
View File
@@ -15,13 +15,19 @@ 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 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 last: false
signal accepted signal accepted
signal cancelled signal cancelled
@@ -44,8 +50,7 @@ Item {
color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer
Behavior on color { Behavior on color {
CAnim { CAnim {}
}
} }
} }
@@ -55,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
@@ -75,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
@@ -103,7 +110,7 @@ Item {
} }
Anim { Anim {
properties: "opacity,radius,bottomLeftRadius,bottomRightRadius" properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
type: Anim.DefaultEffects type: Anim.DefaultEffects
} }
@@ -131,12 +138,13 @@ Item {
id: dialogBg id: dialogBg
anchors.fill: parent anchors.fill: parent
bottomLeftRadius: Tokens.rounding.largeIncreased bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
bottomRightRadius: Tokens.rounding.largeIncreased 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 deformScale: 0
group: blobGroup group: blobGroup
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
radius: Tokens.rounding.extraSmall
} }
RowButton { RowButton {
@@ -145,9 +153,11 @@ Item {
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
color: "transparent" color: "transparent"
height: Math.min(implicitHeight, parent.height) // Clamp to parent height due to overshoot anim height: Math.min(implicitHeight, parent.height)
icon: root.icon icon: root.icon
last: true subtext: root.subtext
last: root.last
first: root.first
text: root.label text: root.label
transform: Matrix4x4 { transform: Matrix4x4 {
@@ -167,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
@@ -219,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();
@@ -232,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 -2
View File
@@ -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) {
selectedItem = null; root.rootParent.interactive = false;
if (!initialSelect)
selectedItem = null;
else
selectedItem = initialSelect;
} else {
root.rootParent.interactive = true;
}
} }
} }
+16 -7
View File
@@ -30,7 +30,7 @@ ColumnLayout {
function findAnchor(item: Item, anchor: string): Item { 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)
} }
} }
+421 -519
View File
@@ -6,15 +6,13 @@ import qs.Components
import qs.Helpers import qs.Helpers
import qs.Services import qs.Services
CustomClippingRect { ColumnLayout {
id: root id: root
required property var object required property int start
required property list<string> settings required property int end
property bool shouldBeActive: true signal endEdit(time: int)
signal startEdit(time: int)
signal applySettings(startTime: int, endTime: int)
signal close
function convertHour(timeValue: int): int { function convertHour(timeValue: int): int {
return Math.floor(timeValue / 60); return Math.floor(timeValue / 60);
@@ -28,555 +26,459 @@ CustomClippingRect {
return hour * 60 + minute; return hour * 60 + minute;
} }
color: Colors.palette.m3surfaceContainer spacing: Tokens.spacing.medium
implicitHeight: column.implicitHeight + column.anchors.margins * 2 + buttonRow.implicitHeight + buttonRow.anchors.topMargin
implicitWidth: column.implicitWidth + column.anchors.margins * 2
radius: Tokens.rounding.largeIncreased
ColumnLayout { RowLayout {
id: column CustomRect {
id: startHourRect
anchors.left: parent.left Layout.preferredHeight: 72
anchors.margins: Tokens.padding.largeIncreased Layout.preferredWidth: 96
anchors.right: parent.right color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
anchors.top: parent.top implicitHeight: 72
spacing: Tokens.spacing.medium implicitWidth: 96
radius: Tokens.rounding.small
CustomRect {
anchors.fill: parent
border.color: startHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: startHourField.focus ? 2 : 0
radius: parent.radius - border.width
Behavior on border.width {
Anim {}
}
}
TextFieldBase {
id: startHourField
function setConfigText(): string {
var val = root.convertHour(root.start);
if (val === 0) {
return "00";
}
return String(val);
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText()
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
if (startHourField.text.length >= 2) {
startHourField.text = "0" + startHourField.text[0];
} else if (startHourField.text.length === 1) {
startHourField.text = "0";
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
startHourField.text = setConfigText();
startHourField.focus = false;
} else if (event.key === Qt.Key_Return) {
startHourField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
startMinuteField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
endMinuteField.focus = true;
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
var digit = event.text;
var textLen = startHourField.text.length;
if (textLen >= 2 && startHourField.text[0] !== '0') {
return;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(startHourField.text + digit);
} else {
val = parseInt(startHourField.text[1] + digit);
}
val = Math.max(0, Math.min(23, val));
if (textLen >= 2 && val < 10) {
startHourField.text = "0" + val;
} else {
startHourField.text = val.toString();
}
}
event.accepted = true;
}
onCursorPositionChanged: cursorPosition = 2
onTextChanged: {
var mins = parseInt(startMinuteField.text);
const hours = root.convertToMinutes(parseInt(startHourField.text));
root.startEdit(hours + mins);
}
}
}
CustomText { CustomText {
text: qsTr("Select time") id: startSeparator
font.pointSize: Tokens.font.size.extraLarge
text: ":"
} }
RowLayout { CustomRect {
CustomRect { id: startMinuteRect
id: startHourRect
Layout.preferredHeight: 72 Layout.preferredHeight: 72
Layout.preferredWidth: 96 Layout.preferredWidth: 96
color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
implicitHeight: 72 implicitHeight: 72
implicitWidth: 96 implicitWidth: 96
radius: Tokens.rounding.small radius: Tokens.rounding.small
CustomRect {
anchors.fill: parent
border.color: startHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: startHourField.focus ? 2 : 0
radius: parent.radius - border.width
Behavior on border.width {
Anim {
}
}
}
TextFieldBase {
id: startHourField
function setConfigText(setting: string): string {
var val = root.convertHour(root.object[setting]);
if (val === 0) {
return "00";
}
return String(val);
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText(root.settings[1])
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
if (startHourField.text.length >= 2) {
startHourField.text = "0" + startHourField.text[0];
} else if (startHourField.text.length === 1) {
startHourField.text = "0";
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
startHourField.text = setConfigText(root.settings[1]);
startHourField.focus = false;
} else if (event.key === Qt.Key_Return) {
startHourField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
startMinuteField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
endMinuteField.focus = true;
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
var digit = event.text;
var textLen = startHourField.text.length;
if (textLen >= 2 && startHourField.text[0] !== '0') {
return;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(startHourField.text + digit);
} else {
val = parseInt(startHourField.text[1] + digit);
}
val = Math.max(0, Math.min(23, val));
if (textLen >= 2 && val < 10) {
startHourField.text = "0" + val;
} else {
startHourField.text = val.toString();
}
}
event.accepted = true;
}
onCursorPositionChanged: cursorPosition = 2
onTextEdited: {
if (startHourField.text === "")
return;
var val = parseInt(startHourField.text);
if (isNaN(val))
return;
val = Math.max(0, Math.min(23, val));
var newText = val.toString();
if (newText !== startHourField.text)
startHourField.text = newText;
}
}
}
CustomText {
id: startSeparator
font.pointSize: Tokens.font.size.extraLarge
text: ":"
}
CustomRect { CustomRect {
id: startMinuteRect anchors.fill: parent
border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: startMinuteField.focus ? 2 : 0
radius: parent.radius - border.width
Layout.preferredHeight: 72 Behavior on border.width {
Layout.preferredWidth: 96 Anim {}
color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest }
implicitHeight: 72 }
implicitWidth: 96
radius: Tokens.rounding.small
CustomRect { TextFieldBase {
anchors.fill: parent id: startMinuteField
border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: startMinuteField.focus ? 2 : 0
radius: parent.radius - border.width
Behavior on border.width { function setConfigText(): string {
Anim { var val = root.convertMinute(root.start);
} if (val === 0) {
return "00";
} }
return String(val);
} }
TextFieldBase { anchors.left: parent.left
id: startMinuteField anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
function setConfigText(setting: string): string { clip: true
var val = root.convertMinute(root.object[setting]); color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
if (val === 0) { font.family: "Roboto"
return "00"; font.letterSpacing: -0.25
} font.pixelSize: 56
return String(val); font.weight: 400
} horizontalAlignment: TextInput.AlignHCenter
text: setConfigText()
anchors.left: parent.left verticalAlignment: TextInput.AlignVCenter
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText(root.settings[1])
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
if (startMinuteField.text.length >= 2) {
startMinuteField.text = "0" + startMinuteField.text[0];
} else if (startMinuteField.text.length === 1) {
startMinuteField.text = "0";
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
startMinuteField.text = setConfigText(root.settings[1]);
startMinuteField.focus = false;
} else if (event.key === Qt.Key_Return) {
startMinuteField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
endHourField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
startHourField.focus = true;
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
var digit = event.text;
var textLen = startMinuteField.text.length;
if (textLen >= 2 && startMinuteField.text[0] !== '0') {
return;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(startMinuteField.text + digit);
} else {
val = parseInt(startMinuteField.text[1] + digit);
}
val = Math.max(0, Math.min(59, val));
if (textLen >= 2 && val < 10) {
startMinuteField.text = "0" + val;
} else {
startMinuteField.text = val.toString();
}
}
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true; event.accepted = true;
} if (startMinuteField.text.length >= 2) {
onCursorPositionChanged: cursorPosition = 2 startMinuteField.text = "0" + startMinuteField.text[0];
onTextEdited: { } else if (startMinuteField.text.length === 1) {
if (startMinuteField.text === "") startMinuteField.text = "0";
return;
var val = parseInt(startMinuteField.text);
if (isNaN(val))
return;
val = Math.max(0, Math.min(23, val));
var newText = val.toString();
if (newText !== startMinuteField.text)
startMinuteField.text = newText;
}
}
}
Item {
id: spacer
Layout.preferredWidth: Tokens.spacing.largeIncreased
}
CustomRect {
id: endHourRect
Layout.preferredHeight: 72
Layout.preferredWidth: 96
color: endHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
implicitHeight: 72
implicitWidth: 96
radius: Tokens.rounding.small
CustomRect {
anchors.fill: parent
border.color: endHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: endHourField.focus ? 2 : 0
radius: parent.radius - border.width
Behavior on border.width {
Anim {
} }
} return;
} } else if (event.key === Qt.Key_Escape) {
TextFieldBase {
id: endHourField
function setConfigText(setting: string): string {
var val = root.convertHour(root.object[setting]);
if (val === 0) {
return "00";
}
return String(val);
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText(root.settings[2])
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
if (endHourField.text.length >= 2) {
endHourField.text = "0" + endHourField.text[0];
} else if (endHourField.text.length === 1) {
endHourField.text = "0";
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
endHourField.text = setConfigText(root.settings[2]);
endHourField.focus = false;
} else if (event.key === Qt.Key_Return) {
endHourField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
endMinuteField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
startMinuteField.focus = true;
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
var digit = event.text;
var textLen = endHourField.text.length;
if (textLen >= 2 && endHourField.text[0] !== '0') {
return;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(endHourField.text + digit);
} else {
val = parseInt(endHourField.text[1] + digit);
}
val = Math.max(0, Math.min(23, val));
if (textLen >= 2 && val < 10) {
endHourField.text = "0" + val;
} else {
endHourField.text = val.toString();
}
}
event.accepted = true; event.accepted = true;
} startMinuteField.text = setConfigText();
onCursorPositionChanged: cursorPosition = 2 startMinuteField.focus = false;
onTextEdited: { } else if (event.key === Qt.Key_Return) {
if (endHourField.text === "") startMinuteField.focus = false;
return; return;
var val = parseInt(endHourField.text); } else if (event.key === Qt.Key_Tab) {
if (isNaN(val)) endHourField.focus = true;
return; } else if (event.key === Qt.Key_Backtab) {
val = Math.max(0, Math.min(23, val)); startHourField.focus = true;
var newText = val.toString();
if (newText !== endHourField.text)
endHourField.text = newText;
}
}
}
CustomText {
id: endSeparator
font.pointSize: Tokens.font.size.extraLarge
text: ":"
}
CustomRect {
id: endMinuteRect
Layout.preferredHeight: 72
Layout.preferredWidth: 96
color: endMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
implicitHeight: 72
implicitWidth: 96
radius: Tokens.rounding.small
CustomRect {
anchors.fill: parent
border.color: endMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: endMinuteField.focus ? 2 : 0
radius: parent.radius - border.width
Behavior on border.width {
Anim {
}
}
}
TextFieldBase {
id: endMinuteField
function setConfigText(setting: string): string {
var val = root.convertMinute(root.object[setting]);
if (val === 0) {
return "00";
}
return String(val);
} }
anchors.left: parent.left if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText(root.settings[2])
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
if (endMinuteField.text.length >= 2) {
endMinuteField.text = "0" + endMinuteField.text[0];
} else if (endMinuteField.text.length === 1) {
endMinuteField.text = "0";
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
endMinuteField.text = setConfigText(root.settings[2]);
endMinuteField.focus = false;
} else if (event.key === Qt.Key_Return) {
endMinuteField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
startHourField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
endHourField.focus = true;
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
var digit = event.text;
var textLen = endMinuteField.text.length;
if (textLen >= 2 && endMinuteField.text[0] !== '0') {
return;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(endMinuteField.text + digit);
} else {
val = parseInt(endMinuteField.text[1] + digit);
}
val = Math.max(0, Math.min(59, val));
if (textLen >= 2 && val < 10) {
endMinuteField.text = "0" + val;
} else {
endMinuteField.text = val.toString();
}
}
event.accepted = true; event.accepted = true;
} var digit = event.text;
onCursorPositionChanged: cursorPosition = 2 var textLen = startMinuteField.text.length;
onTextEdited: {
if (endMinuteField.text === "") if (textLen >= 2 && startMinuteField.text[0] !== '0') {
return; return;
var val = parseInt(endMinuteField.text); }
if (isNaN(val))
return; var val = 0;
val = Math.max(0, Math.min(23, val)); if (textLen === 0) {
var newText = val.toString(); val = parseInt(digit);
if (newText !== endMinuteField.text) } else if (textLen === 1) {
endMinuteField.text = newText; val = parseInt(startMinuteField.text + digit);
} else {
val = parseInt(startMinuteField.text[1] + digit);
}
val = Math.max(0, Math.min(59, val));
if (textLen >= 2 && val < 10) {
startMinuteField.text = "0" + val;
} else {
startMinuteField.text = val.toString();
}
} }
event.accepted = true;
}
onCursorPositionChanged: cursorPosition = 2
onTextChanged: {
var mins = parseInt(startMinuteField.text);
const hours = root.convertToMinutes(parseInt(startHourField.text));
root.startEdit(hours + mins);
} }
} }
} }
RowLayout { Item {
CustomText { id: spacer
Layout.preferredWidth: spacer.x + spacer.width
text: qsTr("Start") Layout.preferredWidth: Tokens.spacing.largeIncreased
}
CustomRect {
id: endHourRect
Layout.preferredHeight: 72
Layout.preferredWidth: 96
color: endHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
implicitHeight: 72
implicitWidth: 96
radius: Tokens.rounding.small
CustomRect {
anchors.fill: parent
border.color: endHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: endHourField.focus ? 2 : 0
radius: parent.radius - border.width
Behavior on border.width {
Anim {}
}
} }
CustomText { TextFieldBase {
Layout.preferredWidth: endMinuteRect.width + endHourRect.width id: endHourField
text: qsTr("End")
function setConfigText(): string {
var val = root.convertHour(root.end);
if (val === 0) {
return "00";
}
return String(val);
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText()
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
if (endHourField.text.length >= 2) {
endHourField.text = "0" + endHourField.text[0];
} else if (endHourField.text.length === 1) {
endHourField.text = "0";
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
endHourField.text = setConfigText();
endHourField.focus = false;
} else if (event.key === Qt.Key_Return) {
endHourField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
endMinuteField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
startMinuteField.focus = true;
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
var digit = event.text;
var textLen = endHourField.text.length;
if (textLen >= 2 && endHourField.text[0] !== '0') {
return;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(endHourField.text + digit);
} else {
val = parseInt(endHourField.text[1] + digit);
}
val = Math.max(0, Math.min(23, val));
if (textLen >= 2 && val < 10) {
endHourField.text = "0" + val;
} else {
endHourField.text = val.toString();
}
}
event.accepted = true;
}
onCursorPositionChanged: cursorPosition = 2
onTextChanged: {
var mins = parseInt(endMinuteField.text);
const hours = root.convertToMinutes(parseInt(endHourField.text));
root.endEdit(hours + mins);
}
}
}
CustomText {
id: endSeparator
font.pointSize: Tokens.font.size.extraLarge
text: ":"
}
CustomRect {
id: endMinuteRect
Layout.preferredHeight: 72
Layout.preferredWidth: 96
color: endMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
implicitHeight: 72
implicitWidth: 96
radius: Tokens.rounding.small
CustomRect {
anchors.fill: parent
border.color: endMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
border.width: endMinuteField.focus ? 2 : 0
radius: parent.radius - border.width
Behavior on border.width {
Anim {}
}
}
TextFieldBase {
id: endMinuteField
function setConfigText(): string {
var val = root.convertMinute(root.end);
if (val === 0) {
return "00";
}
return String(val);
}
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
clip: true
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
font.family: "Roboto"
font.letterSpacing: -0.25
font.pixelSize: 56
font.weight: 400
horizontalAlignment: TextInput.AlignHCenter
text: setConfigText()
verticalAlignment: TextInput.AlignVCenter
Keys.onPressed: event => {
if (event.key === Qt.Key_Backspace) {
event.accepted = true;
if (endMinuteField.text.length >= 2) {
endMinuteField.text = "0" + endMinuteField.text[0];
} else if (endMinuteField.text.length === 1) {
endMinuteField.text = "0";
}
return;
} else if (event.key === Qt.Key_Escape) {
event.accepted = true;
endMinuteField.text = setConfigText();
endMinuteField.focus = false;
} else if (event.key === Qt.Key_Return) {
endMinuteField.focus = false;
return;
} else if (event.key === Qt.Key_Tab) {
startHourField.focus = true;
} else if (event.key === Qt.Key_Backtab) {
endHourField.focus = true;
}
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
event.accepted = true;
var digit = event.text;
var textLen = endMinuteField.text.length;
if (textLen >= 2 && endMinuteField.text[0] !== '0') {
return;
}
var val = 0;
if (textLen === 0) {
val = parseInt(digit);
} else if (textLen === 1) {
val = parseInt(endMinuteField.text + digit);
} else {
val = parseInt(endMinuteField.text[1] + digit);
}
val = Math.max(0, Math.min(59, val));
if (textLen >= 2 && val < 10) {
endMinuteField.text = "0" + val;
} else {
endMinuteField.text = val.toString();
}
}
event.accepted = true;
}
onCursorPositionChanged: cursorPosition = 2
onTextChanged: {
var mins = parseInt(endMinuteField.text);
const hours = root.convertToMinutes(parseInt(endHourField.text));
root.endEdit(hours + mins);
}
} }
} }
} }
RowLayout { RowLayout {
id: buttonRow CustomText {
Layout.preferredWidth: spacer.x + spacer.width
anchors.bottom: parent.bottom text: qsTr("Start")
anchors.left: parent.left
anchors.margins: Tokens.padding.largeIncreased
anchors.right: parent.right
anchors.top: column.bottom
anchors.topMargin: Tokens.spacing.medium
Item {
id: buttonSpacer
Layout.fillWidth: true
} }
ButtonRow { CustomText {
spacing: Tokens.spacing.medium Layout.preferredWidth: endMinuteRect.width + endHourRect.width
text: qsTr("End")
IconTextButton {
font.pointSize: Tokens.font.size.normal
icon: "close"
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2)
inactiveOnColor: Colors.palette.m3onSurfaceVariant
isRound: true
isToggle: false
shapeMorph: true
shapeMorphExpansion: pressed ? 12 : 0
text: "Cancel"
onClicked: root.close()
}
IconTextButton {
font.pointSize: Tokens.font.size.normal
icon: "check"
isRound: true
isToggle: false
shapeMorph: true
shapeMorphExpansion: pressed ? 12 : 0
text: "Apply"
onClicked: {
const start = root.convertToMinutes(parseInt(startHourField.text)) + parseInt(startMinuteField.text);
const end = root.convertToMinutes(parseInt(endHourField.text)) + parseInt(endMinuteField.text);
root.applySettings(start, end);
}
}
} }
} }
} }
+10 -4
View File
@@ -12,6 +12,7 @@ CustomSwitch {
property alias last: bg.last property alias last: bg.last
property string settingAnchor property string settingAnchor
property string subtext property string subtext
property string iconText
function flashHighlight(): void { function flashHighlight(): void {
bg.flashHighlight(); bg.flashHighlight();
@@ -63,8 +64,7 @@ CustomSwitch {
text: root.text text: root.text
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
} }
@@ -81,10 +81,16 @@ CustomSwitch {
visible: root.subtext visible: root.subtext
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
} }
MaterialIcon {
id: icon
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.iconText ?? ""
}
} }
} }
+1 -6
View File
@@ -24,8 +24,7 @@ CustomClippingRect {
radius: Tokens.rounding.largeIncreased + Tokens.padding.small radius: Tokens.rounding.largeIncreased + Tokens.padding.small
Behavior on blobColor { Behavior on blobColor {
CAnim { CAnim {}
}
} }
TapHandler { TapHandler {
@@ -70,8 +69,4 @@ CustomClippingRect {
anchors.top: parent.top anchors.top: parent.top
sState: root.sState sState: root.sState
} }
PopupOverlay {
anchors.fill: parent
}
} }
+12 -19
View File
@@ -81,14 +81,10 @@ VerticalFadeFlickable {
topLeftRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall 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
@@ -318,9 +310,10 @@ 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);
}
} }
CustomSwitch { CustomSwitch {
+33 -46
View File
@@ -11,6 +11,7 @@ import qs.Modules.Settings.Pages.Audio
import qs.Modules.Settings.Pages.Apps import qs.Modules.Settings.Pages.Apps
import qs.Modules.Settings.Pages.Panels import qs.Modules.Settings.Pages.Panels
import qs.Modules.Settings.Pages.Panels.Bar import qs.Modules.Settings.Pages.Panels.Bar
import qs.Modules.Settings.Pages.Panels.Sidebar
import qs.Modules.Settings.Pages.Services import qs.Modules.Settings.Pages.Services
import qs.Services import qs.Services
@@ -23,13 +24,15 @@ QtObject {
// Wallpaper & style // Wallpaper & style
StackPage { StackPage {
Component { Component {
WallpaperPage { WallpaperPage {}
}
} }
Component { Component {
WallpaperSelect { WallpaperSelect {}
} }
Component {
ColorsFonts {}
} }
} }
}, },
@@ -37,32 +40,27 @@ QtObject {
// Screenshot // Screenshot
StackPage { StackPage {
Component { Component {
ScreenshotPage { ScreenshotPage {}
}
} }
} }
}, },
// Connectivity // Connectivity
Component { Component {
PlaceholderComp { PlaceholderComp {}
}
}, },
Component { Component {
PlaceholderComp { PlaceholderComp {}
}
}, },
Component { Component {
// Audio // Audio
StackPage { StackPage {
Component { Component {
AudioPage { AudioPage {}
}
} }
Component { Component {
AppVolumes { AppVolumes {}
}
} }
} }
}, },
@@ -71,49 +69,45 @@ QtObject {
Component { Component {
StackPage { StackPage {
Component { Component {
PanelsPage { PanelsPage {}
}
} }
Component { Component {
BarPanel { BarPanel {}
}
} }
Component { Component {
DashboardPanel { DashboardPanel {}
}
} }
Component { Component {
ResourcesPanel { ResourcesPanel {}
}
} }
Component { Component {
LauncherPanel { LauncherPanel {}
}
} }
Component { Component {
SidebarPanel { SidebarPanel {}
}
} }
// Bar sub pages // Bar sub pages
Component { Component {
BarTray { BarTray {}
}
} }
Component { Component {
BarStatusIcons { BarStatusIcons {}
}
} }
Component { Component {
BarClock { BarClock {}
} }
// Sidebar sub pages
Component {
SidebarLlm {}
} }
} }
}, },
@@ -122,18 +116,15 @@ QtObject {
Component { Component {
StackPage { StackPage {
Component { Component {
AppsPage { AppsPage {}
}
} }
Component { Component {
AllApps { AllApps {}
}
} }
Component { Component {
AppInfo { AppInfo {}
}
} }
} }
}, },
@@ -142,13 +133,11 @@ QtObject {
Component { Component {
StackPage { StackPage {
Component { Component {
ServicesPage { ServicesPage {}
}
} }
Component { Component {
NotificationsPage { NotificationsPage {}
}
} }
} }
}, },
@@ -157,15 +146,13 @@ QtObject {
Component { Component {
StackPage { StackPage {
Component { Component {
AboutPage { AboutPage {}
}
} }
} }
} }
] ]
readonly property Component placeholderComp: Component { readonly property Component placeholderComp: Component {
PlaceholderComp { PlaceholderComp {}
}
} }
component PlaceholderComp: Item { component PlaceholderComp: Item {
+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,11 @@ 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
label: qsTr("Add entry") label: qsTr("Add entry")
model: { model: {
const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id)); const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id));
@@ -0,0 +1,119 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import qs.Components
import qs.Modules.Settings.Common
PageBase {
id: root
readonly property var schemes: [
{
id: "oneDark",
label: qsTr("One Dark")
},
{
id: "nord",
label: qsTr("Nord")
},
{
id: "dracula",
label: qsTr("Dracula")
},
{
id: "githubDark",
label: qsTr("Github Dark")
},
{
id: "solarizedDark",
label: qsTr("Solarized Dark")
},
{
id: "monokai",
label: qsTr("Monokai")
},
{
id: "gruvboxDark",
label: qsTr("Gruvbox Dark")
},
{
id: "catppuccinMocha",
label: qsTr("Catppuccin Mocha")
},
{
id: "tokyoNight",
label: qsTr("Tokyo Night")
},
{
id: "ayuDark",
label: qsTr("Ayu Dark")
},
{
id: "palenight",
label: qsTr("Pale Night")
}
]
isSubPage: true
title: qsTr("Sidebar")
ColumnLayout {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
spacing: Tokens.spacing.extraSmall / 2
width: root.cappedWidth
SectionHeader {
first: true
text: qsTr("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")
}
DialogSelectButton {
id: selectScheme
acceptLabel: qsTr("Confirm")
enabled: Object.keys(model).length > 0
settingAnchor: "panels-sidebar-select-scheme"
separateContent: false
header: qsTr("Select scheme")
first: true
last: true
icon: "format_paint"
iconLabel.font.pointSize: Tokens.font.size.large
rowButton.trailingIcon: "open_in_new"
onOpenChanged: {
root.sState.dimmed = open;
}
rowButton.activeItem: root.schemes.find(s => s.id === Config.llm.appearance.scheme).label
label: qsTr("Color scheme")
subtext: qsTr("Select the color scheme used for code blocks")
model: root.schemes
rootParent: root.flickable
initialSelect: Config.llm.appearance.scheme
onAccepted: {
if (!selectedItem)
return;
Config.llm.appearance.scheme = selectedItem;
}
}
}
}
@@ -88,7 +88,12 @@ 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
model: { model: {
const present = new Set(Config.utilities.quickToggles.values.map(item => item.id)); const present = new Set(Config.utilities.quickToggles.values.map(item => item.id));
return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({ return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({
@@ -108,5 +113,19 @@ PageBase {
}); });
} }
} }
SectionHeader {
text: qsTr("AI chat")
}
NavRow {
text: qsTr("AI chat")
settingAnchor: "panels-sidebar-llm"
icon: "robot_2"
first: true
last: true
onClicked: root.sState.openSubPage(9)
}
} }
} }
+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;
}
}
}
}
+113 -59
View File
@@ -33,18 +33,41 @@ PageBase {
} }
} }
IconTextButton { ButtonRow {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
enabled: Config.background.enabled spacing: Tokens.spacing.small
horizontalPadding: Tokens.padding.extraLarge
icon: "wallpaper"
isRound: true
shapeMorph: true
text: qsTr("Wallpapers")
type: IconTextButton.Tonal
verticalPadding: Tokens.padding.small
onClicked: root.sState.openSubPage(1) // Wallpaper page IconTextButton {
property string settingAnchor: "style-wallpapers"
enabled: Config.background.enabled
horizontalPadding: Tokens.padding.extraLarge
icon: "wallpaper"
isRound: true
shapeMorph: true
text: qsTr("Wallpapers")
type: IconTextButton.Tonal
verticalPadding: Tokens.padding.small
onClicked: root.sState.openSubPage(1) // Wallpaper page
}
IconTextButton {
property string settingAnchor: "style-colors-fonts"
enabled: Config.background.enabled
horizontalPadding: Tokens.padding.extraLarge
icon: "palette"
isRound: true
shapeMorph: true
text: qsTr("Colors & font")
type: IconTextButton.Tonal
verticalPadding: Tokens.padding.small
onClicked: root.sState.openSubPage(2) // Colors & font page
}
} }
ToggleRow { ToggleRow {
@@ -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) => {
Config.general.color.scheduleDarkStart = start;
Config.general.color.scheduleDarkEnd = end;
ModeScheduler.checkStartup();
PopupManager.requestClose();
}
onClose: PopupManager.requestClose()
}
} }
onClicked: value => { onAccepted: {
Config.general.color.scheduleDark = value; Config.colors.scheduleDarkStart = startCurrent;
Config.colors.scheduleDarkEnd = endCurrent;
ModeScheduler.checkStartup();
} }
} }
OverlayRow { TimeDialogSelect {
id: hyprsunset
readonly property string endTime: { readonly property string endTime: {
var d = new Date(0, 0, 0, 0, 0, 0, 0); var d = new Date(0, 0, 0, 0, 0, 0, 0);
d.setMinutes(Config.general.color.scheduleHyprsunsetEnd); d.setMinutes(Config.display.nightlight.scheduleEnd);
return Qt.formatTime(d, "hh:mm AP"); return Qt.formatTime(d, "hh:mm AP");
} }
readonly property string startTime: { readonly property string startTime: {
var d = new Date(0, 0, 0, 0, 0, 0, 0); var d = new Date(0, 0, 0, 0, 0, 0, 0);
d.setMinutes(Config.general.color.scheduleHyprsunsetStart); d.setMinutes(Config.display.nightlight.scheduleStart);
return Qt.formatTime(d, "hh:mm AP"); return Qt.formatTime(d, "hh:mm AP");
} }
rowButton.checked: Config.display.nightlight.schedule
rowButton.onToggled: Config.display.nightlight.schedule = rowButton.checked
start: Config.display.nightlight.scheduleStart
end: Config.display.nightlight.scheduleEnd
first: true
settingAnchor: "style-schedule-nightlight"
acceptLabel: qsTr("Apply")
rootParent: root.flickable
header: qsTr("Select time")
subtext: qsTr("Nightlight will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime)
label: qsTr("Schedule nightlight")
onOpenChanged: {
root.sState.dimmed = open;
}
onAccepted: {
Config.display.nightlight.scheduleStart = startCurrent;
Config.display.nightlight.scheduleEnd = endCurrent;
Hyprsunset.checkStartup();
}
}
ToggleRow {
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
checked: Config.general.color.scheduleHyprsunset
checked: Config.display.nightlight.useNative
settingAnchor: "display-nightlight-useNative"
text: qsTr("Use native")
subtext: qsTr("Use native shader nightlight for more features")
onToggled: Config.display.nightlight.useNative = checked
}
SpinRow {
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
from: 2000
settingAnchor: "display-nightlight-temp"
stepSize: 100
subtext: qsTr("How warm/orange the screen will be. Lower is warmer (Kelvin)")
text: qsTr("Temperature")
to: 6500
value: Config.display.nightlight.temp
onMoved: v => Config.display.nightlight.temp = v
}
SpinRow {
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
from: 0
settingAnchor: "display-nightlight-fade-duration"
enabled: Config.display.nightlight.useNative
last: true last: true
settingAnchor: "style-schedule-hyprsunset" stepSize: 1
subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime) subtext: qsTr("How long it takes for the gradual nightlight to finish (s)")
text: qsTr("Schedule hyprsunset") text: qsTr("Fade duration")
to: 60 * 5
value: Config.display.nightlight.fadeDuration / 1000
popup: Component { onMoved: v => Config.display.nightlight.fadeDuration = v * 1000
TimeInput {
object: Config.general.color
settings: ["scheduleHyprsunset", "scheduleHyprsunsetStart", "scheduleHyprsunsetEnd"]
onApplySettings: (start, end) => {
Config.general.color.scheduleHyprsunsetStart = start;
Config.general.color.scheduleHyprsunsetEnd = end;
Hyprsunset.checkStartup();
PopupManager.requestClose();
}
onClose: PopupManager.requestClose()
}
}
onClicked: value => {
Config.general.color.scheduleHyprsunset = value;
}
} }
} }
} }
-20
View File
@@ -1,20 +0,0 @@
pragma Singleton
import Quickshell
import QtQuick
Singleton {
id: root
property bool closed: true
property Component currentPopup: null
function requestClose(): void {
closed = true;
}
function requestOpen(component: Component): void {
currentPopup = component;
closed = false;
}
}
-47
View File
@@ -1,47 +0,0 @@
import QtQuick
import ZShell.Config
import qs.Components
import qs.Services
CustomRect {
id: root
readonly property bool closing: PopupManager.closed
readonly property var currentPopup: PopupManager.currentPopup
property bool shouldBeVisible: loader.status === Loader.Ready
color: Qt.alpha(Colors.palette.m3shadow, 0.3)
opacity: shouldBeVisible && !closing ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {
}
}
onVisibleChanged: if (!visible)
PopupManager.currentPopup = null
CustomMouseArea {
anchors.fill: parent
hoverEnabled: true
preventStealing: true
propagateComposedEvents: false
onClicked: {
const insideItemWidth = mouseX < loader.x + loader.item.width && mouseX > loader.x;
const insideItemHeight = mouseY < loader.y + loader.item.height && mouseY > loader.y;
if (insideItemHeight && insideItemWidth)
return;
PopupManager.requestClose();
}
}
Loader {
id: loader
anchors.centerIn: parent
sourceComponent: root.currentPopup
}
}
+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()
+5 -4
View File
@@ -1,10 +1,11 @@
qml_module(ZShell-components qml_module(ZShell-components
URI ZShell.Components URI ZShell.Components
SOURCES SOURCES
lazylistview.hpp lazylistview.cpp lazylistview.hpp lazylistview.cpp
wavyline.hpp wavyline.cpp wavyline.hpp wavyline.cpp
buttonrow.hpp buttonrow.cpp buttonrow.hpp buttonrow.cpp
carouselview.hpp carouselview.cpp carouselview.hpp carouselview.cpp
LIBRARIES wheelinverter.hpp
Qt::Quick LIBRARIES
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)) {}
}; };
+16 -3
View File
@@ -1,9 +1,21 @@
#pragma once #pragma once
#include "configobject.hpp" #include "configobject.hpp"
#include <qhashfunctions.h>
#include <qobject.h>
#include <qqmlintegration.h> #include <qqmlintegration.h>
namespace ZShell::config { namespace ZShell::config {
class LlmAppearance : public ConfigObject {
Q_OBJECT
QML_ANONYMOUS
CFG_PROPERTY(QString, scheme, QStringLiteral("tokyoNight"))
public:
explicit LlmAppearance(QObject* parent = nullptr) : ConfigObject(parent) {}
};
class Llm : public ConfigObject { class Llm : public ConfigObject {
Q_OBJECT Q_OBJECT
QML_ANONYMOUS QML_ANONYMOUS
@@ -11,12 +23,13 @@ class Llm : public ConfigObject {
CFG_PROPERTY(QString, endpoint, "http://localhost:8080") CFG_PROPERTY(QString, endpoint, "http://localhost:8080")
CFG_PROPERTY(QString, model, "") CFG_PROPERTY(QString, model, "")
CFG_PROPERTY(double, temperature, 0.7) CFG_PROPERTY(double, temperature, 0.7)
// Whether tools are offered to the model. Models without a tool-calling
// template should run with this off.
CFG_PROPERTY(bool, tools, true) CFG_PROPERTY(bool, tools, true)
CFG_PROPERTY(bool, enabled, true)
CONFIG_SUBOBJECT(LlmAppearance, appearance)
public: public:
explicit Llm(QObject* parent = nullptr) : ConfigObject(parent) {} explicit Llm(QObject* parent = nullptr)
: ConfigObject(parent), m_appearance(new LlmAppearance(this)) {}
}; };
} // namespace ZShell::config } // namespace ZShell::config
+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() {}
+247 -8
View File
@@ -14,15 +14,253 @@ find_path(JKQTPlotter6_CMAKE_DIR
DOC "Directory containing the JKQtPlotter cmake package files") DOC "Directory containing the JKQtPlotter cmake package files")
find_package(JKQTMathText6 REQUIRED PATHS "${JKQTPlotter6_CMAKE_DIR}") find_package(JKQTMathText6 REQUIRED PATHS "${JKQTPlotter6_CMAKE_DIR}")
# Embed the vendored highlight queries as C++ string literals. # --- tree-sitter grammar discovery (configure time) ---
set(HIGHLIGHT_QUERY_LANGS c cpp python javascript typescript bash json rust go yaml toml sql) #
set(HIGHLIGHT_QUERIES_HPP "${CMAKE_CURRENT_BINARY_DIR}/highlight-queries.hpp") # Discover installed tree-sitter grammars — system packages
set(_hl_header "#pragma once\n\n// Vendored tree-sitter highlight queries (MIT; see the per-file\n// source headers in the highlight-queries/ directory).\nnamespace ZShell::llm::hq {\n") # (libtree-sitter-<lang>.so) and the parsers Neovim's nvim-treesitter
foreach(_hl_lang IN LISTS HIGHLIGHT_QUERY_LANGS) # installs (~/.local/share/nvim/site/parser/*.so) — and pair each with
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/${_hl_lang}.scm" _hl_src) # highlight queries. Query sources, in priority order:
string(APPEND _hl_header "inline constexpr const char* ${_hl_lang} = R\"ZSQUERY(${_hl_src})ZSQUERY\";\n") # 1. vendored highlight-queries/*.scm (version-pinned; see the
# per-file source headers, MIT),
# 2. Neovim's own queries (version-matched to its parsers),
# 3. tree-sitter/highlighting from GitHub (cached in the build dir).
# The result is embedded as highlight-queries.hpp. Re-run cmake to pick
# up grammars installed later.
#
# Candidate entry points are read from the .so with `nm` rather than
# assumed to be tree_sitter_<id>; a missing entry point just makes that
# candidate fail at runtime.
function(_ts_entry_point out file)
# OUTPUT_VARIABLE + OUTPUT_QUIET loses the output on CMake 4, so
# capture through a temp file.
get_filename_component(_ts_nm_base "${file}" NAME)
set(_ts_nm_file "${CMAKE_CURRENT_BINARY_DIR}/ts-entry-${_ts_nm_base}")
execute_process(
COMMAND nm -D --defined-only "${file}"
RESULT_VARIABLE _ts_nm_rc
OUTPUT_FILE "${_ts_nm_file}"
ERROR_FILE "${_ts_nm_file}.err")
set(_ts_sym "tree_sitter_missing")
if(_ts_nm_rc EQUAL 0 AND EXISTS "${_ts_nm_file}")
file(READ "${_ts_nm_file}" _ts_nm_out)
file(REMOVE "${_ts_nm_file}" "${_ts_nm_file}.err")
# The library also exports the external scanner functions; the
# entry point is the one without the _external suffix.
string(REGEX MATCHALL " T tree_sitter_[A-Za-z0-9_]+" _ts_syms "${_ts_nm_out}")
foreach(_ts_s IN LISTS _ts_syms)
string(REPLACE " T " "" _ts_s "${_ts_s}")
if(NOT _ts_s MATCHES "_external")
set(_ts_sym "${_ts_s}")
break()
endif()
endforeach()
endif()
set(${out} "${_ts_sym}" PARENT_SCOPE)
endfunction()
# Append a query text to <files> (PARENT_SCOPE) unless it duplicates a
# hash in <hashes>. The text is written to the build tree right away:
# the s-expression `;` comments would split CMake list items and the
# query `(` `)` break bracket arguments, so query text only ever lives
# in variables and files, never in lists.
# Expand Neovim's "; inherits:" directives by appending the inherited
# query set's highlights file (recursively; a visited set prevents
# cycles). Several languages are stubs that inherit the real query
# (html inherits html_tags, qmljs inherits ecma, ...).
function(_ts_expand_inherits query_dir text outVar)
set(result "${text}")
set(_visited "")
set(_depth 0)
while(_depth LESS 8)
# Do not match the leading `;`: a MATCHALL result that itself
# contains a semicolon is re-split into list items.
string(REGEX MATCHALL "inherits:[ \t]*[A-Za-z0-9_]+" _inh "${result}")
if(NOT _inh)
break()
endif()
set(_added FALSE)
foreach(_entry IN LISTS _inh)
string(REGEX REPLACE "^inherits:[ \t]*" "" _name "${_entry}")
set(_file "${query_dir}/${_name}/highlights.scm")
if(NOT EXISTS "${_file}" OR _file IN_LIST _visited)
continue()
endif()
list(APPEND _visited "${_file}")
file(READ "${_file}" _inh_text)
string(APPEND result "\n${_inh_text}")
set(_added TRUE)
endforeach()
if(NOT _added)
break()
endif()
math(EXPR _depth "${_depth} + 1")
endwhile()
set(${outVar} "${result}" PARENT_SCOPE)
endfunction()
function(_ts_add_query sid text filesVar hashesVar)
# filesVar/hashesVar hold the caller's variable names.
set(files "${${filesVar}}")
set(hashes "${${hashesVar}}")
string(SHA256 _ts_qh "${text}")
if(_ts_qh IN_LIST hashes)
return()
endif()
list(APPEND hashes "${_ts_qh}")
list(LENGTH files _ts_qi)
set(_ts_qfile "${CMAKE_CURRENT_BINARY_DIR}/ts-queries/${sid}_${_ts_qi}.scm")
file(WRITE "${_ts_qfile}" "${text}")
list(APPEND files "${_ts_qfile}")
set(${filesVar} "${files}" PARENT_SCOPE)
set(${hashesVar} "${hashes}" PARENT_SCOPE)
endfunction()
set(_ts_candidates "") # entries: <id>|<cmake-safe id>|<lib>|<symbol>
file(GLOB _ts_sys_files
"/usr/lib/libtree-sitter-*.so" "/usr/local/lib/libtree-sitter-*.so")
foreach(_ts_file IN LISTS _ts_sys_files)
get_filename_component(_ts_name "${_ts_file}" NAME)
string(REGEX REPLACE "^libtree-sitter-(.+)\.so$" "\\1" _ts_id "${_ts_name}")
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
_ts_entry_point(_ts_sym "${_ts_file}")
list(APPEND _ts_candidates
"${_ts_id}|${_ts_sid}|libtree-sitter-${_ts_id}.so|${_ts_sym}")
endforeach() endforeach()
string(APPEND _hl_header "}\n") set(_ts_nvim_parser_dirs
"$ENV{HOME}/.local/share/nvim/site/parser"
"$ENV{HOME}/.local/share/nvim/runtime/parser"
"$ENV{HOME}/.local/share/nvim/lazy/nvim-treesitter/parser")
set(_ts_nvim_query_dirs
"$ENV{HOME}/.local/share/nvim/site/queries"
"$ENV{HOME}/.local/share/nvim/lazy/nvim-treesitter/runtime/queries")
foreach(_ts_dir IN LISTS _ts_nvim_parser_dirs)
file(GLOB _ts_dir_files "${_ts_dir}/*.so")
foreach(_ts_file IN LISTS _ts_dir_files)
get_filename_component(_ts_name "${_ts_file}" NAME)
string(REGEX REPLACE "\\.so$" "" _ts_id "${_ts_name}")
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
_ts_entry_point(_ts_sym "${_ts_file}")
list(APPEND _ts_candidates
"${_ts_id}|${_ts_sid}|${_ts_file}|${_ts_sym}")
endforeach()
endforeach()
set(_ts_ids "")
foreach(_ts_c IN LISTS _ts_candidates)
string(REPLACE "|" ";" _ts_parts "${_ts_c}")
list(GET _ts_parts 0 _ts_id)
if(NOT _ts_id IN_LIST _ts_ids)
list(APPEND _ts_ids "${_ts_id}")
endif()
endforeach()
list(SORT _ts_ids)
set(HIGHLIGHT_QUERIES_HPP "${CMAKE_CURRENT_BINARY_DIR}/highlight-queries.hpp")
set(_hl_header
"#pragma once\n\n// Generated by CMake. Discoverd tree-sitter grammars and their\n// highlight queries: vendored highlight-queries/*.scm (MIT), Neovim\n// nvim-treesitter queries, and tree-sitter/highlighting (MIT).\nnamespace ZShell::llm::hq {\nstruct Candidate { const char* lib; const char* symbol; };\nstruct Grammar { const char* id; int nCandidates; const Candidate* candidates; int nQueries; const char* const* queries; };\n")
set(_ts_grammar_rows "")
set(_ts_vendored
c cpp python javascript typescript tsx bash json rust go yaml toml sql)
foreach(_ts_id IN LISTS _ts_ids)
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
# Query candidates in priority order (deduped; identical texts are
# skipped, the nvim site and plugin copies are usually the same
# file).
set(_ts_qfiles "")
set(_ts_qhashes "")
if(_ts_id STREQUAL "cpp")
# The C++ grammar is a superset of C and its query only covers
# the C++ delta; base C coverage comes from the C query.
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/c.scm" _ts_qa)
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/cpp.scm" _ts_qb)
_ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes)
elseif(_ts_id STREQUAL "typescript" OR _ts_id STREQUAL "tsx")
# The TS grammars reuse the JS node names; the JS query usually
# compiles against them and gives full coverage.
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/javascript.scm" _ts_qa)
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/typescript.scm" _ts_qb)
_ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes)
_ts_add_query(${_ts_sid} "${_ts_qb}" _ts_qfiles _ts_qhashes)
elseif(_ts_id IN_LIST _ts_vendored)
file(READ
"${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/${_ts_id}.scm" _ts_q)
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
endif()
foreach(_ts_qd IN LISTS _ts_nvim_query_dirs)
if(EXISTS "${_ts_qd}/${_ts_id}/highlights.scm")
file(READ "${_ts_qd}/${_ts_id}/highlights.scm" _ts_q)
_ts_expand_inherits("${_ts_qd}" "${_ts_q}" _ts_q)
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
endif()
endforeach()
if(NOT _ts_qfiles)
# Last resort: fetch from the tree-sitter/highlighting repo.
# Version-skewed against locally installed grammars, so only
# used when nothing local exists. Cached; offline builds simply
# drop the language.
set(_ts_dl "${CMAKE_CURRENT_BINARY_DIR}/ts-query-downloads/${_ts_sid}.scm")
if(NOT EXISTS "${_ts_dl}")
file(DOWNLOAD
"https://raw.githubusercontent.com/tree-sitter/highlighting/main/queries/${_ts_id}/highlight.scm"
"${_ts_dl}" STATUS _ts_dl_status TIMEOUT 30)
list(GET _ts_dl_status 0 _ts_dl_rc)
if(NOT _ts_dl_rc EQUAL 0)
file(REMOVE "${_ts_dl}")
endif()
endif()
if(EXISTS "${_ts_dl}")
file(READ "${_ts_dl}" _ts_q)
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
endif()
endif()
list(LENGTH _ts_qfiles _ts_nq)
if(_ts_nq EQUAL 0)
continue()
endif()
# Emit query sources.
set(_ts_qn 0)
set(_ts_q_ptrs "")
foreach(_ts_qfile IN LISTS _ts_qfiles)
file(READ "${_ts_qfile}" _ts_q)
string(APPEND _hl_header
"inline constexpr const char* q_${_ts_sid}_${_ts_qn} = R\"ZSQUERY(${_ts_q})ZSQUERY\";\n")
string(APPEND _ts_q_ptrs "q_${_ts_sid}_${_ts_qn}, ")
math(EXPR _ts_qn "${_ts_qn} + 1")
endforeach()
string(APPEND _hl_header
"inline constexpr const char* const q_${_ts_sid}[] = { ${_ts_q_ptrs} };\n")
# Emit library candidates (system package first, then Neovim).
string(APPEND _hl_header "inline constexpr Candidate cand_${_ts_sid}[] = {\n")
set(_ts_nc 0)
foreach(_ts_c IN LISTS _ts_candidates)
string(REPLACE "|" ";" _ts_parts "${_ts_c}")
list(GET _ts_parts 0 _ts_cid)
if(_ts_cid STREQUAL _ts_id)
list(GET _ts_parts 2 _ts_lib)
list(GET _ts_parts 3 _ts_sym)
string(APPEND _hl_header
" { R\"ZSLIB(${_ts_lib})ZSLIB\", R\"ZSSYM(${_ts_sym})ZSSYM\" },\n")
math(EXPR _ts_nc "${_ts_nc} + 1")
endif()
endforeach()
string(APPEND _hl_header "};\n")
if(_ts_nc EQUAL 0)
# Queries but no library: pointless, drop the language.
string(APPEND _hl_header "") # (arrays stay; grammar row is skipped)
continue()
endif()
list(APPEND _ts_grammar_rows
"{ \"${_ts_id}\", ${_ts_nc}, cand_${_ts_sid}, ${_ts_nq}, q_${_ts_sid} },")
endforeach()
string(APPEND _hl_header "inline constexpr Grammar grammars[] = {\n")
foreach(_ts_row IN LISTS _ts_grammar_rows)
string(APPEND _hl_header " ${_ts_row}\n")
endforeach()
string(APPEND _hl_header "};\n}\n")
file(WRITE "${HIGHLIGHT_QUERIES_HPP}" "${_hl_header}") file(WRITE "${HIGHLIGHT_QUERIES_HPP}" "${_hl_header}")
# Embed the vendored Latin Modern fonts (GUST Font License; provenance # Embed the vendored Latin Modern fonts (GUST Font License; provenance
@@ -50,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
+20 -19
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,
@@ -88,7 +88,7 @@ Chat::Chat(QObject* parent)
this, this,
[this](ChatSession* session, const QString& title) { [this](ChatSession* session, const QString& title) {
qInfo() << "Chat: applying generated title" << session->id() qInfo() << "Chat: applying generated title" << session->id()
<< title << "(was" << session->title() << ")"; << title << "(was" << session->title() << ")";
session->setTitle(title); session->setTitle(title);
m_store->saveMeta(session); m_store->saveMeta(session);
}); });
@@ -97,8 +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*);
+286 -190
View File
@@ -4,14 +4,18 @@
#include "message.hpp" #include "message.hpp"
#include "segment.hpp" #include "segment.hpp"
#include <QCoreApplication>
#include <QDateTime> #include <QDateTime>
#include <QDebug> #include <QDebug>
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
#include <QPointer>
#include <QStandardPaths> #include <QStandardPaths>
#include <QSqlDatabase> #include <QSqlDatabase>
#include <QSqlError> #include <QSqlError>
#include <QSqlQuery> #include <QSqlQuery>
#include <QThreadPool>
#include <QVector>
#include <QUuid> #include <QUuid>
#include <algorithm> #include <algorithm>
@@ -33,32 +37,50 @@ 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;
} }
struct SegmentRow {
QString type;
QString text;
QString name;
QString toolCallId;
QString arguments;
QString result;
int status = 0;
qint64 elapsedMs = 0;
qint64 timestamp = 0;
};
struct GenerationRow {
qint64 timestamp = 0;
bool active = false;
QVector<SegmentRow> segments;
};
struct MessageRow {
bool user = false;
qint64 timestamp = 0;
QVector<GenerationRow> generations;
};
} // 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);
@@ -69,17 +91,17 @@ QSqlDatabase ChatStore::db() const {
} }
void ChatStore::openDb() { void ChatStore::openDb() {
const QString path = m_dbPath =
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation) + QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation) +
QStringLiteral("/zshell/chats.sqlite"); QStringLiteral("/zshell/chats.sqlite");
QDir().mkpath(QFileInfo(path).absolutePath()); QDir().mkpath(QFileInfo(m_dbPath).absolutePath());
QSqlDatabase db = QSqlDatabase db =
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
db.setDatabaseName(path); db.setDatabaseName(m_dbPath);
if (!db.open()) { if (!db.open()) {
qWarning() << "ChatStore: failed to open database" << path << ":" qWarning() << "ChatStore: failed to open database" << m_dbPath << ":"
<< db.lastError().text(); << db.lastError().text();
return; return;
} }
{ {
@@ -88,59 +110,55 @@ void ChatStore::openDb() {
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral( "CREATE TABLE IF NOT EXISTS sessions (\n"
"CREATE TABLE IF NOT EXISTS sessions (\n" " id TEXT PRIMARY KEY,\n"
" id TEXT PRIMARY KEY,\n" " title TEXT NOT NULL DEFAULT '',\n"
" title TEXT NOT NULL DEFAULT '',\n" " icon TEXT NOT NULL DEFAULT '',\n"
" icon TEXT NOT NULL DEFAULT '',\n" " created_at INTEGER NOT NULL,\n"
" created_at INTEGER NOT NULL,\n" " updated_at INTEGER NOT NULL,\n"
" updated_at INTEGER NOT NULL,\n" " pinned INTEGER NOT NULL DEFAULT 0\n"
" pinned INTEGER NOT NULL DEFAULT 0\n" ")"));
")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral( "CREATE TABLE IF NOT EXISTS messages (\n"
"CREATE TABLE IF NOT EXISTS messages (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " session_id TEXT NOT NULL REFERENCES sessions (id) "
" session_id TEXT NOT NULL REFERENCES sessions (id) " "ON DELETE CASCADE,\n"
"ON DELETE CASCADE,\n" " role TEXT NOT NULL,\n"
" role TEXT NOT NULL,\n" " timestamp INTEGER NOT NULL\n"
" timestamp INTEGER NOT NULL\n" ")"));
")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral( "CREATE TABLE IF NOT EXISTS generations (\n"
"CREATE TABLE IF NOT EXISTS generations (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " message_id INTEGER NOT NULL REFERENCES messages "
" message_id INTEGER NOT NULL REFERENCES messages " "(id) ON DELETE CASCADE,\n"
"(id) ON DELETE CASCADE,\n" " timestamp INTEGER NOT NULL,\n"
" timestamp INTEGER NOT NULL,\n" " is_active INTEGER NOT NULL DEFAULT 1\n"
" is_active INTEGER NOT NULL DEFAULT 1\n" ")"));
")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
query.exec( query.exec(QStringLiteral(
QStringLiteral( "CREATE TABLE IF NOT EXISTS segments (\n"
"CREATE TABLE IF NOT EXISTS segments (\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " generation_id INTEGER NOT NULL REFERENCES generations "
" generation_id INTEGER NOT NULL REFERENCES generations " "(id) ON DELETE CASCADE,\n"
"(id) ON DELETE CASCADE,\n" " type TEXT NOT NULL,\n"
" type TEXT NOT NULL,\n" " text TEXT NOT NULL DEFAULT '',\n"
" text TEXT NOT NULL DEFAULT '',\n" " name TEXT NOT NULL DEFAULT '',\n"
" name TEXT NOT NULL DEFAULT '',\n" " tool_call_id TEXT NOT NULL DEFAULT '',\n"
" tool_call_id TEXT NOT NULL DEFAULT '',\n" " arguments TEXT NOT NULL DEFAULT '',\n"
" arguments TEXT NOT NULL DEFAULT '',\n" " result TEXT NOT NULL DEFAULT '',\n"
" result TEXT NOT NULL DEFAULT '',\n" " status INTEGER NOT NULL DEFAULT 0,\n"
" status INTEGER NOT NULL DEFAULT 0,\n" " elapsed_ms INTEGER NOT NULL DEFAULT 0,\n"
" elapsed_ms INTEGER NOT NULL DEFAULT 0,\n" " timestamp INTEGER NOT NULL\n"
" timestamp INTEGER NOT NULL\n" ")"));
")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(db);
@@ -169,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);
} }
@@ -187,7 +204,7 @@ ChatSession* ChatStore::insert(int index) {
query.bindValue(":updated_at", now); query.bindValue(":updated_at", now);
if (!query.exec()) if (!query.exec())
qWarning() << "ChatStore: failed to insert session" << id << ":" qWarning() << "ChatStore: failed to insert session" << id << ":"
<< query.lastError().text(); << query.lastError().text();
} }
auto* session = new ChatSession(id, this); auto* session = new ChatSession(id, this);
session->setMeta(QString(), now, now, 0); session->setMeta(QString(), now, now, 0);
@@ -207,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);
{ {
@@ -224,7 +240,7 @@ void ChatStore::removeSession(ChatSession* session) {
void ChatStore::move(int from, int to) { void ChatStore::move(int from, int to) {
if (from < 0 || from >= m_sessions.size() || to < 0 || if (from < 0 || from >= m_sessions.size() || to < 0 ||
to >= m_sessions.size() || from == to) to >= m_sessions.size() || from == to)
return; return;
m_sessions.move(from, to); m_sessions.move(from, to);
Q_EMIT valuesChanged(); Q_EMIT valuesChanged();
@@ -238,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;
} }
@@ -248,27 +263,28 @@ void ChatStore::setLlmClient(LlmClient* client) {
} }
void ChatStore::persist(ChatSession* session) { void ChatStore::persist(ChatSession* session) {
if (!session || !m_sessions.contains(session)) if (!session || !m_sessions.contains(session)) return;
if (!session->isLoaded()) {
m_pendingPersists.insert(session);
return; return;
}
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
session->ensureLoaded(); if (!saveSession(session)) return;
if (!saveSession(session))
return;
sortAndNotify(); sortAndNotify();
} }
void ChatStore::saveMeta(ChatSession* session) { void ChatStore::saveMeta(ChatSession* session) {
if (!session || !m_sessions.contains(session)) if (!session || !m_sessions.contains(session)) return;
return;
QSqlQuery query(db()); QSqlQuery query(db());
query.prepare("UPDATE sessions SET title = :title, icon = :icon " query.prepare(
"WHERE id = :id"); "UPDATE sessions SET title = :title, icon = :icon "
"WHERE id = :id");
query.bindValue(":title", sqlText(session->title())); query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon())); query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":id", session->id()); query.bindValue(":id", session->id());
if (!query.exec()) if (!query.exec())
qWarning() << "ChatStore: failed to save meta for" << session->id() qWarning() << "ChatStore: failed to save meta for" << session->id()
<< ":" << query.lastError().text(); << ":" << query.lastError().text();
} }
bool ChatStore::saveSession(ChatSession* session) { bool ChatStore::saveSession(ChatSession* session) {
@@ -276,7 +292,7 @@ bool ChatStore::saveSession(ChatSession* session) {
QSqlDatabase handle = db(); QSqlDatabase handle = db();
if (!handle.transaction()) { if (!handle.transaction()) {
qWarning() << "ChatStore: failed to begin transaction:" qWarning() << "ChatStore: failed to begin transaction:"
<< handle.lastError().text(); << handle.lastError().text();
return false; return false;
} }
bool ok = true; bool ok = true;
@@ -302,18 +318,18 @@ bool ChatStore::saveSession(ChatSession* session) {
"INSERT INTO messages (session_id, role, timestamp) " "INSERT INTO messages (session_id, role, timestamp) "
"VALUES (:id, :role, :timestamp)"); "VALUES (:id, :role, :timestamp)");
QSqlQuery generationInsert(handle); QSqlQuery generationInsert(handle);
ok = ok && generationInsert.prepare( ok = ok &&
"INSERT INTO generations (message_id, timestamp, is_active) " generationInsert.prepare(
"VALUES (:mid, :timestamp, :is_active)"); "INSERT INTO generations (message_id, timestamp, is_active) "
"VALUES (:mid, :timestamp, :is_active)");
QSqlQuery segmentInsert(handle); QSqlQuery segmentInsert(handle);
ok = ok && segmentInsert.prepare( ok = ok &&
"INSERT INTO segments (generation_id, type, text, name, " segmentInsert.prepare(
"tool_call_id, arguments, result, status, elapsed_ms, " "INSERT INTO segments (generation_id, type, text, name, "
"timestamp) VALUES (:gid, :type, :text, :name, " "tool_call_id, arguments, result, status, elapsed_ms, "
":tool_call_id, :arguments, :result, :status, :elapsed_ms, " "timestamp) VALUES (:gid, :type, :text, :name, "
":timestamp)"); ":tool_call_id, :arguments, :result, :status, :elapsed_ms, "
// The model holds messages most recent first; the database keeps ":timestamp)");
// natural rowid order, so iterate from the oldest row up.
const auto* model = session->messagesModel(); const auto* model = session->messagesModel();
for (int row = model->rowCount() - 1; ok && row >= 0; --row) { for (int row = model->rowCount() - 1; ok && row >= 0; --row) {
const auto* message = model->at(row); const auto* message = model->at(row);
@@ -327,8 +343,8 @@ bool ChatStore::saveSession(ChatSession* session) {
if (!messageInsert.exec()) { if (!messageInsert.exec()) {
ok = false; ok = false;
qWarning() << "ChatStore: saveSession" << id qWarning() << "ChatStore: saveSession" << id
<< "message insert failed:" << "message insert failed:"
<< messageInsert.lastError().text(); << messageInsert.lastError().text();
break; break;
} }
const int messageId = messageInsert.lastInsertId().toInt(); const int messageId = messageInsert.lastInsertId().toInt();
@@ -343,8 +359,8 @@ bool ChatStore::saveSession(ChatSession* session) {
if (!generationInsert.exec()) { if (!generationInsert.exec()) {
ok = false; ok = false;
qWarning() << "ChatStore: saveSession" << id qWarning() << "ChatStore: saveSession" << id
<< "generation insert failed:" << "generation insert failed:"
<< generationInsert.lastError().text(); << generationInsert.lastError().text();
break; break;
} }
const int generationId = const int generationId =
@@ -359,18 +375,17 @@ bool ChatStore::saveSession(ChatSession* session) {
":tool_call_id", sqlText(segment->toolCallId())); ":tool_call_id", sqlText(segment->toolCallId()));
segmentInsert.bindValue( segmentInsert.bindValue(
":arguments", sqlText(segment->arguments())); ":arguments", sqlText(segment->arguments()));
segmentInsert.bindValue(":result", sqlText(segment->result())); segmentInsert.bindValue(
":result", sqlText(segment->result()));
segmentInsert.bindValue( segmentInsert.bindValue(
":status", static_cast<int>(segment->status())); ":status", static_cast<int>(segment->status()));
segmentInsert.bindValue( segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs());
":elapsed_ms", segment->elapsedMs()); segmentInsert.bindValue(":timestamp", segment->timestamp());
segmentInsert.bindValue(
":timestamp", segment->timestamp());
if (!segmentInsert.exec()) { if (!segmentInsert.exec()) {
ok = false; ok = false;
qWarning() << "ChatStore: saveSession" << id qWarning() << "ChatStore: saveSession" << id
<< "segment insert failed:" << "segment insert failed:"
<< segmentInsert.lastError().text(); << segmentInsert.lastError().text();
break; break;
} }
} }
@@ -378,98 +393,183 @@ bool ChatStore::saveSession(ChatSession* session) {
} }
} }
if (!ok || !handle.commit()) { if (!ok || !handle.commit()) {
qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back"; qWarning() << "ChatStore: saveSession" << id
<< "commit failed, rolling back";
handle.rollback(); handle.rollback();
ok = false; ok = false;
qWarning() << "ChatStore: failed to save session" << session->id() << ":" qWarning() << "ChatStore: failed to save session" << session->id()
<< handle.lastError().text(); << ":" << handle.lastError().text();
} }
return ok; return ok;
} }
void ChatStore::loadMessagesInto(ChatSession* session) { void ChatStore::loadMessagesInto(ChatSession* session) {
// Newest first so the model receives rows in display order. if (!session) return;
QSqlQuery query(db()); const QString sessionId = session->id();
query.prepare( const QString path = m_dbPath;
"SELECT id, role, timestamp FROM messages WHERE session_id = :id "
"ORDER BY rowid DESC"); QThreadPool::globalInstance()->start([store = QPointer<ChatStore>(this),
query.bindValue(":id", session->id()); session =
if (!query.exec()) { QPointer<ChatSession>(session),
qWarning() << "ChatStore: failed to load messages for" << session->id() sessionId,
<< ":" << query.lastError().text(); path]() {
return; QList<MessageRow> rows;
} const QString connName = QUuid::createUuid().toString();
auto* model = session->messagesModel(); {
QList<ChatMessage*> messages; QSqlDatabase db =
while (query.next()) { QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName);
const int messageId = query.value(0).toInt(); db.setDatabaseName(path);
auto* message = model->createMessage( if (db.open()) {
query.value(1).toString() == QLatin1String("user") QSqlQuery busy(db);
? ChatMessage::Role::User busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
: ChatMessage::Role::Assistant, QSqlQuery query(db);
query.value(2).toLongLong()); query.prepare(
QSqlQuery generationQuery(db()); "SELECT id, role, timestamp FROM messages "
generationQuery.prepare( "WHERE session_id = :id ORDER BY rowid DESC");
"SELECT id, timestamp, is_active FROM generations " query.bindValue(":id", sessionId);
"WHERE message_id = :mid ORDER BY rowid"); if (!query.exec()) {
generationQuery.bindValue(":mid", messageId); qWarning() << "ChatStore: failed to load messages for"
int activeIndex = 0; << sessionId << ":" << query.lastError().text();
if (generationQuery.exec()) {
int index = 0;
while (generationQuery.next()) {
auto* generation = message->addGeneration(
generationQuery.value(1).toLongLong());
QSqlQuery segmentQuery(db());
segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, arguments, "
"result, status, elapsed_ms, timestamp FROM segments "
"WHERE generation_id = :gid ORDER BY rowid");
segmentQuery.bindValue(
":gid", generationQuery.value(0).toInt());
if (segmentQuery.exec()) {
while (segmentQuery.next()) {
auto* segment = new LlmSegment(
segmentTypeFromName(
segmentQuery.value(0).toString()),
segmentQuery.value(8).toLongLong(),
generation);
segment->setText(
segmentQuery.value(1).toString());
segment->setName(
segmentQuery.value(2).toString());
segment->setToolCallId(
segmentQuery.value(3).toString());
segment->appendArguments(
segmentQuery.value(4).toString());
segment->setResult(
segmentQuery.value(5).toString());
segment->setStatus(
static_cast<LlmSegment::Status>(
segmentQuery.value(6).toInt()));
segment->restore(
segmentQuery.value(7).toLongLong());
generation->addSegment(segment);
}
} else { } else {
qWarning() << "ChatStore: failed to load segments for " while (query.next()) {
<< "generation" const int messageId = query.value(0).toInt();
<< generationQuery.value(0).toInt() MessageRow message;
<< ":" message.user = query.value(1).toString() ==
<< segmentQuery.lastError().text(); QLatin1String("user");
message.timestamp = query.value(2).toLongLong();
QSqlQuery generationQuery(db);
generationQuery.prepare(
"SELECT id, timestamp, is_active FROM "
"generations WHERE message_id = :mid "
"ORDER BY rowid");
generationQuery.bindValue(":mid", messageId);
if (generationQuery.exec()) {
while (generationQuery.next()) {
GenerationRow generation;
generation.timestamp =
generationQuery.value(1).toLongLong();
generation.active =
generationQuery.value(2).toInt() != 0;
QSqlQuery segmentQuery(db);
segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, "
"arguments, result, status, elapsed_ms, "
"timestamp FROM segments WHERE "
"generation_id = :gid ORDER BY rowid");
segmentQuery.bindValue(
":gid", generationQuery.value(0).toInt());
if (segmentQuery.exec()) {
while (segmentQuery.next()) {
SegmentRow segment;
segment.type =
segmentQuery.value(0).toString();
segment.text =
segmentQuery.value(1).toString();
segment.name =
segmentQuery.value(2).toString();
segment.toolCallId =
segmentQuery.value(3).toString();
segment.arguments =
segmentQuery.value(4).toString();
segment.result =
segmentQuery.value(5).toString();
segment.status =
segmentQuery.value(6).toInt();
segment.elapsedMs =
segmentQuery.value(7).toLongLong();
segment.timestamp =
segmentQuery.value(8).toLongLong();
generation.segments.append(segment);
}
} else {
qWarning()
<< "ChatStore: failed to load "
"segments for generation"
<< generationQuery.value(0).toInt()
<< ":"
<< segmentQuery.lastError().text();
}
message.generations.append(generation);
}
} else {
qWarning()
<< "ChatStore: failed to load generations "
"for message"
<< messageId << ":"
<< generationQuery.lastError().text();
}
rows.append(message);
}
} }
if (generationQuery.value(2).toInt() != 0) db.close();
activeIndex = index; } else {
++index; qWarning() << "ChatStore: failed to open database for load:"
<< db.lastError().text();
} }
} else {
qWarning() << "ChatStore: failed to load generations for message"
<< messageId << ":"
<< generationQuery.lastError().text();
} }
message->setActiveGeneration(activeIndex); QSqlDatabase::removeDatabase(connName);
messages.append(message);
} QMetaObject::invokeMethod(
session->adoptMessages(messages); QCoreApplication::instance(),
[store, session, rows = std::move(rows)]() mutable {
ChatStore* st = store;
ChatSession* s = session;
if (!st || !s) return;
auto* model = s->model();
if (!model) return;
QList<ChatMessage*> messages;
for (const MessageRow& row : rows) {
auto* message = model->createMessage(
row.user ? ChatMessage::Role::User
: ChatMessage::Role::Assistant,
row.timestamp);
int activeIndex = 0;
for (int i = 0; i < row.generations.size(); ++i) {
const GenerationRow& generationRow =
row.generations.at(i);
auto* generation =
message->addGeneration(generationRow.timestamp);
for (const SegmentRow& segmentRow :
generationRow.segments) {
auto* segment = new LlmSegment(
segmentTypeFromName(segmentRow.type),
segmentRow.timestamp,
generation);
segment->setText(segmentRow.text);
segment->setName(segmentRow.name);
segment->setToolCallId(segmentRow.toolCallId);
segment->appendArguments(segmentRow.arguments);
segment->setResult(segmentRow.result);
segment->setStatus(
static_cast<LlmSegment::Status>(
segmentRow.status));
segment->restore(segmentRow.elapsedMs);
generation->addSegment(segment);
}
if (generationRow.active) activeIndex = i;
}
message->setActiveGeneration(activeIndex);
messages.append(message);
}
if (model->rowCount() > 0) {
QList<ChatMessage*> live = messages;
for (int r = 0; r < model->rowCount(); ++r)
live.prepend(model->at(r));
messages = live;
}
if (!messages.isEmpty() || model->rowCount() > 0)
s->adoptMessages(messages);
s->markLoaded();
if (s->takeClearPending()) {
s->clear();
} else if (st->m_pendingPersists.remove(s)) {
st->persist(s);
}
},
Qt::QueuedConnection);
});
} }
void ChatStore::load() { void ChatStore::load() {
@@ -498,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
+3
View File
@@ -3,6 +3,7 @@
#include "session.hpp" #include "session.hpp"
#include <QObject> #include <QObject>
#include <QSet>
#include <QSqlDatabase> #include <QSqlDatabase>
#include <QString> #include <QString>
#include <QVariantList> #include <QVariantList>
@@ -56,6 +57,8 @@ class ChatStore : public QObject {
QList<ChatSession*> m_sessions; QList<ChatSession*> m_sessions;
LlmClient* m_llmClient = nullptr; LlmClient* m_llmClient = nullptr;
QString m_connectionName; QString m_connectionName;
QString m_dbPath;
QSet<ChatSession*> m_pendingPersists;
[[nodiscard]] QSqlDatabase db() const; [[nodiscard]] QSqlDatabase db() const;
}; };
+224 -186
View File
@@ -4,17 +4,23 @@
#include <tree_sitter/api.h> #include <tree_sitter/api.h>
#include <QCoreApplication>
#include <QDir>
#include <QHash> #include <QHash>
#include <QMap> #include <QMap>
#include <QMutexLocker>
#include <QPointer>
#include <QStringList>
#include <QThreadPool>
#include <QVariantMap> #include <QVariantMap>
#include <cstring>
#include <dlfcn.h> #include <dlfcn.h>
namespace ZShell::llm { 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,
@@ -36,98 +42,56 @@ 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* (*)();
// Query sources, indexed by Grammar::queries.
struct QuerySources {
std::vector<std::string> sources;
int c, cpp, python, javascript, typescriptExtended, typescript, bash, json, rust, go, yaml, toml, sql, cppExtended;
};
const QuerySources& querySources() {
static const QuerySources sources = [] {
QuerySources qs;
// javascript + typescript concatenated: the TS grammar reuses the
// JS node names, so the JS query usually compiles against it and
// gives full coverage; the TS-only query is the fallback.
const std::string tsExtended =
std::string(hq::javascript) + "\n" + std::string(hq::typescript);
// Same for cpp: the C++ grammar is a superset of C, and the C++
// query only covers the C++-specific delta. Base C coverage
// (keywords, types, calls, strings, comments) comes from the C
// query.
const std::string cppExtended =
std::string(hq::c) + "\n" + std::string(hq::cpp);
qs.sources.reserve(14);
qs.sources.push_back(hq::c);
qs.sources.push_back(hq::cpp);
qs.sources.push_back(hq::python);
qs.sources.push_back(hq::javascript);
qs.sources.push_back(tsExtended);
qs.sources.push_back(hq::typescript);
qs.sources.push_back(hq::bash);
qs.sources.push_back(hq::json);
qs.sources.push_back(hq::rust);
qs.sources.push_back(hq::go);
qs.sources.push_back(hq::yaml);
qs.sources.push_back(hq::toml);
qs.sources.push_back(hq::sql);
qs.sources.push_back(cppExtended);
qs.c = 0;
qs.cpp = 1;
qs.python = 2;
qs.javascript = 3;
qs.typescriptExtended = 4;
qs.typescript = 5;
qs.bash = 6;
qs.json = 7;
qs.rust = 8;
qs.go = 9;
qs.yaml = 10;
qs.toml = 11;
qs.sql = 12;
qs.cppExtended = 13;
return qs;
}();
return sources;
}
const QHash<QString, CodeHighlighter::Grammar>& grammars() { const QHash<QString, CodeHighlighter::Grammar>& grammars() {
static const QHash<QString, CodeHighlighter::Grammar> grammars = [] { static const QHash<QString, CodeHighlighter::Grammar> grammars = [] {
const auto& qs = querySources();
QHash<QString, CodeHighlighter::Grammar> map; QHash<QString, CodeHighlighter::Grammar> map;
map.insert("c", {"libtree-sitter-c.so", "tree_sitter_c", {qs.c}}); for (const auto& g : hq::grammars) {
map.insert("cpp", {"libtree-sitter-cpp.so", "tree_sitter_cpp", {qs.cppExtended, qs.cpp}}); CodeHighlighter::Grammar grammar;
map.insert("python", {"libtree-sitter-python.so", "tree_sitter_python", {qs.python}}); for (int i = 0; i < g.nCandidates; ++i) {
map.insert("javascript", {"libtree-sitter-javascript.so", "tree_sitter_javascript", {qs.javascript}}); grammar.libs.push_back(g.candidates[i].lib);
map.insert("typescript", {"libtree-sitter-typescript.so", "tree_sitter_typescript", {qs.typescriptExtended, qs.typescript}}); grammar.symbols.push_back(g.candidates[i].symbol);
map.insert("tsx", {"libtree-sitter-tsx.so", "tree_sitter_tsx", {qs.typescriptExtended, qs.typescript}}); }
map.insert("bash", {"libtree-sitter-bash.so", "tree_sitter_bash", {qs.bash}}); for (int i = 0; i < g.nQueries; ++i)
map.insert("json", {"libtree-sitter-json.so", "tree_sitter_json", {qs.json}}); grammar.queries.push_back(g.queries[i]);
map.insert("rust", {"libtree-sitter-rust.so", "tree_sitter_rust", {qs.rust}}); map.insert(g.id, std::move(grammar));
map.insert("go", {"libtree-sitter-go.so", "tree_sitter_go", {qs.go}}); }
map.insert("yaml", {"libtree-sitter-yaml.so", "tree_sitter_yaml", {qs.yaml}});
map.insert("toml", {"libtree-sitter-toml.so", "tree_sitter_toml", {qs.toml}});
map.insert("sql", {"libtree-sitter-sql.so", "tree_sitter_sql", {qs.sql}});
return map; return map;
}(); }();
return grammars; return grammars;
@@ -138,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");
@@ -170,6 +132,8 @@ const QHash<QString, QString>& CodeHighlighter::aliases() {
map.insert("shell-session", "bash"); map.insert("shell-session", "bash");
map.insert("zsh", "bash"); map.insert("zsh", "bash");
map.insert("console", "bash"); map.insert("console", "bash");
map.insert("qml", "qmljs");
map.insert("qmljs", "qmljs");
map.insert("json", "json"); map.insert("json", "json");
map.insert("jsonc", "json"); map.insert("jsonc", "json");
map.insert("rust", "rust"); map.insert("rust", "rust");
@@ -190,44 +154,40 @@ const QHash<QString, QString>& CodeHighlighter::aliases() {
return aliases; return aliases;
} }
const std::vector<std::string>& CodeHighlighter::querySources() const {
return hl::querySources().sources;
}
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.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 == "namespace" || n == "module") if (n.startsWith("function") || n == "constructor" ||
return hl::Role::Type; n.startsWith("support.function"))
if (n.startsWith("function") || n == "constructor")
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."))
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;
if (n == "tag" || (n.startsWith("tag.") && n != "tag.delimiter"))
return hl::Role::Keyword;
if (n.startsWith("variable")) return hl::Role::Constant;
if (n.startsWith("support")) return hl::Role::Function;
return hl::Role::None; return hl::Role::None;
} }
@@ -235,75 +195,160 @@ const char* CodeHighlighter::roleName(uint8_t role) {
return hl::roleName(static_cast<hl::Role>(role)); return hl::roleName(static_cast<hl::Role>(role));
} }
QVariantList CodeHighlighter::highlight(const QString& code, const QString& language) const { 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(
const QString& code, const QString& language, QObject* target, int token) {
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);
storeSpans(code, language, spans);
QPointer<QObject> guard(target);
QMetaObject::invokeMethod(
QCoreApplication::instance(),
[guard, token, spans]() {
if (!guard) return;
QMetaObject::invokeMethod(
guard,
"onHighlightSpans",
Q_ARG(QVariant, token),
Q_ARG(QVariant, spans));
},
Qt::QueuedConnection);
});
}
QVariantList CodeHighlighter::doHighlight(
const QString& code, const QString& language) const {
QVariantList spans; QVariantList spans;
if (code.isEmpty()) if (code.isEmpty()) return spans;
return spans;
const QString id = aliases().value(language.trimmed().toLower()); const QString id = resolveId(language);
if (id.isEmpty())
return spans;
const Grammar& grammar = hl::grammars().value(id); const Grammar& grammar = hl::grammars().value(id);
if (grammar.libs.empty()) return spans;
// Guard against pathological blocks; highlighting is best-effort.
static constexpr size_t kMaxBytes = 512 * 1024; 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;
State& state = m_states[id]; const TSLanguage* lang = nullptr;
if (state.bad) TSQuery* query = nullptr;
return spans; {
QMutexLocker locker(&m_stateMutex);
if (!state.lang) { auto& state = m_states[id];
// Missing library is retriable (it may be installed while the if (!state || (!state->lang && !state->bad)) {
// shell runs); ABI/query failures below are not. std::shared_ptr<State> fresh = std::make_shared<State>();
state.lib = dlopen(grammar.lib.toUtf8().constData(), RTLD_NOW | RTLD_LOCAL); bool abiMismatch = false;
if (!state.lib) for (size_t i = 0; i < grammar.libs.size(); ++i) {
return spans; void* lib =
auto* symbol = reinterpret_cast<hl::LanguageFn>( dlopen(grammar.libs[i].c_str(), RTLD_NOW | RTLD_LOCAL);
dlsym(state.lib, grammar.symbol.toUtf8().constData())); if (!lib) continue;
if (!symbol) { auto* symbol = reinterpret_cast<hl::LanguageFn>(
dlclose(state.lib); dlsym(lib, grammar.symbols[i].c_str()));
state.lib = nullptr; if (!symbol) {
return spans; dlclose(lib);
} continue;
const TSLanguage* lang = symbol(); }
const uint32_t version = ts_language_abi_version(lang); const TSLanguage* candidate = symbol();
if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION || const uint32_t version = ts_language_abi_version(candidate);
version > TREE_SITTER_LANGUAGE_VERSION) { if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION ||
state.bad = true; version > TREE_SITTER_LANGUAGE_VERSION) {
return spans; dlclose(lib);
} abiMismatch = true;
state.lang = lang; continue;
} }
fresh->lib = lib;
if (!state.query) { fresh->lang = candidate;
// Candidates in priority order; first that compiles wins. break;
for (const int candidate : grammar.queries) { }
const std::string& source = if (fresh->lang) {
querySources()[static_cast<size_t>(candidate)]; for (const char* source : grammar.queries) {
TSQueryError errorType = TSQueryErrorNone; TSQueryError errorType = TSQueryErrorNone;
uint32_t errorOffset = 0; uint32_t errorOffset = 0;
TSQuery* query = ts_query_new( TSQuery* candidate = ts_query_new(
static_cast<const TSLanguage*>(state.lang), static_cast<const TSLanguage*>(fresh->lang),
source.data(), source,
static_cast<uint32_t>(source.size()), static_cast<uint32_t>(std::strlen(source)),
&errorOffset, &errorOffset,
&errorType); &errorType);
if (!query) if (!candidate) continue;
continue; fresh->query = candidate;
state.query = query; break;
break; }
} if (!fresh->query) fresh->bad = true;
if (!state.query) { } else if (abiMismatch) {
state.bad = true; fresh->bad = true;
return spans; }
if (fresh->lang || fresh->bad) state = std::move(fresh);
} }
if (!state || state->bad || !state->lang) return spans;
lang = static_cast<const TSLanguage*>(state->lang);
query = static_cast<TSQuery*>(state->query);
} }
TSParser* parser = ts_parser_new(); TSParser* parser = ts_parser_new();
ts_parser_set_language(parser, static_cast<const TSLanguage*>(state.lang)); ts_parser_set_language(parser, lang);
TSTree* tree = ts_parser_parse_string( TSTree* tree = ts_parser_parse_string(
parser, nullptr, utf8.constData(), static_cast<uint32_t>(utf8.size())); parser, nullptr, utf8.constData(), static_cast<uint32_t>(utf8.size()));
if (!tree) { if (!tree) {
@@ -311,17 +356,12 @@ QVariantList CodeHighlighter::highlight(const QString& code, const QString& lang
return spans; return spans;
} }
TSQuery* query = static_cast<TSQuery*>(state.query);
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];
@@ -331,9 +371,9 @@ QVariantList CodeHighlighter::highlight(const QString& code, const QString& lang
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;
@@ -341,14 +381,13 @@ QVariantList CodeHighlighter::highlight(const QString& code, const QString& lang
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);
} }
@@ -376,8 +415,7 @@ QVariantList CodeHighlighter::highlight(const QString& code, const QString& lang
} }
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;
} }
+35 -29
View File
@@ -1,11 +1,13 @@
#pragma once #pragma once
#include <QMutex>
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QVariantList> #include <QVariantList>
#include <QtQml> #include <QtQml>
#include <cstdint> #include <cstdint>
#include <memory>
#include <vector> #include <vector>
class QQmlEngine; class QQmlEngine;
@@ -13,56 +15,60 @@ 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
// (libtree-sitter-<lang>.so) are dlopen()'d lazily, so a missing
// grammar package degrades that language to plain text instead of
// breaking the build or the app. Highlight queries are embedded at
// build time (highlight-queries/*.scm, vendored from the grammar
// repos, MIT).
//
// The fence language the LLM wrote (```cpp, ```python, ...) is mapped
// to a grammar through an alias table.
//
// highlight() returns a list of span maps:
// { "start": int, "length": int, "kind": QString }
// where kind is a semantic role (keyword, string, comment, number,
// function, type, ...) that QML maps to theme colors. An empty list
// means "no highlighting" (unknown language or grammar not installed).
class CodeHighlighter : public QObject { class CodeHighlighter : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
QML_SINGLETON QML_SINGLETON
public: public:
Q_INVOKABLE QVariantList highlight(const QString& code, const QString& language) const; Q_INVOKABLE void highlight(
const QString& code,
const QString& language,
QObject* target,
int token);
static CodeHighlighter* create(QQmlEngine*, QJSEngine*); static CodeHighlighter* create(QQmlEngine*, QJSEngine*);
struct Grammar { struct Grammar {
QString lib; // soname to dlopen std::vector<std::string> libs;
QString symbol; // tree_sitter_<lang>() entry point std::vector<std::string> symbols;
// Candidate query sources, first wins. Lets typescript reuse std::vector<const char*> queries;
// the javascript query when it compiles against the grammar.
std::vector<int> queries; // indices into querySources()
}; };
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();
[[nodiscard]] const std::vector<std::string>& querySources() const;
// Maps a tree-sitter capture name to a role index (0 = unstyled).
[[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length); [[nodiscard]] static 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);
[[nodiscard]] static QString resolveId(const QString& language);
[[nodiscard]] static QString cacheKey(
const QString& id, const QString& code);
[[nodiscard]] QVariantList doHighlight(
const QString& code, const QString& language) const;
[[nodiscard]] QVariantList lookupSpans(
const QString& code, const QString& language) const;
void storeSpans(
const QString& code,
const QString& language,
const QVariantList& spans) const;
mutable QHash<QString, State> m_states; struct SpanCacheEntry {
QString code;
QVariantList spans;
};
mutable QHash<QString, std::shared_ptr<const State>> m_states;
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

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