47 Commits
Author SHA1 Message Date
zach d3345906b9 Merge pull request 'Feat: LLM Chat' (#155) from feat/llm-chat-tab into main
Reviewed-on: #155
2026-10-18 17:05:05 +02:00
zach 08d42e1f8a chore: formatter & clangd lsp option
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) Successful in 7s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 27s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Successful in 1m40s
Rust / fmt (pull_request) Successful in 28s
Rust / clippy (pull_request) Successful in 45s
2026-10-18 15:23:40 +02:00
zach 45aa6c9005 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 20s
C++ / clang-tidy (pull_request) Failing after 12s
JS/TS / fmt (pull_request) Successful in 6s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 48s
Rust / fmt (pull_request) Successful in 28s
Python / verify (pull_request) Successful in 1m45s
Rust / clippy (pull_request) Successful in 46s
2026-10-18 14:43:00 +02:00
zach 1763b03287 Merge pull request 'Chore/remove nix support' (#159) from chore/remove-nix-support into main
Reviewed-on: #159
2026-10-18 14:42:50 +02:00
zach 72ff6ad42a Merge branch 'main' into chore/remove-nix-support
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 9s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 29s
Rust / clippy (pull_request) Successful in 55s
2026-10-18 14:42:43 +02:00
zach 2a45740020 Merge pull request 'fix(pr-template): adds an "Other" issue type' (#160) from fix/ci-pr-template into main
Reviewed-on: #160
2026-10-18 14:42:35 +02:00
zach 0a503bc27c fix: remove unimplemented method
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 27s
Rust / build (pull_request) Successful in 49s
Rust / fmt (pull_request) Successful in 28s
Python / verify (pull_request) Successful in 1m41s
Rust / clippy (pull_request) Successful in 45s
2026-10-18 13:03:25 +02:00
zach 1fe8a3a959 chore: restructure llm plugin to be more coherent & fix various bugs in llm ui
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 36s
Rust / clippy (pull_request) Successful in 54s
2026-10-18 12:58:38 +02:00
AramJonghu ae25f33fc8 fix(pr-template): adds an "Other" issue type
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
Rust / fmt (pull_request) Successful in 48s
Rust / build (pull_request) Successful in 1m9s
Rust / clippy (pull_request) Successful in 52s
Python / static (pull_request) Successful in 55s
JS/TS / fmt (pull_request) Successful in 11s
JS/TS / lint (pull_request) Successful in 9s
Python / verify (pull_request) Successful in 1m26s
2026-10-14 00:22:33 +02:00
AramJonghu 02267ec9a6 docs(readme): adjust Nix support documentation
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 27s
Rust / build (pull_request) Successful in 50s
Rust / fmt (pull_request) Successful in 32s
Python / verify (pull_request) Successful in 2m16s
C++ / fmt (pull_request) Failing after 7s
C++ / build (pull_request) Failing after 12s
Rust / clippy (pull_request) Successful in 50s
C++ / clang-tidy (pull_request) Failing after 26s
2026-10-14 00:08:17 +02:00
AramJonghu 4b8d74a7e5 ci(cleanup): removed NixOS from issue template
Avoids confusion
2026-10-14 00:08:17 +02:00
AramJonghu 1fa3c84a56 chore(nix): remove all .nix files and flake.lock 2026-10-14 00:08:17 +02:00
zach fc81be922f Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 48s
Python / verify (pull_request) Successful in 1m38s
Rust / fmt (pull_request) Successful in 28s
Rust / clippy (pull_request) Successful in 46s
2026-10-12 16:06:23 +02:00
zach 8d54e1ce6f fix: undo accidental debug code 2026-10-12 16:05:52 +02:00
zach bee8cd061b Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 7s
C++ / build (pull_request) Failing after 11s
C++ / clang-tidy (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 48s
Python / verify (pull_request) Successful in 1m39s
Rust / fmt (pull_request) Successful in 28s
Rust / clippy (pull_request) Successful in 46s
2026-10-12 15:58:33 +02:00
zach b9e818faa9 fix: add proper atRestOverride for notification background 2026-10-12 15:58:25 +02:00
zach 2cabd72fc0 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 12s
C++ / clang-tidy (pull_request) Failing after 12s
JS/TS / fmt (pull_request) Successful in 8s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 31s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m32s
Rust / fmt (pull_request) Successful in 51s
Rust / clippy (pull_request) Successful in 1m1s
2026-10-12 15:54:52 +02:00
zach 255db9e533 fix: ignore stale coordinate data for panels + convert wavyline to use new qt 6.12 canvas painter api 2026-10-12 15:54:32 +02:00
zach a4f6aa1d4a Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 16s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 14s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 29s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Successful in 1m37s
Rust / fmt (pull_request) Successful in 31s
Rust / clippy (pull_request) Successful in 49s
2026-10-11 08:42:22 +02:00
zach 3883917228 fix: ui no longer blocks when volumes are unreachable + add support for network and fuse volumes 2026-10-11 08:42:05 +02:00
zach bf6a7027e4 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 7s
C++ / build (pull_request) Failing after 13s
C++ / clang-tidy (pull_request) Failing after 12s
JS/TS / fmt (pull_request) Successful in 9s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 27s
Rust / build (pull_request) Successful in 49s
Rust / fmt (pull_request) Successful in 28s
Python / verify (pull_request) Successful in 1m40s
Rust / clippy (pull_request) Successful in 47s
2026-10-08 12:09:59 +02:00
zach 873b2eb01b fix: dashboard tabs scroll now only loads previous item + next item 2026-10-08 12:09:50 +02:00
zach d70fb61e4a gemini glow fixes + positioning for window view
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Successful in 1m38s
Rust / fmt (pull_request) Successful in 27s
Rust / clippy (pull_request) Successful in 53s
2026-10-08 12:08:48 +02:00
zach 4c410d638c Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 7s
C++ / build (pull_request) Failing after 21s
C++ / clang-tidy (pull_request) Failing after 12s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 29s
Rust / build (pull_request) Successful in 47s
Python / verify (pull_request) Successful in 1m33s
Rust / fmt (pull_request) Successful in 28s
Rust / clippy (pull_request) Successful in 49s
2026-10-07 22:39:19 +02:00
zach aef7cd5918 fix: qt 6.12 build 2026-10-07 22:38:49 +02:00
zach a13fa8442b fix: qt 6.12 build
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 22s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 54s
Python / verify (pull_request) Successful in 1m41s
Rust / fmt (pull_request) Successful in 28s
Rust / clippy (pull_request) Successful in 53s
2026-10-07 22:37:46 +02:00
zach 4d75b317cd revert rounding var
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 18s
C++ / clang-tidy (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 8s
JS/TS / lint (pull_request) Successful in 12s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 52s
Python / verify (pull_request) Successful in 1m38s
Rust / fmt (pull_request) Successful in 31s
Rust / clippy (pull_request) Successful in 1m0s
2026-10-07 13:33:54 +02:00
zach 76823eff79 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 29s
C++ / build (pull_request) Failing after 33s
C++ / clang-tidy (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 11s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 51s
Python / verify (pull_request) Successful in 1m36s
Rust / fmt (pull_request) Successful in 43s
Rust / clippy (pull_request) Successful in 1m4s
2026-10-07 13:32:49 +02:00
zach 909b5113fb fix: closed panels don't add resistance to bounce + use qtrendering for text 2026-10-07 13:32:37 +02:00
zach c06cbea854 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 8s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 48s
Python / verify (pull_request) Successful in 1m40s
Rust / fmt (pull_request) Successful in 25s
Rust / clippy (pull_request) Successful in 43s
2026-10-06 13:33:41 +02:00
zach 376595cd0b fix: don't use zoom value when rendering wallpaper 2026-10-06 13:30:27 +02:00
zach a00bbe50f2 add rounding var
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 8s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 48s
Python / verify (pull_request) Successful in 1m43s
Rust / fmt (pull_request) Successful in 25s
Rust / clippy (pull_request) Successful in 46s
2026-10-06 00:55:50 +02:00
zach 11e94fbb4b Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 47s
Python / verify (pull_request) Successful in 1m40s
Rust / fmt (pull_request) Successful in 29s
Rust / clippy (pull_request) Successful in 46s
2026-10-05 19:33:42 +02:00
zach 9f5e62f9c7 feat: use custom wayland layer-shell to render wallpaper to reduce vram 2026-10-05 19:31:15 +02:00
zach b91befa9f7 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 11s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Successful in 1m30s
Rust / fmt (pull_request) Successful in 30s
Rust / clippy (pull_request) Successful in 48s
2026-10-05 09:58:00 +02:00
zach f90e9bb1a0 fix: all apps icon size and suppress warnings 2026-10-05 09:57:47 +02:00
zach 99fdfbc465 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 4s
C++ / clang-tidy (pull_request) Failing after 10s
C++ / build (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 9s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 35s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Successful in 1m40s
Rust / fmt (pull_request) Successful in 28s
Rust / clippy (pull_request) Successful in 51s
2026-10-04 18:20:23 +02:00
zach 2c88b7abcb move picker to main panelwindow to reduce vram usage 2026-10-04 18:20:10 +02:00
zach e148a0f843 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 12s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 8s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 26s
Rust / build (pull_request) Successful in 48s
Rust / fmt (pull_request) Successful in 29s
Python / verify (pull_request) Successful in 1m40s
Rust / clippy (pull_request) Successful in 44s
2026-10-04 13:55:22 +02:00
zach f1918172c3 fix: merge two strokecanvas into one + better cursorShape and pointer handling
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 8s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 47s
Python / verify (pull_request) Successful in 1m34s
Rust / fmt (pull_request) Successful in 35s
Rust / clippy (pull_request) Successful in 1m0s
2026-10-04 13:53:21 +02:00
zach 5507b99fdc fix: merge two strokecanvas into one + better cursorShape and pointer handling 2026-10-04 13:48:50 +02:00
zach 506b69165d Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 16s
JS/TS / lint (pull_request) Successful in 10s
Python / static (pull_request) Successful in 35s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Successful in 1m39s
Rust / fmt (pull_request) Successful in 29s
Rust / clippy (pull_request) Successful in 51s
2026-10-03 20:26:19 +02:00
zach dc99d6677c remove layer usage to reduce vram consumption 2026-10-03 20:26:00 +02:00
zach add3beed59 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 9s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 48s
Python / verify (pull_request) Successful in 1m37s
Rust / fmt (pull_request) Successful in 26s
Rust / clippy (pull_request) Successful in 47s
2026-10-02 19:54:45 +02:00
zach fc672924d1 fix: settings gpu enum 2026-10-02 19:54:33 +02:00
zach 4b67cc1d88 Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 8s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 32s
Rust / build (pull_request) Successful in 51s
Rust / fmt (pull_request) Successful in 29s
Python / verify (pull_request) Successful in 1m55s
Rust / clippy (pull_request) Successful in 45s
2026-10-01 17:11:29 +02:00
zach 765b84a19c fix: settings drag gesture and dashboard popout regions 2026-10-01 17:11:20 +02:00
143 changed files with 7249 additions and 3773 deletions
+5
View File
@@ -0,0 +1,5 @@
CompileFlags:
Remove:
# GCC-only flags from the system's default spec that clang/clangd cannot
# parse. They appear in compile_commands.json but are not set by the project.
- -mno-direct-extern-access
-1
View File
@@ -38,7 +38,6 @@ body:
attributes: attributes:
label: OS label: OS
options: options:
- NixOS
- Arch - Arch
- Debian / Ubuntu - Debian / Ubuntu
- Fedora - Fedora
+1
View File
@@ -30,6 +30,7 @@ body:
- Refactor / style - Refactor / style
- CI / build / tooling - CI / build / tooling
- Documentation - Documentation
- Other (Describe in description box with header "Issue type")
validations: validations:
required: true required: true
+11 -2
View File
@@ -61,10 +61,12 @@ set(CMAKE_INSTALL_MESSAGE NEVER)
add_compile_options( add_compile_options(
-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wall -Wextra -Wpedantic -Wshadow -Wconversion
-Wold-style-cast -Wnull-dereference -Wdouble-promotion -Wnull-dereference -Wdouble-promotion
-Wformat=2 -Wfloat-equal -Woverloaded-virtual -Wformat=2 -Wfloat-equal
-Wsign-conversion -Wredundant-decls -Wswitch -Wsign-conversion -Wredundant-decls -Wswitch
-Wunreachable-code -Wunreachable-code
$<$<COMPILE_LANGUAGE:CXX>:-Wold-style-cast>
$<$<COMPILE_LANGUAGE:CXX>:-Woverloaded-virtual>
) )
@@ -188,4 +190,11 @@ if("m3shapes" IN_LIST ENABLE_MODULES)
if(TARGET m3shapesplugin) if(TARGET m3shapesplugin)
set_target_properties(m3shapesplugin PROPERTIES INSTALL_RPATH "$ORIGIN") set_target_properties(m3shapesplugin PROPERTIES INSTALL_RPATH "$ORIGIN")
endif() endif()
# Silence warnings that come from m3shapes files we do not manage.
foreach(m3shapes_target m3shapes_core m3shapes_morph m3shapes_shapes m3shapes m3shapesplugin)
if(TARGET ${m3shapes_target})
target_compile_options(${m3shapes_target} PRIVATE -Wno-shadow -Wno-float-equal)
endif()
endforeach()
endif() endif()
+5 -3
View File
@@ -16,11 +16,12 @@ Text {
color: Colors.palette.m3onSurface color: Colors.palette.m3onSurface
font: Tokens.font.body.small font: Tokens.font.body.small
linkColor: Colors.palette.m3onPrimaryFixedVariant linkColor: Colors.palette.m3onPrimaryFixedVariant
renderType: Text.NativeRendering renderType: Text.QtRendering
textFormat: Text.PlainText textFormat: Text.PlainText
Behavior on color { Behavior on color {
CAnim {} CAnim {
}
} }
Behavior on text { Behavior on text {
enabled: root.animate enabled: root.animate
@@ -32,7 +33,8 @@ Text {
type: Anim.FastEffects type: Anim.FastEffects
} }
PropertyAction {} PropertyAction {
}
TAnim { TAnim {
target: root target: root
+1
View File
@@ -9,5 +9,6 @@ CustomText {
property int grade: Colors.light ? 0 : -25 property int grade: Colors.light ? 0 : -25
property font fontStyle: Tokens.font.icon.small property font fontStyle: Tokens.font.icon.small
renderType: Text.NativeRendering
font: Tokens.font.icon.size(fontStyle.pointSize).weight(fontStyle.weight).vaxes(fontStyle.variableAxes).fill(fill.toFixed(1)).grade(grade).build() font: Tokens.font.icon.size(fontStyle.pointSize).weight(fontStyle.weight).vaxes(fontStyle.variableAxes).fill(fill.toFixed(1)).grade(grade).build()
} }
+13 -12
View File
@@ -124,7 +124,7 @@ MouseArea {
model: root.items model: root.items
CustomRect { CustomRect {
id: item id: menuItem
readonly property bool active: modelData === root?.active readonly property bool active: modelData === root?.active
required property int index required property int index
@@ -142,13 +142,13 @@ MouseArea {
} }
StateLayer { StateLayer {
color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
enabled: root.expanded enabled: root.expanded
onClicked: { onClicked: {
root.itemSelected(item.modelData); root.itemSelected(menuItem.modelData);
root.active = item.modelData; root.active = menuItem.modelData;
item.modelData.clicked(); menuItem.modelData.clicked();
root.expanded = false; root.expanded = false;
} }
} }
@@ -162,26 +162,27 @@ MouseArea {
MaterialIcon { MaterialIcon {
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant
text: item.modelData?.icon ?? "" text: menuItem.modelData?.icon ?? ""
visible: text !== ""
} }
CustomText { CustomText {
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true Layout.fillWidth: true
color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
text: item.modelData?.text ?? "" text: menuItem.modelData?.text ?? ""
} }
Loader { Loader {
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
active: item.modelData?.trailingIcon.length > 0 active: menuItem.modelData?.trailingIcon.length > 0
asynchronous: true asynchronous: true
visible: active visible: active
sourceComponent: MaterialIcon { sourceComponent: MaterialIcon {
color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant color: menuItem.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant
text: item.modelData.trailingIcon text: menuItem.modelData.trailingIcon
} }
} }
} }
+3
View File
@@ -34,4 +34,7 @@ PersistentProperties {
// Drawing state // Drawing state
property color penColor: "white" property color penColor: "white"
property int penWidth: 4 property int penWidth: 4
// Screenshot state
property bool screenshot
} }
+36 -39
View File
@@ -11,19 +11,18 @@ CustomMouseArea {
required property Item bar required property Item bar
required property real borderThickness required property real borderThickness
required property ScreenState screenState property bool dashboardShortcutActive
property point dragStart
required property Drawing drawing required property Drawing drawing
required property EdgeGeometry geometry required property EdgeGeometry geometry
property bool osdShortcutActive
required property Panels panels required property Panels panels
required property Wrapper popouts required property Wrapper popouts
required property ShellScreen screen required property ShellScreen screen
required property ScreenState screenState
property bool dashboardShortcutActive property bool setInitialPoint: false
property bool osdShortcutActive
property bool singleGestureTriggered: false property bool singleGestureTriggered: false
property bool utilitiesShortcutActive property bool utilitiesShortcutActive
property bool setInitialPoint: false
property point dragStart
function inBottomPanel(panel: Item, x: real, y: real): bool { function inBottomPanel(panel: Item, x: real, y: real): bool {
const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0));
@@ -60,39 +59,10 @@ CustomMouseArea {
return x >= panelX && x <= panelX + panel.width; return x >= panelX && x <= panelX + panel.width;
} }
anchors.fill: parent
acceptedButtons: bar.fullscreen ? Qt.NoButton : Qt.AllButtons acceptedButtons: bar.fullscreen ? Qt.NoButton : Qt.AllButtons
anchors.fill: parent
hoverEnabled: true hoverEnabled: true
onPressed: event => {
dragStart = Qt.point(event.x, event.y);
if (screenState.isDrawing) {
if (event.button & Qt.RightButton) {
drawing.content.clear();
return;
}
if (!inLeftPanel(panels.drawing, mouseX, mouseY))
drawing.content.beginStroke(event.x, event.y);
}
}
onPressedChanged: {
if (!pressed) {
if (screenState.isDrawing) {
setInitialPoint = false;
drawing.content.endStroke();
if (!inLeftPanel(panels.drawing, mouseX, mouseY))
drawing.content.showHover(mouseX, mouseY);
}
} else {
if (screenState.isDrawing) {
panels.drawing.collapse();
}
}
}
onContainsMouseChanged: { onContainsMouseChanged: {
if (!containsMouse) { if (!containsMouse) {
if (!osdShortcutActive) { if (!osdShortcutActive) {
@@ -111,7 +81,6 @@ CustomMouseArea {
screenState.dashboard = false; screenState.dashboard = false;
} }
} }
onPositionChanged: event => { onPositionChanged: event => {
const x = event.x; const x = event.x;
const y = event.y; const y = event.y;
@@ -144,7 +113,8 @@ CustomMouseArea {
if (pressed && dragStart.y >= screen.height - Config.bar.border && dragStart.x > screen.width / 5 && dragY < -Config.launcher.dragThreshold) if (pressed && dragStart.y >= screen.height - Config.bar.border && dragStart.x > screen.width / 5 && dragY < -Config.launcher.dragThreshold)
screenState.launcher = true; screenState.launcher = true;
const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboardWrapper, x, y); const entryAt = bar.entryAt(geometry.axisPos(x, y));
const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboardWrapper, x, y) && (!entryAt || entryAt === "activeWindow");
if (!dashboardShortcutActive) { if (!dashboardShortcutActive) {
screenState.dashboard = showDashboard; screenState.dashboard = showDashboard;
@@ -159,7 +129,7 @@ CustomMouseArea {
screenState.dashboard = false; screenState.dashboard = false;
} }
if (pressed && geometry.insetTop(borderThickness) > dragStart.y && (Config.dashboard.showOnHover || !inTopPanel(panels.dashboardWrapper, dragStart.x, dragStart.y))) { if (pressed && geometry.insetTop(borderThickness) > dragStart.y && !inTopPanel(panels.dashboardWrapper, dragStart.x, dragStart.y)) {
if (dragY > 20) { if (dragY > 20) {
screenState.settings = true; screenState.settings = true;
} else if (dragY < -20) { } else if (dragY < -20) {
@@ -212,6 +182,33 @@ CustomMouseArea {
if (geometry.barContains(x, y)) if (geometry.barContains(x, y))
bar.checkPopout(geometry.axisPos(x, y)); bar.checkPopout(geometry.axisPos(x, y));
} }
onPressed: event => {
dragStart = Qt.point(event.x, event.y);
if (screenState.isDrawing) {
if (event.button & Qt.RightButton) {
drawing.content.clear();
return;
}
if (!inLeftPanel(panels.drawing, mouseX, mouseY))
drawing.content.beginStroke(event.x, event.y);
}
}
onPressedChanged: {
if (!pressed) {
if (screenState.isDrawing) {
setInitialPoint = false;
drawing.content.endStroke();
if (!inLeftPanel(panels.drawing, mouseX, mouseY))
drawing.content.showHover(mouseX, mouseY);
}
} else {
if (screenState.isDrawing) {
panels.drawing.collapse();
}
}
}
Timer { Timer {
id: unhideTimer id: unhideTimer
+1 -1
View File
@@ -151,7 +151,7 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top anchors.top: parent.top
clip: true clip: offsetScale > 0
implicitHeight: dashboard.implicitHeight * (1 - offsetScale) implicitHeight: dashboard.implicitHeight * (1 - offsetScale)
implicitWidth: dashboard.implicitWidth implicitWidth: dashboard.implicitWidth
+43 -45
View File
@@ -52,15 +52,14 @@ CustomWindow {
property var root: Quickshell.shellDir property var root: Quickshell.shellDir
readonly property ScreenState screenState: ShellState.forScreen(screen) readonly property ScreenState screenState: ShellState.forScreen(screen)
readonly property real sdfBorderOffset: 2 * fsTransitionProg readonly property real sdfBorderOffset: 2 * fsTransitionProg
readonly property real shadowOpacity: 0.7 * (1 - (bar.isHovered ? 0 : fsTransitionProg)) readonly property real shadowOpacity: 0.5 * (1 - (bar.isHovered ? 0 : fsTransitionProg))
property color surfaceColor: Colors.tPalette.m3surface property color surfaceColor: Colors.tPalette.m3surface
WlrLayershell.exclusionMode: ExclusionMode.Ignore WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.keyboardFocus: screenState.launcher || screenState.settings || screenState.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None WlrLayershell.keyboardFocus: screenState.screenshot ? WlrKeyboardFocus.Exclusive : screenState.launcher || screenState.settings || screenState.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 mask: screenState.isDrawing || screenState.screenshot ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions)
mask: screenState.isDrawing ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions)
name: "Bar" name: "Bar"
Behavior on fsTransitionProg { Behavior on fsTransitionProg {
@@ -143,7 +142,6 @@ CustomWindow {
HyprlandFocusGrab { HyprlandFocusGrab {
id: focusGrab id: focusGrab
windows: [root]
active: { active: {
const s = root.screenState; const s = root.screenState;
const c = Config; const c = Config;
@@ -160,6 +158,7 @@ CustomWindow {
return true; return true;
return false; return false;
} }
windows: [root]
onCleared: { onCleared: {
root.screenState.launcher = false; root.screenState.launcher = false;
@@ -185,23 +184,19 @@ CustomWindow {
id: surface id: surface
anchors.fill: parent anchors.fill: parent
layer.enabled: true
opacity: root.surfaceColor.a opacity: root.surfaceColor.a
layer.effect: MultiEffect { BlobGroup2 {
blurMax: 32
shadowColor: Qt.alpha(Colors.palette.m3shadow, Math.max(0, root.shadowOpacity))
shadowEnabled: true
}
BlobGroup {
id: blobGroup id: blobGroup
anchors.fill: parent
color: root.surfaceColor color: root.surfaceColor
shadowColor: Qt.alpha(Colors.palette.m3shadow, Math.max(0, root.shadowOpacity))
shadowRadius: 18
smoothing: Config.bar.smoothing smoothing: Config.bar.smoothing
} }
BlobInvertedRect { BlobInvertedRect2 {
anchors.fill: parent anchors.fill: parent
anchors.margins: -50 anchors.margins: -50
borderBottom: geometry.insetBottom(root.borderThickness) - anchors.margins - root.sdfBorderOffset borderBottom: geometry.insetBottom(root.borderThickness) - anchors.margins - root.sdfBorderOffset
@@ -251,6 +246,7 @@ CustomWindow {
PanelBg { PanelBg {
id: osdBg id: osdBg
atRestOverride: panels.osd.offsetScale < 0.99
deformAmount: 0.1 deformAmount: 0.1
implicitHeight: panels.osd.height implicitHeight: panels.osd.height
implicitWidth: panels.osd.width implicitWidth: panels.osd.width
@@ -262,6 +258,7 @@ CustomWindow {
PanelBg { PanelBg {
id: notifsBg id: notifsBg
atRestOverride: panel.implicitHeight > 0
panel: panels.notifications panel: panels.notifications
radius: Tokens.rounding.large radius: Tokens.rounding.large
} }
@@ -296,13 +293,16 @@ CustomWindow {
PanelBg { PanelBg {
id: resourcesBg id: resourcesBg
property real extraExtent: 0.2
atRestOverride: panels.resources.offsetScale < 0.99
deformAmount: 0.05 deformAmount: 0.05
implicitHeight: panels.resources.height implicitHeight: panels.resources.height * (1 + extraExtent)
implicitWidth: panels.resources.width implicitWidth: panels.resources.width * (1 + extraExtent)
panel: panels.resourcesWrapper panel: panels.resourcesWrapper
radius: Tokens.rounding.largeIncreased + Tokens.padding.small radius: Tokens.rounding.largeIncreased + Tokens.padding.small
x: panels.resourcesWrapper.x + panels.resources.x + geometry.insetLeft(root.borderThickness) x: panels.resourcesWrapper.x + panels.resources.x + geometry.insetLeft(root.borderThickness) - panels.resources.width * extraExtent
y: panels.resourcesWrapper.y + panels.resources.y + geometry.insetTop(root.borderThickness) y: panels.resourcesWrapper.y + panels.resources.y + geometry.insetTop(root.borderThickness) - panels.resources.height * extraExtent
} }
PanelBg { PanelBg {
@@ -332,9 +332,15 @@ CustomWindow {
PanelBg { PanelBg {
id: clipboardBg id: clipboardBg
property real extraExtent: 0.2
deformAmount: 0.03 deformAmount: 0.03
implicitHeight: panels.clipboard.height * (1 + extraExtent)
implicitWidth: panels.clipboard.width * (1 + extraExtent)
panel: panels.clipboard panel: panels.clipboard
radius: 29 radius: 29
x: panels.clipboard.x + geometry.insetLeft(root.borderThickness) - panels.clipboard.width * extraExtent
y: panels.clipboard.y + geometry.insetTop(root.borderThickness)
} }
} }
@@ -342,34 +348,8 @@ CustomWindow {
id: drawing id: drawing
anchors.fill: parent anchors.fill: parent
layer.enabled: true
screenState: root.screenState screenState: root.screenState
z: 2 z: 2
layer.effect: MultiEffect {
maskEnabled: true
maskInverted: true
maskSource: maskSource
}
}
Item {
id: maskSource
anchors.fill: parent
layer.enabled: true
visible: false
CustomRect {
readonly property int extraWidth: radius
color: "white"
implicitHeight: panels.drawing.height
implicitWidth: panels.drawing.width + extraWidth
radius: drawingBg.radius
x: -extraWidth + root.borderThickness + panels.drawing.x
y: panels.drawing.y + bar.implicitHeight
}
} }
Interactions2 { Interactions2 {
@@ -449,10 +429,28 @@ CustomWindow {
} }
} }
component PanelBg: BlobRect { LazyLoader {
id: loader
property bool closing: !root.screenState.screenshot
onClosingChanged: if (!closing)
activeAsync = true
Picker {
loader: loader
parent: root.contentItem
screen: root.screen
screenState: root.screenState
}
}
component PanelBg: BlobRect2 {
property real deformAmount: 0.15 property real deformAmount: 0.15
required property Item panel required property Item panel
atRestOverride: panel?.offsetScale < 0.99 ?? false
damping: 28.3
deformScale: (deformAmount * Config.appearance.deform.scale) / 10000 deformScale: (deformAmount * Config.appearance.deform.scale) / 10000
group: blobGroup group: blobGroup
implicitHeight: panel.height implicitHeight: panel.height
-88
View File
@@ -1,88 +0,0 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Wayland
import Quickshell.Io
import Quickshell.Hyprland
import ZShell
import qs.Components
Scope {
LazyLoader {
id: root
property bool closing
property bool freeze
Variants {
model: Quickshell.screens
PanelWindow {
id: win
required property ShellScreen modelData
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.keyboardFocus: root.closing ? WlrKeyboardFocus.None : WlrKeyboardFocus.Exclusive
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "areapicker"
color: "transparent"
mask: root.closing ? empty : null
screen: modelData
anchors {
bottom: true
left: true
right: true
top: true
}
Region {
id: empty
}
Picker {
loader: root
screen: win.modelData
}
}
}
}
IpcHandler {
function open(): void {
root.freeze = false;
root.closing = false;
root.activeAsync = true;
}
function openFreeze(): void {
root.freeze = true;
root.closing = false;
root.activeAsync = true;
}
target: "picker"
}
CustomShortcut {
name: "screenshot"
onPressed: {
root.freeze = false;
root.closing = false;
root.activeAsync = true;
}
}
CustomShortcut {
name: "screenshotFreeze"
onPressed: {
root.freeze = true;
root.closing = false;
root.activeAsync = true;
}
}
}
+82 -36
View File
@@ -41,6 +41,7 @@ Item {
property real rsy: Math.min(sy, ey) property real rsy: Math.min(sy, ey)
readonly property real scaleRatio: Hypr.monitorFor(screen).scale readonly property real scaleRatio: Hypr.monitorFor(screen).scale
required property ShellScreen screen required property ShellScreen screen
required property ScreenState screenState
property real sh: Math.abs(sy - ey) property real sh: Math.abs(sy - ey)
readonly property color shadowColor: Hypr.options.decoration.shadow?.color ?? "black" readonly property color shadowColor: Hypr.options.decoration.shadow?.color ?? "black"
readonly property bool shadowEnabled: Hypr.options.decoration.shadow.enabled readonly property bool shadowEnabled: Hypr.options.decoration.shadow.enabled
@@ -97,10 +98,6 @@ Item {
const args = Config.screenshot.mode === "auto" ? ["--rounding", `${rounding}`, "--radius", root.cornerRadius, "--shadow", root.shadowEnabled, "--shadow-blur", `${shadow_blur}`, "--shadow-color", `${r},${g},${b},${a}`, "--shadow-offset-x", root.shadowOffset[0], "--shadow-offset-y", root.shadowOffset[1]] : []; const args = Config.screenshot.mode === "auto" ? ["--rounding", `${rounding}`, "--radius", root.cornerRadius, "--shadow", root.shadowEnabled, "--shadow-blur", `${shadow_blur}`, "--shadow-color", `${r},${g},${b},${a}`, "--shadow-offset-x", root.shadowOffset[0], "--shadow-offset-y", root.shadowOffset[1]] : [];
const postCmd = Config.screenshot.enable_pp ? ["zshell-img-tools", "--scale", root.scaleRatio, ...args, "--image"] : null; const postCmd = Config.screenshot.enable_pp ? ["zshell-img-tools", "--scale", root.scaleRatio, ...args, "--image"] : null;
overlay.visible = border.visible = false;
handles.visible = false;
toolbar.visible = false;
ZShellIo.saveItem(captureLayer, tmpfile, Qt.rect(Math.ceil(rsx), Math.ceil(rsy), Math.floor(sw), Math.floor(sh)), path => { ZShellIo.saveItem(captureLayer, tmpfile, Qt.rect(Math.ceil(rsx), Math.ceil(rsy), Math.floor(sw), Math.floor(sh)), path => {
const finalize = finalPath => { const finalize = finalPath => {
if (copyToClipboard) { if (copyToClipboard) {
@@ -129,22 +126,22 @@ Item {
Behavior on rsx { Behavior on rsx {
enabled: !selectArea.pressed && root.mode === "select" enabled: !selectArea.pressed && root.mode === "select"
ExAnim {} Anim {}
} }
Behavior on rsy { Behavior on rsy {
enabled: !selectArea.pressed && root.mode === "select" enabled: !selectArea.pressed && root.mode === "select"
ExAnim {} Anim {}
} }
Behavior on sh { Behavior on sh {
enabled: !selectArea.pressed && root.mode === "select" enabled: !selectArea.pressed && root.mode === "select"
ExAnim {} Anim {}
} }
Behavior on sw { Behavior on sw {
enabled: !selectArea.pressed && root.mode === "select" enabled: !selectArea.pressed && root.mode === "select"
ExAnim {} Anim {}
} }
Behavior on opacity { Behavior on opacity {
Anim { Anim {
@@ -154,7 +151,7 @@ Item {
Component.onCompleted: { Component.onCompleted: {
Hypr.extras.refreshOptions(); Hypr.extras.refreshOptions();
screencopy.active = true; root.forceActiveFocus();
opacity = 1; opacity = 1;
@@ -219,13 +216,63 @@ Item {
onReleased: root.enterEditMode() onReleased: root.enterEditMode()
} }
Connections {
function onClosingChanged(): void {
if (root.loader.closing)
closeAnim.start();
else {
closeAnim.stop();
returnAnim.start();
}
}
target: root.loader
}
ParallelAnimation {
id: returnAnim
Anim {
duration: 300
property: "opacity"
target: root
to: 1
}
Anim {
property: "rsx"
target: root
to: Math.min(root.sx, root.ex)
}
Anim {
property: "rsy"
target: root
to: Math.min(root.sy, root.ey)
}
Anim {
property: "sw"
target: root
to: Math.abs(root.sx - root.ex)
}
Anim {
property: "sh"
target: root
to: Math.abs(root.sy - root.ey)
}
}
SequentialAnimation { SequentialAnimation {
id: closeAnim id: closeAnim
PropertyAction { ScriptAction {
property: "closing" script: {
target: root.loader const states = ShellState.allStates();
value: true for (const s of states)
s.screenshot = false;
}
} }
ParallelAnimation { ParallelAnimation {
@@ -236,19 +283,19 @@ Item {
to: 0 to: 0
} }
ExAnim { Anim {
properties: "rsx,rsy" properties: "rsx,rsy"
target: root target: root
to: 0 to: 0
} }
ExAnim { Anim {
property: "sw" property: "sw"
target: root target: root
to: root.screen.width to: root.screen.width
} }
ExAnim { Anim {
property: "sh" property: "sh"
target: root target: root
to: root.screen.height to: root.screen.height
@@ -267,17 +314,12 @@ Item {
anchors.fill: parent anchors.fill: parent
Loader { ScreencopyView {
id: screencopy id: screencopy
active: false
anchors.fill: parent anchors.fill: parent
asynchronous: true captureSource: root.screen
paintCursor: false
sourceComponent: ScreencopyView {
captureSource: root.screen
paintCursor: false
}
} }
AnnotationCanvas { AnnotationCanvas {
@@ -286,6 +328,7 @@ Item {
anchors.fill: parent anchors.fill: parent
inkColor: root.inkColor inkColor: root.inkColor
layer.enabled: true layer.enabled: true
selection: selectionRect
tool: root.mode === "edit" && root.tool !== "move" ? root.tool : "none" tool: root.mode === "edit" && root.tool !== "move" ? root.tool : "none"
layer.effect: MultiEffect { layer.effect: MultiEffect {
@@ -297,15 +340,14 @@ Item {
} }
} }
Rectangle { CustomRect {
id: overlay id: overlay
anchors.fill: parent anchors.fill: parent
color: "white" color: "white"
layer.enabled: true layer.enabled: true
opacity: 0.3 opacity: 0.3
radius: root.realRounding visible: screencopy.hasContent
visible: screencopy.item?.hasContent ?? false
layer.effect: MultiEffect { layer.effect: MultiEffect {
maskEnabled: true maskEnabled: true
@@ -334,16 +376,15 @@ Item {
} }
} }
Rectangle { CustomRect {
id: border id: border
border.color: Colors.palette.m3primary border.color: Colors.palette.m3primary
border.width: root.realBorderWidth border.width: root.realBorderWidth
color: "transparent"
implicitHeight: selectionRect.implicitHeight + root.realBorderWidth * 2 implicitHeight: selectionRect.implicitHeight + root.realBorderWidth * 2
implicitWidth: selectionRect.implicitWidth + root.realBorderWidth * 2 implicitWidth: selectionRect.implicitWidth + root.realBorderWidth * 2
radius: root.realRounding > 0 ? root.realRounding + root.realBorderWidth : 0 radius: root.realRounding > 0 ? root.realRounding + root.realBorderWidth : 0
visible: screencopy.item?.hasContent ?? false visible: screencopy.hasContent
x: selectionRect.x - root.realBorderWidth x: selectionRect.x - root.realBorderWidth
y: selectionRect.y - root.realBorderWidth y: selectionRect.y - root.realBorderWidth
@@ -355,17 +396,24 @@ Item {
SelectionHandles { SelectionHandles {
id: handles id: handles
border: root.realBorderWidth
selH: root.sh selH: root.sh
selW: root.sw selW: root.sw
selX: root.rsx selX: root.rsx
selY: root.rsy selY: root.rsy
selection: selectionRect
tool: root.tool
visible: root.mode === "edit" && root.tool === "move" visible: root.mode === "edit" && root.tool === "move"
onBoundsChanged: (x, y, w, h) => { onBoundsChanged: (x, y, w, h) => {
root.sx = x; let nx = Math.max(0, x);
root.sy = y; let ny = Math.max(0, y);
root.ex = x + w; let nw = Math.min(w, root.screen.width - nx);
root.ey = y + h; let nh = Math.min(h, root.screen.height - ny);
root.sx = nx;
root.sy = ny;
root.ex = nx + nw;
root.ey = ny + nh;
} }
onMoveBy: (dx, dy) => { onMoveBy: (dx, dy) => {
const w = root.sw, h = root.sh; const w = root.sw, h = root.sh;
@@ -396,6 +444,4 @@ Item {
onToolSelected: t => root.tool = t onToolSelected: t => root.tool = t
onUndoRequested: annotations.undo() onUndoRequested: annotations.undo()
} }
component ExAnim: Anim {}
} }
+51 -56
View File
@@ -8,13 +8,13 @@ Item {
property color inkColor: "#ff3b30" property color inkColor: "#ff3b30"
property real inkWidth: 4 property real inkWidth: 4
required property Rectangle selection
property string tool: "none" property string tool: "none"
signal changed signal changed
function clearAll() { function clearAll() {
committedCanvas.clear(); canvas.clear();
previewCanvas.clear();
changed(); changed();
} }
@@ -54,90 +54,85 @@ Item {
} }
function undo() { function undo() {
committedCanvas.undoLastGroup(); canvas.undoLastGroup();
changed(); changed();
} }
anchors.fill: parent anchors.fill: parent
enabled: tool !== "none"
StrokeCanvas { StrokeCanvas {
id: committedCanvas id: canvas
anchors.fill: parent anchors.fill: parent
penColor: root.inkColor penColor: root.inkColor
penWidth: root.inkWidth penWidth: root.inkWidth
} }
StrokeCanvas { MouseArea {
id: previewCanvas
anchors.fill: parent
penColor: root.inkColor
penWidth: root.inkWidth
}
PointHandler {
id: drawHandler id: drawHandler
property real startX property real startX
property real startY property real startY
property bool started: false
acceptedButtons: Qt.LeftButton | Qt.RightButton acceptedButtons: Qt.LeftButton | Qt.RightButton
enabled: root.tool !== "none" anchors.fill: parent
cursorShape: undefined
enabled: root.enabled
onActiveChanged: { onPositionChanged: e => {
if (active) const x = e.x;
const y = e.y;
if (!pressed)
return; return;
if (root.tool === "pen") { if (root.tool === "pen") {
committedCanvas.endStroke(); canvas.appendPoint(x, y);
} else if (started) {
const pts = root.sampleShape(root.tool, startX, startY, point.position.x, point.position.y);
if (pts.length > 0) {
committedCanvas.beginStroke(pts[0][0], pts[0][1], true);
for (let i = 1; i < pts.length; i++)
committedCanvas.appendPoint(pts[i][0], pts[i][1]);
committedCanvas.endStroke();
}
previewCanvas.clear();
}
started = false;
root.changed();
}
onPointChanged: {
if (!active)
return; return;
}
if (point.pressedButtons & Qt.RightButton) { const pts = root.sampleShape(root.tool, startX, startY, x, y);
root.clearAll(); if (pts.length > 0) {
return; canvas.beginStroke(pts[0][0], pts[0][1], true);
for (let i = 1; i < pts.length; i++)
canvas.appendPoint(pts[i][0], pts[i][1]);
} }
}
onPressed: e => {
if (e.button & Qt.LeftButton) {
startX = e.x;
startY = e.y;
canvas.beginStroke(e.x, e.y);
} else if (e.button & Qt.RightButton) {
root.clearAll();
}
}
onPressedChanged: if (!pressed)
canvas.endStroke()
}
HoverHandler {
cursorShape: {
if (root.tool === "none")
return undefined;
const x = point.position.x; const x = point.position.x;
const y = point.position.y; const y = point.position.y;
const withinXWidth = x > root.selection.x && x < root.selection.x + root.selection.implicitWidth;
const withinYHeight = y > root.selection.y && y < root.selection.y + root.selection.implicitHeight;
if (root.tool === "pen") { if (withinXWidth && withinYHeight) {
if (!started) { if (!drawHandler.pressed)
started = true; canvas.showHover(x, y);
committedCanvas.beginStroke(x, y); else
return; canvas.showHover(drawHandler.mouseX, drawHandler.mouseY);
} return Qt.BlankCursor;
committedCanvas.appendPoint(x, y); } else {
return; canvas.hideHover();
} return undefined;
if (!started) {
started = true;
startX = point.pressPosition.x;
startY = point.pressPosition.y;
}
const pts = root.sampleShape(root.tool, startX, startY, x, y);
if (pts.length > 0) {
previewCanvas.beginStroke(pts[0][0], pts[0][1], true);
for (let i = 1; i < pts.length; i++)
previewCanvas.appendPoint(pts[i][0], pts[i][1]);
} }
} }
enabled: root.enabled
} }
} }
+40 -14
View File
@@ -1,18 +1,23 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import ZShell.Internal
import qs.Services
Item { Item {
id: root id: root
property color handleBorder: "#3584e4" required property int border
property color handleFill: "white" property color handleBorder: Colors.palette.m3primary
property color handleFill: Colors.palette.m3onPrimary
property real hs: 12 property real hs: 12
property real minSize: 24 property real minSize: 24
required property real selH required property real selH
required property real selW required property real selW
required property real selX required property real selX
required property real selY required property real selY
required property Rectangle selection
required property string tool
signal boundsChanged(x: real, y: real, w: real, h: real) signal boundsChanged(x: real, y: real, w: real, h: real)
signal dragFinished signal dragFinished
@@ -23,12 +28,16 @@ Item {
x: selX x: selX
y: selY y: selY
Component.onDestruction: Cursor.clearOverride()
DragHandler { DragHandler {
id: moveHandler id: moveHandler
property real accumX: 0 property real accumX: 0
property real accumY: 0 property real accumY: 0
cursorShape: undefined
dragThreshold: 0
target: null target: null
onActiveChanged: { onActiveChanged: {
@@ -45,51 +54,63 @@ Item {
} }
} }
HoverHandler {
cursorShape: {
if (root.tool !== "move")
return undefined;
if ((moveHandler.centroid.pressedButtons & Qt.LeftButton) && moveHandler.active)
return Qt.ClosedHandCursor;
else
return Qt.OpenHandCursor;
}
}
Handle { Handle {
edge: "tl" edge: "tl"
x: -root.hs / 2 x: -(root.hs + root.border) / 2
y: -root.hs / 2 y: -(root.hs + root.border) / 2
} }
Handle { Handle {
edge: "t" edge: "t"
x: root.width / 2 - root.hs / 2 x: root.width / 2 - root.hs / 2
y: -root.hs / 2 y: -(root.hs + root.border) / 2
} }
Handle { Handle {
edge: "tr" edge: "tr"
x: root.width - root.hs / 2 x: root.width - root.hs / 2 + root.border / 2
y: -root.hs / 2 y: -(root.hs + root.border) / 2
} }
Handle { Handle {
edge: "r" edge: "r"
x: root.width - root.hs / 2 x: root.width - root.hs / 2 + root.border / 2
y: root.height / 2 - root.hs / 2 y: root.height / 2 - root.hs / 2
} }
Handle { Handle {
edge: "br" edge: "br"
x: root.width - root.hs / 2 x: root.width - root.hs / 2 + root.border / 2
y: root.height - root.hs / 2 y: root.height - root.hs / 2 + root.border / 2
} }
Handle { Handle {
edge: "b" edge: "b"
x: root.width / 2 - root.hs / 2 x: root.width / 2 - root.hs / 2
y: root.height - root.hs / 2 y: root.height - root.hs / 2 + root.border / 2
} }
Handle { Handle {
edge: "bl" edge: "bl"
x: -root.hs / 2 x: -(root.hs + root.border) / 2
y: root.height - root.hs / 2 y: root.height - root.hs / 2 + root.border / 2
} }
Handle { Handle {
edge: "l" edge: "l"
x: -root.hs / 2 x: -(root.hs + root.border) / 2
y: root.height / 2 - root.hs / 2 y: root.height / 2 - root.hs / 2
} }
@@ -107,6 +128,8 @@ Item {
z: 10 z: 10
HoverHandler { HoverHandler {
id: hover
cursorShape: { cursorShape: {
switch (handle.edge) { switch (handle.edge) {
case "tl": case "tl":
@@ -132,6 +155,7 @@ Item {
property real startX property real startX
property real startY property real startY
dragThreshold: 0
grabPermissions: PointerHandler.CanTakeOverFromAnything | PointerHandler.TakeOverForbidden grabPermissions: PointerHandler.CanTakeOverFromAnything | PointerHandler.TakeOverForbidden
target: null target: null
@@ -141,8 +165,10 @@ Item {
startY = root.y; startY = root.y;
startW = root.width; startW = root.width;
startH = root.height; startH = root.height;
Cursor.setOverride(hover.cursorShape);
} else { } else {
root.dragFinished(); root.dragFinished();
Cursor.clearOverride();
} }
} }
onTranslationChanged: { onTranslationChanged: {
+38
View File
@@ -5,7 +5,9 @@ import Quickshell
import Quickshell.Io import Quickshell.Io
import QtQuick import QtQuick
import ZShell.Models import ZShell.Models
import ZShell.Internal
import ZShell.Config import ZShell.Config
import qs.Components
import qs.Services import qs.Services
import qs.Helpers import qs.Helpers
import qs.Paths import qs.Paths
@@ -140,4 +142,40 @@ Searcher {
} }
} }
} }
WallpaperService {
id: wallpaper
path: root.current
}
Variants {
model: Quickshell.screens
WallpaperObject {
readonly property var crops: root.getCrop(modelData.name)
required property var modelData
cropHeight: crops?.height ?? 1.0
cropWidth: crops?.width ?? 1.0
cropX: crops?.x ?? 0.0
cropY: crops?.y ?? 0.0
screen: modelData.name
service: wallpaper
zoom: crops?.zoom ?? 1.0
Behavior on cropHeight {
Anim {}
}
Behavior on cropWidth {
Anim {}
}
Behavior on cropX {
Anim {}
}
Behavior on cropY {
Anim {}
}
}
}
} }
+4 -3
View File
@@ -17,12 +17,12 @@ GridLayout {
required property Wrapper popouts required property Wrapper popouts
required property ClipWrapper popoutsWrapper required property ClipWrapper popoutsWrapper
required property ShellScreen screen required property ShellScreen screen
readonly property int vPadding: 6
required property ScreenState screenState required property ScreenState screenState
readonly property int vPadding: 6
function axisCenterOf(item: Item): real { function axisCenterOf(item: Item): real {
const c = item.mapToItem(root, item.implicitWidth / 2, item.implicitHeight / 2); const c = item.mapToItem(root, item.implicitWidth / 2, item.implicitHeight / 2);
return horizontal ? c.x : c.y; return horizontal ? Math.floor(c.x) : Math.floor(c.y);
} }
function checkPopout(pos: real): void { function checkPopout(pos: real): void {
@@ -210,7 +210,8 @@ GridLayout {
roleValue: "network" roleValue: "network"
delegate: EntryWrapper { delegate: EntryWrapper {
NetworkWidget {} NetworkWidget {
}
} }
} }
+31 -17
View File
@@ -1,6 +1,6 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Shapes import Quickshell
import qs.Components import qs.Components
import ZShell.Config import ZShell.Config
import qs.Services import qs.Services
@@ -12,6 +12,7 @@ GridLayout {
property real animatedPercentage: 0 property real animatedPercentage: 0
readonly property real arcStartAngle: 0.75 * Math.PI readonly property real arcStartAngle: 0.75 * Math.PI
readonly property real arcSweep: 1.5 * Math.PI readonly property real arcSweep: 1.5 * Math.PI
readonly property real dpr: (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1
required property bool horizontal required property bool horizontal
property string icon property string icon
required property color iconColor required property color iconColor
@@ -22,12 +23,17 @@ GridLayout {
property bool warning: percentage * 100 >= warningThreshold property bool warning: percentage * 100 >= warningThreshold
property int warningThreshold: 80 property int warningThreshold: 80
function snap(v) {
return Math.round(v * dpr) / dpr;
}
columnSpacing: Tokens.spacing.medium columnSpacing: Tokens.spacing.medium
columns: horizontal ? -1 : 1 columns: horizontal ? -1 : 1
percentage: 0 percentage: 0
Behavior on animatedPercentage { Behavior on animatedPercentage {
Anim {} Anim {
}
} }
Component.onCompleted: animatedPercentage = percentage Component.onCompleted: animatedPercentage = percentage
@@ -47,27 +53,35 @@ GridLayout {
text: root.icon text: root.icon
} }
CustomClippingRect { CustomRect {
id: track
Layout.preferredHeight: root.horizontal ? icon.implicitHeight - Tokens.padding.extraSmall : 4 Layout.preferredHeight: root.horizontal ? icon.implicitHeight - Tokens.padding.extraSmall : 4
Layout.preferredWidth: root.horizontal ? 4 : icon.implicitWidth Layout.preferredWidth: root.horizontal ? 4 : icon.implicitWidth
color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 2) color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 2)
radius: Tokens.rounding.full radius: Math.min(width, height) / 2
CustomRect { Item {
id: fill id: clipper
anchors.bottom: parent.bottom anchors.bottom: root.horizontal ? parent.bottom : undefined
anchors.left: parent.left anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined
anchors.right: root.horizontal ? parent.right : undefined anchors.left: root.horizontal ? undefined : parent.left
anchors.top: root.horizontal ? undefined : parent.top anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter
color: root.mainColor clip: true
implicitHeight: root.horizontal ? Math.ceil(root.percentage * parent.height) : 0 height: root.horizontal ? root.snap(root.percentage * track.height) : track.height * 2
implicitWidth: root.horizontal ? 0 : Math.ceil(root.percentage * parent.width) width: root.horizontal ? track.width * 2 : root.snap(root.percentage * track.width)
// Behavior on implicitHeight { CustomRect {
// Anim { anchors.bottom: root.horizontal ? parent.bottom : undefined
// } anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined
// } anchors.left: root.horizontal ? undefined : parent.left
anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter
color: root.mainColor
height: track.height
radius: track.radius
width: track.width
}
} }
} }
} }
+4 -2
View File
@@ -36,8 +36,8 @@ CustomClippingRect {
MaterialIcon { MaterialIcon {
id: noUpdatesIcon id: noUpdatesIcon
anchors.horizontalCenter: parent.horizontalCenter anchors.centerIn: parent
anchors.top: parent.top anchors.verticalCenterOffset: -noUpdatesText.implicitHeight
color: Colors.tPalette.m3onSurfaceVariant color: Colors.tPalette.m3onSurfaceVariant
fontStyle: Tokens.font.icon.builders.large.size(48).weight(Font.DemiBold).build() fontStyle: Tokens.font.icon.builders.large.size(48).weight(Font.DemiBold).build()
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
@@ -45,6 +45,8 @@ CustomClippingRect {
} }
CustomText { CustomText {
id: noUpdatesText
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
anchors.top: noUpdatesIcon.bottom anchors.top: noUpdatesIcon.bottom
color: Colors.tPalette.m3onSurfaceVariant color: Colors.tPalette.m3onSurfaceVariant
+41 -65
View File
@@ -1,7 +1,6 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import Quickshell import Quickshell
import Quickshell.Widgets
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import ZShell.Config import ZShell.Config
@@ -11,11 +10,6 @@ import qs.Components
Item { Item {
id: root id: root
readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2
readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins + viewWrapper.anchors.topMargin
required property ScreenState screenState
readonly property var dashboardTabs: { readonly property var dashboardTabs: {
const allTabs = [ const allTabs = [
{ {
@@ -45,9 +39,12 @@ Item {
]; ];
return allTabs.filter(tab => tab.enabled); return allTabs.filter(tab => tab.enabled);
} }
readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins + viewWrapper.anchors.topMargin
readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2
required property ScreenState screenState
implicitWidth: nonAnimWidth
implicitHeight: nonAnimHeight implicitHeight: nonAnimHeight
implicitWidth: nonAnimWidth
Behavior on implicitHeight { Behavior on implicitHeight {
Anim { Anim {
@@ -63,15 +60,14 @@ Item {
Tabs { Tabs {
id: tabs id: tabs
anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.margins: Tokens.padding.small
anchors.right: parent.right anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins)
nonAnimWidth: view.implicitWidth nonAnimWidth: view.implicitWidth
screenState: root.screenState screenState: root.screenState
tabs: root.dashboardTabs tabs: root.dashboardTabs
anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins)
anchors.margins: Tokens.padding.small
} }
Item { Item {
@@ -79,15 +75,15 @@ Item {
property int radius: Tokens.rounding.extraLarge - anchors.margins property int radius: Tokens.rounding.extraLarge - anchors.margins
anchors.top: tabs.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.topMargin: Tokens.spacing.small anchors.left: parent.left
anchors.margins: Tokens.padding.small anchors.margins: Tokens.padding.small
clip: true anchors.right: parent.right
anchors.top: tabs.bottom
anchors.topMargin: Tokens.spacing.small
clip: view.currentIndex !== 3 || (!view.currentItem?.settled ?? true)
Flickable { Item {
id: view id: view
readonly property int currentIndex: root.screenState.dashboardTab readonly property int currentIndex: root.screenState.dashboardTab
@@ -97,64 +93,50 @@ Item {
} }
anchors.fill: parent anchors.fill: parent
implicitWidth: currentItem?.implicitWidth ?? 0
implicitHeight: currentItem?.implicitHeight ?? 0 implicitHeight: currentItem?.implicitHeight ?? 0
implicitWidth: currentItem?.implicitWidth ?? 0
contentX: currentItem?.x ?? 0 Repeater {
contentWidth: row.implicitWidth id: repeater
contentHeight: row.implicitHeight
interactive: false delegate: Loader {
id: paneLoader
onContentXChanged: { required property int index
if (!moving || !currentItem) required property var modelData
return;
const x = contentX - currentItem.x; readonly property real targetX: index < view.currentIndex ? -view.implicitWidth : index > view.currentIndex ? view.implicitWidth : 0
if (x > currentItem.implicitWidth / 2) readonly property bool settled: x === targetX
root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1);
else if (x < -currentItem.implicitWidth / 2)
root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0);
}
RowLayout { active: index === view.currentIndex || opacity > 0
id: row opacity: index === view.currentIndex ? 1 : 0
sourceComponent: modelData.component
visible: opacity > 0
x: targetX
y: 0
Repeater { Behavior on opacity {
id: repeater Anim {
type: Anim.DefaultEffects
model: ScriptModel { }
values: root.dashboardTabs
} }
Behavior on x {
delegate: Loader { Anim {
id: paneLoader type: Anim.DefaultEffects
}
required property int index
required property var modelData
Layout.alignment: Qt.AlignTop
sourceComponent: modelData.component
Component.onCompleted: active = Qt.binding(() => {
if (index === view.currentIndex)
return true;
const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth);
const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth);
return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth);
})
} }
} }
model: ScriptModel {
values: root.dashboardTabs
}
} }
Component { Component {
id: dashComponent id: dashComponent
Dash { Dash {
screenState: root.screenState
radius: viewWrapper.radius radius: viewWrapper.radius
screenState: root.screenState
} }
} }
@@ -170,8 +152,8 @@ Item {
id: mediaComponent id: mediaComponent
Media { Media {
screenState: root.screenState
radius: viewWrapper.radius radius: viewWrapper.radius
screenState: root.screenState
} }
} }
@@ -182,12 +164,6 @@ Item {
radius: viewWrapper.radius radius: viewWrapper.radius
} }
} }
Behavior on contentX {
Anim {
type: Anim.DefaultEffects
}
}
} }
} }
} }
@@ -171,7 +171,7 @@ Item {
Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter
elide: Text.ElideRight elide: Text.ElideRight
font: Tokens.font.title.medium font: Tokens.font.title.medium
text: qsTr(root.chatData.title) text: qsTr(root.chatData?.title || ChatState.title)
} }
} }
@@ -540,14 +540,17 @@ Item {
anchors.margins: Tokens.padding.medium anchors.margins: Tokens.padding.medium
bg.border.color: Colors.palette.m3outlineVariant bg.border.color: Colors.palette.m3outlineVariant
bg.color: Colors.tPalette.m3surfaceContainerLowest bg.color: Colors.tPalette.m3surfaceContainerLowest
fastGlow: Chat.busy fastGlow: LlmClient.busy
glow.opacity: input.activeFocus && (!LlmClient.busy || ChatState.isWindow)
font: Tokens.font.body.small font: Tokens.font.body.small
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding) implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
focus: true focus: true
placeholderText: qsTr("Send a message") placeholderText: qsTr("Send a message")
sendIcon.font: Tokens.font.icon.medium sendIcon.font: Tokens.font.icon.medium
sendIcon.icon: "arrow_upward" sendIcon.icon: LlmClient.busy ? "stop" : "arrow_upward"
sendIcon.padding: Tokens.padding.extraSmall sendIcon.padding: Tokens.padding.extraSmall
sendIcon.opacity: text || LlmClient.busy ? 1.0 : 0.5
sendIcon.enabled: text || LlmClient.busy
Component.onCompleted: { Component.onCompleted: {
root.focusInput(); root.focusInput();
@@ -568,6 +571,11 @@ Item {
} }
} }
} }
onSendPressed: root.send(text) onButtonPressed: {
if (LlmClient.busy)
LlmClient.stop();
else
root.send(text);
}
} }
} }
@@ -161,10 +161,6 @@ CustomClippingRect {
CustomRect { CustomRect {
id: fadeMask id: fadeMask
// anchors.top: titleText.top
// anchors.bottom: titleText.bottom
// anchors.left: titleText.left
// anchors.right: parent.right
anchors.fill: titleWrapper anchors.fill: titleWrapper
layer.enabled: true layer.enabled: true
@@ -259,7 +255,7 @@ CustomClippingRect {
font: Tokens.font.body.small font: Tokens.font.body.small
maximumLineCount: 1 maximumLineCount: 1
opacity: !root.expanded && !root.slim ? 1 : 0 opacity: !root.expanded && !root.slim ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet") text: root.modelData.messagesModel.lastMessage?.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet")
Behavior on y { Behavior on y {
Anim {} Anim {}
@@ -284,7 +280,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 ?? qsTr("No messages yet") text: root.modelData.messagesModel.lastMessage?.content ?? qsTr("No messages yet")
wrapMode: Text.WrapAtWordBoundaryOrAnywhere wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Behavior on y { Behavior on y {
@@ -8,10 +8,11 @@ TextAreaBase {
readonly property alias bg: bg readonly property alias bg: bg
readonly property alias sendIcon: sendIcon readonly property alias sendIcon: sendIcon
readonly property alias glow: glow
property bool fastGlow: false property bool fastGlow: false
signal sendPressed signal buttonPressed
bottomPadding: Tokens.padding.large bottomPadding: Tokens.padding.large
leftPadding: Tokens.padding.extraLarge leftPadding: Tokens.padding.extraLarge
@@ -39,6 +40,7 @@ TextAreaBase {
GeminiGlow { GeminiGlow {
id: glow id: glow
glowSize: 32 glowSize: 32
z: 5
opacity: root.activeFocus ? 1 : 0 opacity: root.activeFocus ? 1 : 0
visible: opacity > 0 visible: opacity > 0
@@ -131,7 +133,7 @@ TextAreaBase {
} }
} }
onClicked: root.sendPressed() onClicked: root.buttonPressed()
} }
TapHandler { TapHandler {
+17 -152
View File
@@ -5,6 +5,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.Content
import qs.Services import qs.Services
import qs.Components import qs.Components
@@ -21,8 +22,13 @@ Item {
signal loadChatRequest(content: ChatSession, index: int) signal loadChatRequest(content: ChatSession, index: int)
signal newChatRequest signal newChatRequest
onSlimChanged: {
if (slim)
modelsContainer.open = false;
}
CustomText { CustomText {
id: header id: chatHeader
anchors.left: parent.left anchors.left: parent.left
anchors.margins: Tokens.padding.extraSmall anchors.margins: Tokens.padding.extraSmall
@@ -41,7 +47,7 @@ Item {
anchors.bottom: modelsContainer.top 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: chatHeader.bottom
anchors.topMargin: Tokens.spacing.medium anchors.topMargin: Tokens.spacing.medium
clip: true clip: true
spacing: Tokens.spacing.medium spacing: Tokens.spacing.medium
@@ -98,156 +104,24 @@ Item {
} }
} }
CustomClippingRect { ModelsContainer {
id: modelsContainer id: modelsContainer
property bool expanded: false
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.left: parent.left anchors.left: parent.left
anchors.right: fabRoot.left anchors.right: fabRoot.left
anchors.margins: Tokens.padding.small anchors.margins: Tokens.padding.small
anchors.rightMargin: Tokens.spacing.small anchors.rightMargin: Tokens.spacing.small
implicitHeight: expanded ? 400 : fabRoot.implicitHeight rootParent: root
radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2 fabRoot: fabRoot
function prettyModelName(): string { opacity: root.slim ? 0 : 1
if (modelsList.count < 1) visible: opacity > 0
return qsTr("No models found");
const pathList = Chat.model.split("\/"); Behavior on opacity {
const name = pathList[pathList.length - 1]; Anim {
type: Anim.DefaultEffects
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
fontStyle: Tokens.font.icon.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
fontStyle: Tokens.font.icon.normal
opacity: model.selected ? 1 : 0
text: "check"
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
}
}
StateLayer {
id: stateLayer
onClicked: {
Chat.selectModel(model.modelData);
}
}
} }
} }
} }
@@ -262,19 +136,11 @@ Item {
font.pointSize: Math.round(18 * 1.2) font.pointSize: Math.round(18 * 1.2)
icon: root.slim ? "left_panel_open" : "add" icon: root.slim ? "left_panel_open" : "add"
isRound: ChatState.fabExpanded isRound: ChatState.fabExpanded
// opacity: root.slim ? 0 : 1
// visible: opacity > 0
//
// Behavior on opacity {
// Anim {
// type: Anim.DefaultEffects
// }
// }
label.transform: Rotation { label.transform: Rotation {
origin.y: fabRoot.label.height / 2 origin.y: fabRoot.label.height / 2
origin.x: fabRoot.label.width / 2 origin.x: fabRoot.label.width / 2
angle: ChatState.fabExpanded ? 135 : 0 angle: ChatState.fabExpanded && (ChatState.narrowSidebarExpanded && !ChatState.isWide || !ChatState.isWindow) ? 135 : 0
Behavior on angle { Behavior on angle {
Anim {} Anim {}
@@ -287,7 +153,6 @@ Item {
return; return;
} }
modelsContainer.expanded = false;
ChatState.fabExpanded = !ChatState.fabExpanded; ChatState.fabExpanded = !ChatState.fabExpanded;
} }
@@ -17,15 +17,26 @@ Item {
anchors.margins: Tokens.padding.small anchors.margins: Tokens.padding.small
Component.onCompleted: { Component.onCompleted: {
if (ChatState.firstOpen) {
ChatState.openDraft();
stack.push(chatList);
stack.push(chatContent);
ChatState.firstOpen = false;
return;
}
if (ChatState.inChat) { if (ChatState.inChat) {
stack.push(chatList); stack.push(chatList);
stack.push(chatContent, { stack.push(chatContent);
"chatData": ChatState.chatSession
});
} else } else
stack.push(chatList); stack.push(chatList);
} }
function openChat(): void {
if (stack.depth < 2)
stack.push(chatContent);
}
StackView { StackView {
id: stack id: stack
anchors.fill: parent anchors.fill: parent
@@ -35,27 +46,23 @@ Item {
id: chatList id: chatList
ChatList { ChatList {
model: ScriptModel { model: LlmClient.chats
values: Chat.chats.values
}
onDeleteChatRequest: chat => { onDeleteChatRequest: chat => {
Chat.chats.remove(chat); const wasCurrent = chat === ChatState.chatSession;
LlmClient.chats.remove(chat);
if (wasCurrent)
ChatState.openDraft();
} }
onLoadChatRequest: (chat, index) => { onLoadChatRequest: (chat, index) => {
stack.push(chatContent, {
"chatData": chat
});
ChatState.inChat = true;
ChatState.chatSession = chat; ChatState.chatSession = chat;
openChat();
ChatState.inChat = true;
} }
onNewChatRequest: { onNewChatRequest: {
const data = Chat.chats.insert(); ChatState.openDraft();
stack.push(chatContent, { openChat();
"chatData": data
});
ChatState.inChat = true; ChatState.inChat = true;
ChatState.chatSession = data;
} }
} }
} }
@@ -64,9 +71,12 @@ Item {
id: chatContent id: chatContent
ChatContent { ChatContent {
chatData: ChatState.chatSession
onRequestClose: { onRequestClose: {
stack.pop(); stack.pop();
ChatState.inChat = false; ChatState.inChat = false;
ChatState.openDraft();
} }
} }
} }
@@ -8,9 +8,10 @@ import qs.Services
Singleton { Singleton {
id: root id: root
property ChatSession chatSession property ChatSession chatSession: LlmClient.chats.draft()
readonly property string title: chatSession ? chatSession.title : "Chat" readonly property string title: chatSession?.title || "Chat"
property int currentIdx: -1 property int currentIdx: -1
property bool firstOpen: true
property bool inChat: false property bool inChat: false
property bool fabExpanded: false property bool fabExpanded: false
property bool isWindow: false property bool isWindow: false
@@ -22,6 +23,11 @@ Singleton {
property ShellScreen screen property ShellScreen screen
property bool sidebarVisible: ShellState.forActive().sidebar property bool sidebarVisible: ShellState.forActive().sidebar
function openDraft(): void {
root.chatSession = LlmClient.chats.draft();
root.currentIdx = -1;
}
onSidebarVisibleChanged: if (!sidebarVisible) onSidebarVisibleChanged: if (!sidebarVisible)
fabExpanded = false fabExpanded = false
} }
@@ -9,12 +9,11 @@ import qs.Services
Item { Item {
id: root id: root
required property ChatGeneration current
required property TextEdit edit required property TextEdit edit
required property bool hovered required property bool hovered
required property bool isUser required property bool isUser
required property ChatMessage message required property ChatMessage message
required property LlmSegment segment required property bool running
implicitHeight: editButton.implicitHeight implicitHeight: editButton.implicitHeight
implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small
@@ -63,7 +62,7 @@ Item {
id: editTools id: editTools
anchors.right: parent.right anchors.right: parent.right
opacity: !root.current.streaming && root.hovered ? 1 : 0 opacity: !root.running && root.hovered ? 1 : 0
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
visible: opacity > 0 visible: opacity > 0
@@ -99,7 +98,7 @@ Item {
Anim {} Anim {}
} }
onClicked: Quickshell.clipboardText = root.current.content onClicked: Quickshell.clipboardText = root.message.content
} }
IconButton { IconButton {
@@ -0,0 +1,400 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import ZShell.Components
import ZShell.Llm
import ZShell.Blobs
import qs.Components
import qs.Services
Item {
id: root
required property Item rootParent
required property ChatGeneration current
implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2
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: Tokens.font.headline.medium
color: Colors.palette.m3onSurface
}
Item {
id: toolsBg
property bool open: false
property int maxHeight: root.rootParent.height - Tokens.padding.medium * 2
property int openHeight: Math.min(toolsContent.item?.implicitHeight, maxHeight)
property int openWidth: root.rootParent.width - Tokens.padding.medium * 2
property real openHeightProg: openHeight / maxHeight
property int collapsedY: 0
implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2
z: open || dialogTransition.running ? 2 : 0
Layout.fillWidth: true
function reparentWrapper(): void {
const newParent = open ? root.rootParent : toolsBg;
const c = toolsWrapper.parent.mapToItem(newParent, toolsWrapper.x + toolsWrapper.width / 2, toolsWrapper.y + toolsWrapper.height / 2);
toolsBg.collapsedY = toolsWrapper.y;
toolsWrapper.parent = newParent;
toolsWrapper.x = c.x - toolsWrapper.width / 2;
toolsWrapper.y = c.y - toolsWrapper.height / 2;
}
onOpenChanged: {
if (open)
root.rootParent.interactive = false;
else
root.rootParent.interactive = true;
}
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 ? root.rootParent : toolsBg
onClicked: toolsBg.open = false
}
Item {
id: toolsWrapper
width: toolsBg.width
height: toolBox.implicitHeight
rotation: parent === root.rootParent ? 180 : 0
z: 1
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: (root.rootParent.width - toolsBg.openWidth) / 2
toolsWrapper.y: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium
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: {
toolsBg.open = true;
}
}
ColumnLayout {
id: toolList
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Tokens.padding.medium
Repeater {
id: repeater
model: root.current.pendingToolCalls?.slice(0, 4) ?? []
delegate: RowLayout {
id: toolText
required property var modelData
required property int index
spacing: Tokens.spacing.medium
function toolTarget(seg: var): 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);
}
MaterialIcon {
color: Colors.palette.m3onPrimaryContainer
text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet"
fill: 1
}
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))
}
}
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("And %1 more").arg(root.current.pendingToolCalls.slice(4).length)
visible: repeater.count < root.current.pendingToolCalls.length
}
}
}
Loader {
id: toolsContent
active: opacity > 0
anchors.fill: parent
asynchronous: false
opacity: 0
sourceComponent: MouseArea {
onWheel: event => event.accepted = true
preventStealing: true
implicitHeight: innerLayout.contentHeight + innerLayout.anchors.margins * 2
VerticalFadeListView {
id: innerLayout
anchors.fill: parent
anchors.margins: Tokens.padding.small
cacheBuffer: height * 10
fadeAmount: 0.1
fadeEnabled: interactive
spacing: 0
clip: true
interactive: contentHeight > toolsBg.maxHeight
model: root.current.pendingToolCalls ?? []
delegate: Item {
id: toolCall
required property var modelData
property bool expanded: false
implicitWidth: CustomListView.view.width
implicitHeight: collapsedRow.implicitHeight + collapsedRow.anchors.margins + (expanded ? expandedTool.implicitHeight + expandedTool.anchors.margins + expandedTool.anchors.topMargin : collapsedRow.anchors.margins)
function toolTarget(seg: var): 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);
}
Behavior on implicitHeight {
Anim {}
}
RowLayout {
id: collapsedRow
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Tokens.padding.medium
IconButton {
id: toolExpandBtn
font: Tokens.font.icon.medium
icon: "keyboard_arrow_down"
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
rotation: toolCall.expanded ? 180 : 0
type: IconButton.Text
Behavior on rotation {
Anim {}
}
onClicked: toolCall.expanded = !toolCall.expanded
}
CustomText {
id: preview
Layout.fillWidth: true
text: toolCall.modelData.name
}
}
CustomClippingRect {
id: expandedTool
anchors.top: collapsedRow.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
anchors.topMargin: Tokens.spacing.medium
implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2
radius: Tokens.rounding.medium
color: Colors.palette.m3surfaceContainerLow
CustomText {
id: fullToolCall
anchors.fill: parent
anchors.margins: Tokens.padding.medium
text: toolCall.toolTarget(toolCall.modelData)
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
}
}
}
}
}
}
}
}
ButtonRow {
id: confirmationRow
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()
}
}
}
}
}
@@ -34,8 +34,12 @@ Item {
}); });
const attach = () => { const attach = () => {
incubator.object.anchors.fill = container; incubator.object.anchors.top = container.top;
incubator.object.anchors.bottom = container.bottom;
incubator.object.anchors.margins = Tokens.padding.medium;
incubator.object.anchors.horizontalCenter = container.horizontalCenter;
incubator.object.requestClose.connect(root.requestClose); incubator.object.requestClose.connect(root.requestClose);
incubator.object.implicitWidth = Qt.binding(() => Math.max(Math.min(container.width - Tokens.padding.medium * 2, 800), Config.sidebar.sizes.width));
currentItem = incubator.object; currentItem = incubator.object;
root.loading = false; root.loading = false;
if (!noAnim) if (!noAnim)
@@ -10,12 +10,13 @@ Item {
id: root id: root
required property bool isUser required property bool isUser
required property LlmSegment segment required property string text
required property var blocks
required property bool running
required property bool hovered required property bool hovered
required property Repeater repeater required property Repeater repeater
required property int index required property int index
required property ChatMessage message required property ChatMessage message
required property ChatGeneration current
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
@@ -34,7 +35,7 @@ Item {
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: layoutFrozen ? frozenWidth : layoutWidth implicitWidth: layoutFrozen ? frozenWidth : layoutWidth
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2 implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : markdown.implicitHeight + markdown.anchors.topMargin * 2
anchors.right: root.isUser ? parent.right : undefined anchors.right: root.isUser ? parent.right : undefined
layer.enabled: false layer.enabled: false
layer.smooth: true layer.smooth: true
@@ -74,7 +75,7 @@ Item {
TextEditBase { TextEditBase {
id: msgText id: msgText
property string cachedText: root.segment.text property string cachedText: root.text
property bool cancelled: false property bool cancelled: false
visible: root.isUser visible: root.isUser
@@ -88,7 +89,7 @@ Item {
font: Tokens.font.body.small font: Tokens.font.body.small
readOnly: true readOnly: true
selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4) selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4)
text: root.isUser ? root.segment.text : "" text: root.isUser ? root.text : ""
textFormat: CustomText.MarkdownText textFormat: CustomText.MarkdownText
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
@@ -129,23 +130,22 @@ Item {
} }
} }
// Assistant content: parsed markdown blocks.
MarkdownBlocks { MarkdownBlocks {
id: blocks id: markdown
visible: !root.isUser visible: !root.isUser
anchors.topMargin: Tokens.padding.medium 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
blocks: root.segment.markdown blocks: root.blocks
} }
} }
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) readonly property bool shouldBeActive: !root.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
@@ -156,8 +156,7 @@ Item {
edit: msgText edit: msgText
hovered: root.hovered hovered: root.hovered
isUser: root.isUser isUser: root.isUser
segment: root.segment running: root.running
current: root.current
message: root.message message: root.message
Behavior on opacity { Behavior on opacity {
@@ -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 fontStyle: Tokens.font.icon.extraLarge
text: "chat" text: "chat"
} }
CustomText { CustomText {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
color: Colors.tPalette.m3onSurfaceVariant color: Colors.tPalette.m3onSurfaceVariant
text: qsTr("Send a message to get started") text: qsTr("Send a message to get started")
} }
Item { Item {
Layout.fillHeight: true Layout.fillHeight: true
} }
} }
@@ -108,7 +108,7 @@ Item {
wrapMode: CustomText.WordWrap wrapMode: CustomText.WordWrap
color: Colors.palette.m3onErrorContainer color: Colors.palette.m3onErrorContainer
text: root.current.errorMessage || Chat.serverError text: root.current.errorMessage || LlmClient.serverError
} }
} }
@@ -23,10 +23,10 @@ Column {
role: "type" role: "type"
DelegateChoice { DelegateChoice {
roleValue: LlmMarkdown.Type.Code roleValue: MarkdownBlock.Type.Code
delegate: CodeBlockView { delegate: CodeBlockView {
required property var modelData required property MarkdownBlock modelData
language: modelData.language language: modelData.language
code: modelData.code code: modelData.code
@@ -37,10 +37,10 @@ Column {
} }
DelegateChoice { DelegateChoice {
roleValue: LlmMarkdown.Type.Math roleValue: MarkdownBlock.Type.Math
delegate: MathBlockView { delegate: MathBlockView {
required property var modelData required property MarkdownBlock modelData
latex: modelData.latex latex: modelData.latex
anchors.right: parent.right anchors.right: parent.right
@@ -50,10 +50,10 @@ Column {
} }
DelegateChoice { DelegateChoice {
roleValue: LlmMarkdown.Type.Heading roleValue: MarkdownBlock.Type.Heading
delegate: TextEditBase { delegate: TextEditBase {
required property var modelData required property MarkdownBlock modelData
color: Colors.palette.m3onSurface color: Colors.palette.m3onSurface
anchors.right: parent.right anchors.right: parent.right
@@ -74,10 +74,10 @@ Column {
} }
DelegateChoice { DelegateChoice {
roleValue: LlmMarkdown.Type.Text roleValue: MarkdownBlock.Type.Text
delegate: BubbleEdit { delegate: BubbleEdit {
required property var modelData required property MarkdownBlock modelData
anchors.right: parent.right anchors.right: parent.right
anchors.left: parent.left anchors.left: parent.left
@@ -19,7 +19,16 @@ MouseArea {
required property ChatListView chatList required property ChatListView chatList
readonly property bool isUser: modelData.role === ChatMessage.Role.User readonly property bool isUser: modelData.role === ChatMessage.Role.User
readonly property var blocks: blockify(current.segments, current.error)
readonly property var blocks: root.isUser ? [
{
kind: "content",
id: 0,
text: root.modelData.content,
markdown: [],
running: false
}
] : blockify(current.segments, current.error)
property ChatGeneration current: modelData.activeGeneration property ChatGeneration current: modelData.activeGeneration
property bool reasoningExpanded: false property bool reasoningExpanded: false
@@ -54,7 +63,9 @@ MouseArea {
blocks.push({ blocks.push({
kind: "content", kind: "content",
id: i, id: i,
segments: [seg] text: seg.text,
markdown: seg.markdown,
running: seg.running
}); });
} else { } else {
const last = blocks.length ? blocks[blocks.length - 1] : null; const last = blocks.length ? blocks[blocks.length - 1] : null;
@@ -330,11 +341,12 @@ MouseArea {
required property var modelData required property var modelData
isUser: root.isUser isUser: root.isUser
segment: modelData.segments[0] text: modelData.text
blocks: modelData.markdown
running: modelData.running
width: root.width width: root.width
repeater: segmentRep repeater: segmentRep
message: root.modelData message: root.modelData
current: root.current
hovered: root.containsMouse hovered: root.containsMouse
onEdit: text => { onEdit: text => {
@@ -0,0 +1,351 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import ZShell.Config
import ZShell.Components
import ZShell.Llm
import ZShell.Blobs
import qs.Modules.Notifications.Sidebar.Chat
import qs.Components
import qs.Services
Item {
id: root
required property Item rootParent
required property Item fabRoot
property bool open: false
property int maxHeight: root.rootParent.height - Tokens.padding.medium * 2
property int openHeight: Math.min(modelsContent.item?.implicitHeight, maxHeight)
property int openWidth: root.rootParent.width - Tokens.padding.medium * 2
property real openHeightProg: openHeight / maxHeight
property int collapsedY: 0
property int horizontalContentMargin
property string header: qsTr("Available models")
property string acceptLabel: qsTr("Accept")
z: open || dialogTransition.running ? 2 : 0
Layout.fillWidth: true
implicitHeight: fabRoot.implicitHeight
function prettyModelName(): string {
if (modelsContent.item?.modelsList.count < 1)
return qsTr("No models found");
const pathList = LlmClient.model.split("\/");
const name = pathList[pathList.length - 1];
if (!name)
return qsTr("No model selected");
return name;
}
Behavior on implicitHeight {
Anim {}
}
function reparentWrapper(): void {
const newParent = open ? root.rootParent : root;
const c = modelsWrapper.parent.mapToItem(newParent, modelsWrapper.x + modelsWrapper.width / 2, modelsWrapper.y + modelsWrapper.height / 2);
root.collapsedY = modelsWrapper.y;
modelsWrapper.parent = newParent;
modelsWrapper.x = c.x - modelsWrapper.width / 2;
modelsWrapper.y = c.y - modelsWrapper.height / 2;
}
BlobGroup {
id: blobGroup
color: root.open ? Colors.palette.m3surfaceContainerHighest : 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: modelsWrapper
width: root.width
height: modelBox.implicitHeight
z: 1
states: State {
name: "open"
when: root.open
PropertyChanges {
backdrop.enabled: true
modelsBackground.radius: Tokens.rounding.largeIncreased
modelsContent.opacity: 1
modelsWrapper.height: root.openHeight
modelsWrapper.width: root.openWidth
modelsWrapper.x: (root.rootParent.width - root.openWidth) / 2
modelsWrapper.y: root.rootParent.height - root.openHeight - Tokens.spacing.medium - Tokens.padding.medium - root.fabRoot.implicitHeight
elevation.opacity: 1
modelBox.opacity: 0
}
}
transitions: Transition {
id: dialogTransition
SequentialAnimation {
ScriptAction {
script: root.reparentWrapper()
}
Anim {
properties: "x,y"
}
}
PropertyAction {
property: "enabled"
}
Anim {
properties: "opacity,radius"
type: Anim.DefaultEffects
}
Anim {
properties: "width,height"
}
}
Elevation {
id: elevation
anchors.fill: parent
level: 4
opacity: 0
radius: modelsBackground.radius
transform: Matrix4x4 {
matrix: modelsBackground.deformMatrix
}
}
BlobRect {
id: modelsBackground
anchors.fill: parent
radius: modelLayer.pressed ? Tokens.rounding.small : Tokens.rounding.extraLarge
deformScale: 0
group: blobGroup
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
Behavior on radius {
Anim {
type: Anim.DefaultEffects
}
}
}
CustomRect {
id: modelBox
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
implicitHeight: root.fabRoot.implicitHeight
radius: modelsBackground.radius
CustomText {
id: label
text: root.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: "unfold_more"
fontStyle: Tokens.font.icon.large
}
StateLayer {
id: modelLayer
onClicked: {
ChatState.fabExpanded = false;
root.open = true;
}
}
}
Loader {
id: modelsContent
active: opacity > 0
anchors.fill: parent
asynchronous: false
opacity: 0
sourceComponent: MouseArea {
readonly property alias modelsList: modelsList
onWheel: event => event.accepted = true
preventStealing: true
clip: true
implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins * 2
ColumnLayout {
id: innerLayout
anchors.bottomMargin: Tokens.padding.largeIncreased
anchors.fill: parent
anchors.margins: Tokens.padding.extraLarge
spacing: 0
CustomText {
font: Tokens.font.title.builders.large.weight(Font.Normal).build()
text: root.header
}
VerticalFadeListView {
id: modelsList
Layout.fillWidth: true
Layout.leftMargin: root.horizontalContentMargin
Layout.topMargin: Tokens.spacing.medium
Layout.bottomMargin: Tokens.spacing.medium
Layout.rightMargin: root.horizontalContentMargin
Layout.preferredHeight: 400
Layout.maximumHeight: 400
model: LlmClient.availableModels
currentIndex: LlmClient.availableModels.indexOf(LlmClient.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: ListView.view.currentIndex === model.index
implicitWidth: ListView.view.width
implicitHeight: name.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.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Tokens.padding.small
anchors.leftMargin: Tokens.padding.large
anchors.rightMargin: Tokens.padding.large
CustomText {
id: name
text: model.modelData
elide: CustomText.ElideRight
color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
Layout.fillWidth: true
}
MaterialIcon {
id: check
color: Colors.palette.m3onTertiaryContainer
fontStyle: Tokens.font.icon.medium
opacity: model.selected ? 1 : 0
visible: opacity > 0
text: "check"
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
}
}
StateLayer {
id: stateLayer
onClicked: {
model.ListView.view.currentIndex = model.index;
}
}
}
}
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.open = false;
}
}
TextButton {
horizontalPadding: Tokens.padding.largeIncreased
isRound: true
text: root.acceptLabel
type: TextButton.Text
stateLayer.preventStealing: true
verticalPadding: Tokens.padding.small
onClicked: {
LlmClient.selectModel(modelsList.currentItem.modelData);
root.open = false;
}
}
}
}
}
}
}
}
@@ -18,9 +18,9 @@ Item {
required property ChatGeneration current required property ChatGeneration current
readonly property bool isActive: index === blocks.length - 1 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 var 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)) readonly property bool pendingApproval: current.toolApprovalPending && segments.some(s => s.type === LlmSegment.Type.ToolCall && s.status === LlmToolCall.Status.Pending)
implicitHeight: { implicitHeight: {
const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin; const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin;
@@ -112,396 +112,17 @@ Item {
} }
} }
Item { ApprovalPrompt {
id: approvalPrompt id: approvalPrompt
anchors.top: layout.bottom anchors.top: layout.bottom
anchors.topMargin: Tokens.spacing.small anchors.topMargin: Tokens.spacing.small
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2
visible: root.pendingApproval visible: root.pendingApproval
CustomRect { rootParent: root.rootParent
id: approvalBg current: root.current
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: Tokens.font.headline.medium
color: Colors.palette.m3onSurface
}
Item {
id: toolsBg
property bool open: false
property Item rootParent: root.rootParent
property int maxHeight: rootParent.height - Tokens.padding.medium * 2
property int openHeight: Math.min(toolsContent.item?.implicitHeight, maxHeight)
property int openWidth: toolsBg.rootParent.width - Tokens.padding.medium * 2
property real openHeightProg: openHeight / maxHeight
property int collapsedY: 0
implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2
z: open || dialogTransition.running ? 2 : 0
Layout.fillWidth: true
function reparentWrapper(): void {
const newParent = open ? toolsBg.rootParent : toolsBg;
const c = toolsWrapper.parent.mapToItem(newParent, toolsWrapper.x + toolsWrapper.width / 2, toolsWrapper.y + toolsWrapper.height / 2);
toolsBg.collapsedY = toolsWrapper.y;
toolsWrapper.parent = newParent;
toolsWrapper.x = c.x - toolsWrapper.width / 2;
toolsWrapper.y = c.y - toolsWrapper.height / 2;
}
onOpenChanged: {
if (open)
toolsBg.rootParent.interactive = false;
else
toolsBg.rootParent.interactive = true;
}
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
rotation: parent === toolsBg.rootParent ? 180 : 0
z: 1
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: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium
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: {
toolsBg.open = true;
}
}
ColumnLayout {
id: toolList
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Tokens.padding.medium
Repeater {
id: repeater
model: root.current.pendingToolCalls?.slice(0, 4) ?? []
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);
}
MaterialIcon {
color: Colors.palette.m3onPrimaryContainer
text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet"
fill: 1
}
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))
}
}
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("And %1 more").arg(root.current.pendingToolCalls.slice(4).length)
visible: repeater.count < root.current.pendingToolCalls.length
}
}
}
Loader {
id: toolsContent
active: opacity > 0
anchors.fill: parent
asynchronous: false
opacity: 0
sourceComponent: MouseArea {
onWheel: event => event.accepted = true
preventStealing: true
implicitHeight: innerLayout.contentHeight + innerLayout.anchors.margins * 2
VerticalFadeListView {
id: innerLayout
anchors.fill: parent
anchors.margins: Tokens.padding.small
cacheBuffer: height * 10
fadeAmount: 0.1
fadeEnabled: interactive
spacing: 0
clip: true
interactive: contentHeight > toolsBg.maxHeight
model: root.current.pendingToolCalls ?? []
delegate: Item {
id: toolCall
required property LlmSegment modelData
property bool expanded: false
implicitWidth: CustomListView.view.width
implicitHeight: collapsedRow.implicitHeight + collapsedRow.anchors.margins + (expanded ? expandedTool.implicitHeight + expandedTool.anchors.margins + expandedTool.anchors.topMargin : collapsedRow.anchors.margins)
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);
}
Behavior on implicitHeight {
Anim {}
}
RowLayout {
id: collapsedRow
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Tokens.padding.medium
IconButton {
id: toolExpandBtn
font: Tokens.font.icon.medium
icon: "keyboard_arrow_down"
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
rotation: toolCall.expanded ? 180 : 0
type: IconButton.Text
Behavior on rotation {
Anim {}
}
onClicked: toolCall.expanded = !toolCall.expanded
}
CustomText {
id: preview
Layout.fillWidth: true
text: toolCall.modelData.name
}
}
CustomClippingRect {
id: expandedTool
anchors.top: collapsedRow.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
anchors.topMargin: Tokens.spacing.medium
implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2
radius: Tokens.rounding.medium
color: Colors.palette.m3surfaceContainerLow
CustomText {
id: fullToolCall
anchors.fill: parent
anchors.margins: Tokens.padding.medium
text: toolCall.toolTarget(toolCall.modelData)
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
}
}
}
}
}
}
}
}
ButtonRow {
id: confirmationRow
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()
}
}
}
}
} }
CustomClippingRect { CustomClippingRect {
@@ -533,7 +154,7 @@ Item {
delegate: Item { delegate: Item {
id: segment id: segment
required property LlmSegment modelData required property var modelData
required property int index required property int index
implicitWidth: ListView.view.width implicitWidth: ListView.view.width
@@ -560,7 +181,7 @@ Item {
CustomText { CustomText {
id: content id: content
function toolTarget(seg: LlmSegment): string { function toolTarget(seg: var): string {
var a = null; var a = null;
try { try {
a = JSON.parse(seg.arguments); a = JSON.parse(seg.arguments);
@@ -573,29 +194,29 @@ Item {
return String(seg.arguments); return String(seg.arguments);
} }
function prettyTool(seg: LlmSegment): string { function prettyTool(seg: var): string {
if (seg.name === "webfetch") { if (seg.name === "webfetch") {
switch (seg.status) { switch (seg.status) {
case LlmSegment.Status.Running: case LlmToolCall.Status.Running:
return qsTr("Fetching"); return qsTr("Fetching");
case LlmSegment.Status.Pending: case LlmToolCall.Status.Pending:
return qsTr("Pending fetch"); return qsTr("Pending fetch");
case LlmSegment.Status.Success: case LlmToolCall.Status.Success:
return qsTr("Fetched"); return qsTr("Fetched");
case LlmSegment.Status.Error: case LlmToolCall.Status.Error:
return qsTr("Failed to fetch"); return qsTr("Failed to fetch");
default: default:
return qsTr("Fetched website"); return qsTr("Fetched website");
} }
} else if (seg.name === "readfile") { } else if (seg.name === "readfile") {
switch (seg.status) { switch (seg.status) {
case LlmSegment.Status.Running: case LlmToolCall.Status.Running:
return qsTr("Reading"); return qsTr("Reading");
case LlmSegment.Status.Pending: case LlmToolCall.Status.Pending:
return qsTr("Pending read"); return qsTr("Pending read");
case LlmSegment.Status.Success: case LlmToolCall.Status.Success:
return qsTr("Read"); return qsTr("Read");
case LlmSegment.Status.Error: case LlmToolCall.Status.Error:
return qsTr("Failed to read"); return qsTr("Failed to read");
default: default:
return qsTr("Read file"); return qsTr("Read file");
@@ -65,9 +65,7 @@ Item {
} }
} }
model: ScriptModel { model: LlmClient.chats
values: Chat.chats.values
}
onLoadChatRequest: (chat, index) => { onLoadChatRequest: (chat, index) => {
ChatState.currentIdx = index; ChatState.currentIdx = index;
@@ -75,17 +73,13 @@ Item {
} }
onDeleteChatRequest: chat => { onDeleteChatRequest: chat => {
if (chat === ChatState.chatSession) { const wasCurrent = chat === ChatState.chatSession;
ChatState.chatSession = null; LlmClient.chats.remove(chat);
ChatState.currentIdx = -1;
} if (wasCurrent)
Chat.chats.remove(chat); ChatState.openDraft();
}
onNewChatRequest: {
const data = Chat.chats.insert();
ChatState.currentIdx = 0;
ChatState.chatSession = data;
} }
onNewChatRequest: ChatState.openDraft()
onRequestExpand: { onRequestExpand: {
ChatState.narrowSidebarExpanded = true; ChatState.narrowSidebarExpanded = true;
@@ -126,9 +120,8 @@ Item {
z: 0 z: 0
anchors.top: parent.top anchors.top: parent.top
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
anchors.right: parent.right anchors.right: parent.right
implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 - anchors.margins * 2 implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2
states: State { states: State {
name: "wide" name: "wide"
@@ -151,40 +144,13 @@ Item {
ChatHost { ChatHost {
id: convHost id: convHost
anchors.top: parent.top anchors.fill: parent
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
implicitWidth: Math.max(Math.min(parent.width, 800), Config.sidebar.sizes.width)
clip: true clip: true
onLoadingChanged: if (!loading) onLoadingChanged: if (!loading)
root.chatOpen = true 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"
fontStyle: Tokens.font.icon.extraLarge
color: Colors.tPalette.m3onSurfaceVariant
}
CustomText {
Layout.alignment: Qt.AlignCenter
text: qsTr("Start or create a chat")
color: Colors.tPalette.m3onSurfaceVariant
}
}
MouseArea { MouseArea {
id: dimArea id: dimArea
anchors.fill: parent anchors.fill: parent
@@ -196,8 +162,10 @@ Item {
const x = mouseX; const x = mouseX;
const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2; const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2;
if (x > invertedRect.borderLeft - margin) if (x > invertedRect.borderLeft - margin) {
ChatState.narrowSidebarExpanded = false; ChatState.narrowSidebarExpanded = false;
ChatState.fabExpanded = false;
}
} }
HoverHandler { HoverHandler {
+57 -3
View File
@@ -1,6 +1,7 @@
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
@@ -35,10 +36,10 @@ Item {
visible: height > 0 visible: height > 0
} }
CustomClippingRect { Item {
Layout.fillHeight: true Layout.fillHeight: true
Layout.fillWidth: true Layout.fillWidth: true
radius: Tokens.rounding.small clip: true
Item { Item {
id: pages id: pages
@@ -58,6 +59,7 @@ Item {
} }
CustomRect { CustomRect {
id: bg
anchors.fill: parent anchors.fill: parent
color: Colors.tPalette.m3surfaceContainerLow color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small radius: Tokens.rounding.small
@@ -96,11 +98,63 @@ Item {
CustomRect { CustomRect {
anchors.fill: parent anchors.fill: parent
color: Colors.tPalette.m3surfaceContainerLow color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small radius: Tokens.rounding.large
ChatPanel { ChatPanel {
anchors.fill: parent anchors.fill: parent
} }
GeminiGlow {
innerGlow: 50
z: 5
opacity: LlmClient.busy ? 1.0 : 0.0
glowSize: 0
visible: opacity > 0 && !ChatState.isWindow
layer.enabled: true
layer.effect: OpacityMask {
maskSource: fadeMask
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
Rectangle {
id: fadeMask
readonly property real fadeStartPos: {
const h = Math.max(1, height);
const nh = (h - h / 3) / h;
return nh;
}
anchors.fill: parent
layer.enabled: true
visible: false
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop {
color: Qt.rgba(0, 0, 0, 0)
position: 0
}
GradientStop {
color: Qt.rgba(0, 0, 0, 0)
position: fadeMask.fadeStartPos
}
GradientStop {
color: Qt.rgba(0, 0, 0, 1.0)
position: 1.0
}
}
}
} }
} }
} }
+2 -2
View File
@@ -30,9 +30,9 @@ Item {
} }
Connections { Connections {
target: Chat target: LlmClient
function onErrorOccurred(message: string): void { function onGenerationFailed(session: ChatSession, message: string): void {
if (root.shouldBeActive && root.chatActive) if (root.shouldBeActive && root.chatActive)
return; return;
+2 -1
View File
@@ -51,7 +51,8 @@ CustomRect {
value: Memory.percentage value: Memory.percentage
Behavior on clampedVal { Behavior on clampedVal {
Anim {} Anim {
}
} }
ColumnLayout { ColumnLayout {
+1 -1
View File
@@ -121,6 +121,6 @@ CustomRect {
activeIcon: "storage" activeIcon: "storage"
icon: modelData === Storage.primaryDisk ? "check" : "" icon: modelData === Storage.primaryDisk ? "check" : ""
text: modelData.mount text: modelData.name
} }
} }
+9
View File
@@ -0,0 +1,9 @@
import Quickshell
import QtQuick
import qs.Helpers
Scope {
Component.onCompleted: {
Wallpapers;
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ CustomRect {
bottomLeftRadius: last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall bottomLeftRadius: last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
bottomRightRadius: last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall bottomRightRadius: last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
color: Colors.tPalette.m3surfaceContainer color: Colors.tPalette.m3surfaceContainer
opacity: enabled && parent.enabled ? 1 : 0.5 opacity: enabled && (parent?.enabled ?? false) ? 1 : 0.5
topLeftRadius: first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall topLeftRadius: first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
topRightRadius: first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall topRightRadius: first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
+4 -4
View File
@@ -141,11 +141,11 @@ Item {
anchors.fill: parent anchors.fill: parent
bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
bottomRightRadius: 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 deformScale: 0
group: blobGroup group: blobGroup
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
} }
RowButton { RowButton {
@@ -154,11 +154,11 @@ Item {
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
color: "transparent" color: "transparent"
first: root.first
height: Math.min(implicitHeight, parent.height) height: Math.min(implicitHeight, parent.height)
icon: root.icon icon: root.icon
subtext: root.subtext
last: root.last last: root.last
first: root.first subtext: root.subtext
text: root.label text: root.label
transform: Matrix4x4 { transform: Matrix4x4 {
+4 -2
View File
@@ -235,8 +235,6 @@ Item {
implicitWidth: Math.round(fitW) implicitWidth: Math.round(fitW)
radius: Tokens.rounding.largeIncreased radius: Tokens.rounding.largeIncreased
onAspectChanged: console.log(aspect)
Loader { Loader {
active: opacity > 0 active: opacity > 0
anchors.centerIn: parent anchors.centerIn: parent
@@ -274,6 +272,10 @@ Item {
retainWhileLoading: true retainWhileLoading: true
smooth: true smooth: true
source: Wallpapers.current source: Wallpapers.current
sourceSize.height: {
const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1;
return imageSlot.height * dpr;
}
Behavior on opacity { Behavior on opacity {
Anim { Anim {
-12
View File
@@ -42,18 +42,6 @@ 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 {
+2 -2
View File
@@ -52,11 +52,11 @@ PageBase {
anchors.leftMargin: Tokens.padding.largeIncreased anchors.leftMargin: Tokens.padding.largeIncreased
anchors.margins: Tokens.padding.small anchors.margins: Tokens.padding.small
anchors.rightMargin: Tokens.padding.largeIncreased anchors.rightMargin: Tokens.padding.largeIncreased
spacing: Tokens.spacing.small spacing: Tokens.spacing.medium
IconImage { IconImage {
asynchronous: true asynchronous: true
implicitSize: Math.round(Tokens.font.icon.large * 1.8) implicitSize: Math.round(Tokens.font.icon.large.pointSize * 1.8)
source: Quickshell.iconPath(appItem.modelData.icon, "image-missing") source: Quickshell.iconPath(appItem.modelData.icon, "image-missing")
} }
@@ -88,6 +88,7 @@ PageBase {
id: selectScheme id: selectScheme
acceptLabel: qsTr("Confirm") acceptLabel: qsTr("Confirm")
settingAnchor: "panels-sidebar-llm-scheme"
enabled: Object.keys(model).length > 0 enabled: Object.keys(model).length > 0
header: qsTr("Select scheme") header: qsTr("Select scheme")
first: true first: true
+2 -14
View File
@@ -25,18 +25,6 @@ PageBase {
text: qsTr("None") text: qsTr("None")
} }
] ]
readonly property list<string> gpuValues: ["", "NVIDIA", "GENERIC", "None"]
function gpuKeyToIndex(key: string): int {
const u = (key ?? "").trim().toUpperCase();
if (u === "")
return 0; // Auto
if (u === "NVIDIA")
return 1;
if (u === "GENERIC")
return 2;
return 3; // None
}
title: qsTr("Services") title: qsTr("Services")
@@ -211,7 +199,7 @@ PageBase {
} }
SelectRow { SelectRow {
active: root.gpuItems[root.gpuKeyToIndex(Config.services.gpuType)] active: root.gpuItems[Config.services.gpuType]
last: true last: true
menuItems: root.gpuItems menuItems: root.gpuItems
menuOnTop: true menuOnTop: true
@@ -219,7 +207,7 @@ PageBase {
subtext: Gpu.name ? qsTr("Monitoring: %1").arg(Gpu.name) : qsTr("Override for GPU type") subtext: Gpu.name ? qsTr("Monitoring: %1").arg(Gpu.name) : qsTr("Override for GPU type")
text: qsTr("GPU") text: qsTr("GPU")
onSelected: item => Config.services.gpuType = root.gpuValues[root.gpuItems.indexOf(item)] onSelected: item => Config.services.gpuType = root.gpuItems.indexOf(item)
} }
} }
} }
+17
View File
@@ -11,6 +11,18 @@ Scope {
readonly property bool hasFullscreen: Hypr.focusedWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen === 2) ?? false readonly property bool hasFullscreen: Hypr.focusedWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen === 2) ?? false
property bool launcherInterrupted property bool launcherInterrupted
CustomShortcut {
description: "Open screenshot"
name: "screenshot"
onPressed: {
const states = ShellState.allStates();
for (const s of states) {
s.screenshot = true;
}
}
}
CustomShortcut { CustomShortcut {
description: "Toggle launcher" description: "Toggle launcher"
name: "toggle-launcher" name: "toggle-launcher"
@@ -26,6 +38,7 @@ Scope {
} }
CustomShortcut { CustomShortcut {
description: "Toggle drawing"
name: "toggle-drawing" name: "toggle-drawing"
onPressed: { onPressed: {
@@ -35,6 +48,7 @@ Scope {
} }
CustomShortcut { CustomShortcut {
description: "Toggle sidebar"
name: "toggle-nc" name: "toggle-nc"
onPressed: { onPressed: {
@@ -44,6 +58,7 @@ Scope {
} }
CustomShortcut { CustomShortcut {
description: "Show On-Screen-Display"
name: "show-osd" name: "show-osd"
onPressed: { onPressed: {
@@ -53,6 +68,7 @@ Scope {
} }
CustomShortcut { CustomShortcut {
description: "Toggle settings"
name: "toggle-settings" name: "toggle-settings"
onPressed: { onPressed: {
@@ -62,6 +78,7 @@ Scope {
} }
CustomShortcut { CustomShortcut {
description: "Toggle clipboard"
name: "toggle-clipboard" name: "toggle-clipboard"
onPressed: { onPressed: {
-51
View File
@@ -1,51 +0,0 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Hyprland
import QtQuick
import qs.Components
import qs.Helpers
import ZShell.Internal
Item {
id: root
readonly property var displayData: Wallpapers.getCrop(screen.name)
required property ShellScreen screen
property size screenResolution: Qt.size(screen.width * screenScale, screen.height * screenScale)
property real screenScale: Hyprland.monitorFor(screen).scale
anchors.fill: parent
Component.onCompleted: Hyprland.refreshMonitors()
WallpaperImage {
id: img
anchors.fill: parent
cropHeight: root.displayData?.height ?? 1.0
cropWidth: root.displayData?.width ?? 1.0
cropX: root.displayData?.x ?? 0.0
cropY: root.displayData?.y ?? 0.0
fadeDuration: 150
screenResolution: root.screenResolution
source: Wallpapers.current
Behavior on cropHeight {
Anim {
}
}
Behavior on cropWidth {
Anim {
}
}
Behavior on cropX {
Anim {
}
}
Behavior on cropY {
Anim {
}
}
}
}
-47
View File
@@ -1,47 +0,0 @@
import Quickshell
import QtQuick
import Quickshell.Wayland
import ZShell.Config
import qs.Modules.DesktopIcons
Loader {
active: Config.background.enabled
asynchronous: false
sourceComponent: Variants {
model: Quickshell.screens
PanelWindow {
id: root
required property var modelData
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Background
WlrLayershell.namespace: "ZShell-Wallpaper"
color: "transparent"
screen: modelData
anchors {
bottom: true
left: true
right: true
top: true
}
WallBackground {
screen: root.screen
}
Loader {
id: loader
active: Config.general.desktopIcons
anchors.fill: parent
sourceComponent: DesktopIcons {
}
}
}
}
}
+22 -1
View File
@@ -1,5 +1,25 @@
find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick QuickPrivate Concurrent Sql Network DBus CanvasPainter) find_package(Qt6 REQUIRED COMPONENTS
ShaderTools
Core
Qml
Gui
GuiPrivate
Quick
CanvasPainter
QuickPrivate
Concurrent
Sql
Network
DBus
CanvasPainter
WaylandClient
WaylandClientPrivate
)
find_package(PkgConfig REQUIRED) find_package(PkgConfig REQUIRED)
pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client)
pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl)
pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl)
pkg_check_modules(GL REQUIRED IMPORTED_TARGET gl)
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED) pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED)
@@ -17,6 +37,7 @@ qt_standard_project_setup(REQUIRES 6.9)
include(cmake/pch.cmake) include(cmake/pch.cmake)
include(cmake/qml-module.cmake) include(cmake/qml-module.cmake)
include(cmake/wayland-protocol.cmake)
add_library(zshell-util INTERFACE) add_library(zshell-util INTERFACE)
target_include_directories(zshell-util INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") target_include_directories(zshell-util INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include")
+4
View File
@@ -5,6 +5,10 @@ qml_module(ZShell-blobs
blobshape.cpp blobshape.cpp
blobrect.cpp blobrect.cpp
blobinvertedrect.cpp blobinvertedrect.cpp
blobgroup2.cpp
blobshape2.cpp
blobrect2.cpp
blobinvertedrect2.cpp
blobmaterial.cpp blobmaterial.cpp
LIBRARIES LIBRARIES
Qt::Quick Qt::Quick
+387
View File
@@ -0,0 +1,387 @@
#include "blobgroup2.hpp"
#include "blobinvertedrect2.hpp"
#include "blobshape2.hpp"
#include <qsggeometry.h>
#include <qsgnode.h>
#include <algorithm>
#include <cmath>
#include <cstring>
static float cpuSdBox(
float px, float py, float cx, float cy, float hw, float hh) {
const float dx = std::abs(px - cx) - hw;
const float dy = std::abs(py - cy) - hh;
const float mdx = std::max(dx, 0.0f);
const float mdy = std::max(dy, 0.0f);
return std::sqrt(mdx * mdx + mdy * mdy) + std::min(std::max(dx, dy), 0.0f);
}
static float cpuSmoothstep(float edge0, float edge1, float x) {
const float t = std::clamp((x - edge0) / (edge1 - edge0), 0.0f, 1.0f);
return t * t * (3.0f - 2.0f * t);
}
static float cornerFillFactor(float sd, float smoothFactor) {
const float outside = cpuSmoothstep(0.0f, smoothFactor, sd);
const float inside = cpuSmoothstep(0.0f, -smoothFactor, sd);
return std::max(outside, inside);
}
BlobGroup2::BlobGroup2(QQuickItem* parent) : QQuickItem(parent) {
setFlag(ItemHasContents);
}
BlobGroup2::~BlobGroup2() {
for (auto* shape : std::as_const(m_shapes))
shape->m_group = nullptr;
if (m_invertedRect)
static_cast<BlobShape2*>(m_invertedRect)->m_group = nullptr;
}
void BlobGroup2::setSmoothing(qreal s) {
if (qFuzzyCompare(m_smoothing, s)) return;
m_smoothing = s;
emit smoothingChanged();
markDirty();
}
void BlobGroup2::setColor(const QColor& c) {
if (m_color == c) return;
m_color = c;
emit colorChanged();
markDirty();
}
void BlobGroup2::setCornerFill(bool e) {
if (m_cornerFill == e) return;
m_cornerFill = e;
emit cornerFillChanged();
markDirty();
}
void BlobGroup2::setShadowColor(const QColor& c) {
if (m_shadowColor == c) return;
m_shadowColor = c;
emit shadowColorChanged();
markDirty();
}
void BlobGroup2::setShadowRadius(qreal r) {
if (qFuzzyCompare(m_shadowRadius, r)) return;
m_shadowRadius = r;
emit shadowRadiusChanged();
markDirty();
}
void BlobGroup2::addShape(BlobShape2* shape) {
if (!shape || m_shapes.contains(shape)) return;
m_shapes.append(shape);
markDirty();
}
void BlobGroup2::removeShape(BlobShape2* shape) {
m_shapes.removeOne(shape);
markDirty();
}
void BlobGroup2::setInvertedRect(BlobInvertedRect2* rect) {
if (m_invertedRect == rect) return;
m_invertedRect = rect;
markDirty();
}
void BlobGroup2::clearInvertedRect(BlobInvertedRect2* rect) {
if (m_invertedRect != rect) return;
m_invertedRect = nullptr;
markDirty();
}
void BlobGroup2::markDirty() {
m_physicsUpdated = false;
m_dataBuilt = false;
// Physics runs during polish, before the render pass. The panel content is
// transformed by deformMatrix from QML, so the matrix has to be final for this
// frame; building the data in updatePaintNode would let the transform lag one
// frame behind the material.
polish();
update();
}
void BlobGroup2::updatePolish() {
// The group is the only thing that draws, so it also drives the shapes'
// physics.
ensureDataBuilt();
}
void BlobGroup2::ensurePhysicsUpdated() {
if (m_physicsUpdated) return;
m_physicsUpdated = true;
for (auto* shape : std::as_const(m_shapes))
shape->updatePhysics();
}
void BlobGroup2::ensureDataBuilt() {
if (m_dataBuilt) return;
ensurePhysicsUpdated();
// Physics can request the next frame through markDirty(); the data is
// refreshed here anyway, so the flag is set after it runs.
m_dataBuilt = true;
m_data.rects.clear();
m_bounds = QRectF();
const float smoothFactor = static_cast<float>(m_smoothing);
// The same reach the shader uses to decide whether a rect can contribute, plus
// the shadow, so every pixel the shader can give a value to is inside the node.
const double extent = static_cast<double>(smoothFactor) * 1.5 +
static_cast<double>(m_shadowRadius);
// Group order, no pruning by proximity: the shader sees this list for every pixel. A
// shape that is not pushing is not in the list at all: shapes here are data for the
// group draw, so leaving a panel at rest out means it cannot define any part of the
// edge. A closing panel is still in the list while its curve runs out, even with no
// size left, which is what keeps the bounce.
// entryShapes maps a shared entry back to the shape it came from.
QVector<BlobShape2*> entryShapes;
for (auto* shape : std::as_const(m_shapes)) {
if (!shape->isMoving()) continue;
if (shape->width() <= 0 || shape->height() <= 0) continue;
if (static_cast<int>(m_data.rects.size()) >= BlobGroupData::kMaxRects)
continue;
const QPointF scene = shape->mapToScene(QPointF(0, 0));
const QMatrix4x4& dm = shape->m_deformMatrix;
BlobRectData r;
r.cx = static_cast<float>(scene.x() + shape->width() / 2.0);
r.cy = static_cast<float>(scene.y() + shape->height() / 2.0);
r.hw = static_cast<float>(shape->width()) * 0.5f;
r.hh = static_cast<float>(shape->height()) * 0.5f;
shape->cornerRadii(r.radius);
r.offsetX = dm(0, 3);
r.offsetY = dm(1, 3);
const float a = dm(0, 0), b = dm(1, 0);
const float c = dm(0, 1), e = dm(1, 1);
const float det = a * e - c * b;
const float invDet = std::abs(det) > 1e-6f ? 1.0f / det : 1.0f;
r.invDeform[0] = e * invDet;
r.invDeform[1] = -b * invDet;
r.invDeform[2] = -c * invDet;
r.invDeform[3] = a * invDet;
const float halfTr = 0.5f * (a + e);
const float halfDiff = 0.5f * (a - e);
r.minEig = halfTr - std::sqrt(halfDiff * halfDiff + c * c);
r.screenHalfX = std::abs(a) * r.hw + std::abs(c) * r.hh;
r.screenHalfY = std::abs(b) * r.hw + std::abs(e) * r.hh;
m_data.rects.append(r);
m_bounds = m_bounds.united(QRectF(
static_cast<double>(r.cx) + static_cast<double>(r.offsetX) -
static_cast<double>(r.screenHalfX) - extent,
static_cast<double>(r.cy) + static_cast<double>(r.offsetY) -
static_cast<double>(r.screenHalfY) - extent,
2.0 * (static_cast<double>(r.screenHalfX) + extent),
2.0 * (static_cast<double>(r.screenHalfY) + extent)));
entryShapes.append(shape);
}
const int count = static_cast<int>(m_data.rects.size());
// Exclusion gates only the smooth blend.
for (int i = 0; i < count; ++i) {
int mask = 0;
BlobShape2* const si = entryShapes[i];
for (int j = 0; j < count; ++j) {
if (j == i) continue;
BlobShape2* const sj = entryShapes[j];
if (si->isExcluded(sj) || sj->isExcluded(si)) mask |= (1 << j);
}
m_data.rects[i].excludeMask = mask;
}
constexpr float minR = 2.0f;
const bool cornerFill = m_cornerFill;
for (int i = 0; i < count; ++i) {
auto& ri = m_data.rects[i];
BlobShape2* const si = entryShapes[i];
float fTr = 1.0f, fBr = 1.0f, fBl = 1.0f, fTl = 1.0f;
const float cTrX = ri.cx + ri.hw, cTrY = ri.cy - ri.hh;
const float cBrX = ri.cx + ri.hw, cBrY = ri.cy + ri.hh;
const float cBlX = ri.cx - ri.hw, cBlY = ri.cy + ri.hh;
const float cTlX = ri.cx - ri.hw, cTlY = ri.cy - ri.hh;
if (cornerFill) {
for (int j = 0; j < count; ++j) {
if (j == i) continue;
if (ri.excludeMask & (1 << j)) continue;
BlobShape2* const sj = entryShapes[j];
if (si->isCornerExcluded(sj) || sj->isCornerExcluded(si))
continue;
const auto& rj = m_data.rects[j];
fTr = std::min(
fTr,
cornerFillFactor(
cpuSdBox(cTrX, cTrY, rj.cx, rj.cy, rj.hw, rj.hh),
smoothFactor));
fBr = std::min(
fBr,
cornerFillFactor(
cpuSdBox(cBrX, cBrY, rj.cx, rj.cy, rj.hw, rj.hh),
smoothFactor));
fBl = std::min(
fBl,
cornerFillFactor(
cpuSdBox(cBlX, cBlY, rj.cx, rj.cy, rj.hw, rj.hh),
smoothFactor));
fTl = std::min(
fTl,
cornerFillFactor(
cpuSdBox(cTlX, cTlY, rj.cx, rj.cy, rj.hw, rj.hh),
smoothFactor));
}
}
ri.radius[0] = std::max(ri.radius[0] * fTr, minR);
ri.radius[1] = std::max(ri.radius[1] * fBr, minR);
ri.radius[2] = std::max(ri.radius[2] * fBl, minR);
ri.radius[3] = std::max(ri.radius[3] * fTl, minR);
}
// Inverted rect. The frame term is evaluated for every pixel in the bounds,
// so the group flag cannot be gated per shape.
if (!m_invertedRect) return;
auto* inv = static_cast<BlobShape2*>(m_invertedRect);
if (inv->width() <= 0 || inv->height() <= 0) return;
const QPointF invScene = inv->mapToScene(QPointF(0, 0));
const float outerCX = static_cast<float>(invScene.x() + inv->width() / 2.0);
const float outerCY =
static_cast<float>(invScene.y() + inv->height() / 2.0);
const float outerHW = static_cast<float>(inv->width()) * 0.5f;
const float outerHH = static_cast<float>(inv->height()) * 0.5f;
const auto* invRect = m_invertedRect;
const float innerCX =
outerCX + static_cast<float>(
(invRect->borderLeft() - invRect->borderRight()) / 2.0);
const float innerCY =
outerCY + static_cast<float>(
(invRect->borderTop() - invRect->borderBottom()) / 2.0);
const float innerHW =
outerHW - static_cast<float>(
(invRect->borderLeft() + invRect->borderRight()) / 2.0);
const float innerHH =
outerHH - static_cast<float>(
(invRect->borderTop() + invRect->borderBottom()) / 2.0);
m_data.hasInverted = true;
m_data.invertedRadius = static_cast<float>(invRect->radius());
m_data.invertedOuter[0] = outerCX;
m_data.invertedOuter[1] = outerCY;
m_data.invertedOuter[2] = outerHW;
m_data.invertedOuter[3] = outerHH;
m_data.invertedInner[0] = innerCX;
m_data.invertedInner[1] = innerCY;
m_data.invertedInner[2] = innerHW;
m_data.invertedInner[3] = innerHH;
m_bounds = m_bounds.united(QRectF(
static_cast<double>(outerCX) - static_cast<double>(outerHW) - extent,
static_cast<double>(outerCY) - static_cast<double>(outerHH) - extent,
2.0 * (static_cast<double>(outerHW) + extent),
2.0 * (static_cast<double>(outerHH) + extent)));
}
void BlobGroup2::fillMaterial(BlobMaterial* material) {
material->m_paddedX = static_cast<float>(m_bounds.x());
material->m_paddedY = static_cast<float>(m_bounds.y());
material->m_paddedW = static_cast<float>(m_bounds.width());
material->m_paddedH = static_cast<float>(m_bounds.height());
material->m_smoothFactor = static_cast<float>(m_smoothing);
// The single group draw has no owner, so the shader skips the ownership test.
material->m_myIndex = -2;
material->m_color = m_color;
material->m_hasInverted = m_data.hasInverted ? 1 : 0;
material->m_invertedRadius = data().invertedRadius;
memcpy(
material->m_invertedOuter,
data().invertedOuter,
sizeof(data().invertedOuter));
memcpy(
material->m_invertedInner,
data().invertedInner,
sizeof(data().invertedInner));
material->m_shadowRadius = static_cast<float>(m_shadowRadius);
material->m_shadowColor[0] = static_cast<float>(m_shadowColor.redF());
material->m_shadowColor[1] = static_cast<float>(m_shadowColor.greenF());
material->m_shadowColor[2] = static_cast<float>(m_shadowColor.blueF());
material->m_shadowColor[3] = static_cast<float>(m_shadowColor.alphaF());
const int count =
static_cast<int>(qMin(data().rects.size(), qsizetype(16)));
material->m_rectCount = count;
for (int i = 0; i < count; ++i)
material->m_rects[i] = data().rects[i];
}
QSGNode* BlobGroup2::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) {
// Data is built during polish. This only covers a render pass that happens
// without one.
ensureDataBuilt();
if (m_data.rects.isEmpty() && !m_data.hasInverted) {
delete oldNode;
return nullptr;
}
auto* node = static_cast<QSGGeometryNode*>(oldNode);
if (!node) {
node = new QSGGeometryNode;
auto* geometry =
new QSGGeometry(QSGGeometry::defaultAttributes_TexturedPoint2D(), 4);
geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip);
node->setGeometry(geometry);
node->setFlag(QSGNode::OwnsGeometry);
auto* material = new BlobMaterial;
material->setFlag(QSGMaterial::Blending);
node->setMaterial(material);
node->setFlag(QSGNode::OwnsMaterial);
}
// The shader evaluates the field at the scene position a vertex maps to, so
// the group item itself must not be transformed. The vertex positions are the
// bounds in this item's frame; the material carries the same rect in scene
// coordinates.
auto* v = node->geometry()->vertexDataAsTexturedPoint2D();
const QPointF p0 = mapFromScene(m_bounds.topLeft());
const QPointF p1 = mapFromScene(m_bounds.bottomRight());
v[0].set(static_cast<float>(p0.x()), static_cast<float>(p0.y()), 0.0f, 0.0f);
v[1].set(static_cast<float>(p1.x()), static_cast<float>(p0.y()), 1.0f, 0.0f);
v[2].set(static_cast<float>(p0.x()), static_cast<float>(p1.y()), 0.0f, 1.0f);
v[3].set(static_cast<float>(p1.x()), static_cast<float>(p1.y()), 1.0f, 1.0f);
node->markDirty(QSGNode::DirtyGeometry);
fillMaterial(static_cast<BlobMaterial*>(node->material()));
node->markDirty(QSGNode::DirtyMaterial);
return node;
}
+128
View File
@@ -0,0 +1,128 @@
#pragma once
#include "blobmaterial.hpp"
#include <qcolor.h>
#include <qlist.h>
#include <qobject.h>
#include <qqmlengine.h>
#include <qquickitem.h>
#include <qrect.h>
#include <qsgnode.h>
#include <qvector.h>
class BlobShape2;
class BlobInvertedRect2;
// One shared valuation for a whole group, drawn by one node.
//
// The shapes here are data only: they say where the material should be. The group
// draws a single quad over everything the shader can evaluate, so a pixel cannot
// be evaluated by one shape and missed by another, and no second draw can
// disagree with the first. That is what makes overlapping shapes read as one
// surface - one value, one writer, one alpha per pixel.
struct BlobGroupData {
// The shader reads rectData[80] = 16 entries, and excludeMask is a 32-bit
// mask. The shared list is capped at 16, in group order, so the cap is the
// same list for every pixel.
static constexpr int kMaxRects = 16;
QVector<BlobRectData> rects;
bool hasInverted = false;
float invertedRadius = 0;
float invertedOuter[4] = {};
float invertedInner[4] = {};
};
class BlobGroup2 : public QQuickItem {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(
qreal smoothing READ smoothing WRITE setSmoothing NOTIFY
smoothingChanged)
Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged)
Q_PROPERTY(
bool cornerFill READ cornerFill WRITE setCornerFill NOTIFY
cornerFillChanged)
Q_PROPERTY(
QColor shadowColor READ shadowColor WRITE setShadowColor NOTIFY
shadowColorChanged)
Q_PROPERTY(
qreal shadowRadius READ shadowRadius WRITE setShadowRadius NOTIFY
shadowRadiusChanged)
friend class BlobShape2;
public:
explicit BlobGroup2(QQuickItem* parent = nullptr);
~BlobGroup2() override;
[[nodiscard]] qreal smoothing() const { return m_smoothing; }
void setSmoothing(qreal s);
[[nodiscard]] QColor color() const { return m_color; }
void setColor(const QColor& c);
[[nodiscard]] bool cornerFill() const { return m_cornerFill; }
void setCornerFill(bool e);
[[nodiscard]] QColor shadowColor() const { return m_shadowColor; }
void setShadowColor(const QColor& c);
[[nodiscard]] qreal shadowRadius() const { return m_shadowRadius; }
void setShadowRadius(qreal r);
void addShape(BlobShape2* shape);
void removeShape(BlobShape2* shape);
void setInvertedRect(BlobInvertedRect2* rect);
void clearInvertedRect(BlobInvertedRect2* rect);
[[nodiscard]] const QList<BlobShape2*>& shapes() const { return m_shapes; }
[[nodiscard]] BlobInvertedRect2* invertedRect() const {
return m_invertedRect;
}
[[nodiscard]] const BlobGroupData& data() const { return m_data; }
// One draw per group, so a change to any shape changes the value for every
// pixel. Refresh the whole group.
void markDirty();
signals:
void smoothingChanged();
void colorChanged();
void cornerFillChanged();
void shadowColorChanged();
void shadowRadiusChanged();
protected:
void updatePolish() override;
QSGNode* updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) override;
private:
void ensurePhysicsUpdated();
void ensureDataBuilt();
void fillMaterial(BlobMaterial* material);
qreal m_smoothing = 32.0;
QColor m_color{0x44, 0x88, 0xff};
bool m_cornerFill = true;
QColor m_shadowColor{0, 0, 0, 128};
qreal m_shadowRadius = 0.0;
QList<BlobShape2*> m_shapes;
BlobInvertedRect2* m_invertedRect = nullptr;
bool m_physicsUpdated = false;
bool m_dataBuilt = false;
BlobGroupData m_data;
// Scene-space union of what the shader can evaluate for any shape. The node
// covers exactly this, so gaps are not possible.
QRectF m_bounds;
};
@@ -0,0 +1,44 @@
#include "blobinvertedrect2.hpp"
#include "blobgroup2.hpp"
BlobInvertedRect2::BlobInvertedRect2(QQuickItem* parent) : BlobShape2(parent) {}
BlobInvertedRect2::~BlobInvertedRect2() {
if (m_group) m_group->clearInvertedRect(this);
}
void BlobInvertedRect2::setBorderLeft(qreal v) {
if (qFuzzyCompare(m_borderLeft, v)) return;
m_borderLeft = v;
emit borderLeftChanged();
if (m_group) m_group->markDirty();
}
void BlobInvertedRect2::setBorderRight(qreal v) {
if (qFuzzyCompare(m_borderRight, v)) return;
m_borderRight = v;
emit borderRightChanged();
if (m_group) m_group->markDirty();
}
void BlobInvertedRect2::setBorderTop(qreal v) {
if (qFuzzyCompare(m_borderTop, v)) return;
m_borderTop = v;
emit borderTopChanged();
if (m_group) m_group->markDirty();
}
void BlobInvertedRect2::setBorderBottom(qreal v) {
if (qFuzzyCompare(m_borderBottom, v)) return;
m_borderBottom = v;
emit borderBottomChanged();
if (m_group) m_group->markDirty();
}
void BlobInvertedRect2::registerWithGroup() {
if (m_group) m_group->setInvertedRect(this);
}
void BlobInvertedRect2::unregisterFromGroup() {
if (m_group) m_group->clearInvertedRect(this);
}
@@ -0,0 +1,58 @@
#pragma once
#include "blobshape2.hpp"
#include <qqmlengine.h>
class BlobInvertedRect2 : public BlobShape2 {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(
qreal borderLeft READ borderLeft WRITE setBorderLeft NOTIFY
borderLeftChanged)
Q_PROPERTY(
qreal borderRight READ borderRight WRITE setBorderRight NOTIFY
borderRightChanged)
Q_PROPERTY(
qreal borderTop READ borderTop WRITE setBorderTop NOTIFY
borderTopChanged)
Q_PROPERTY(
qreal borderBottom READ borderBottom WRITE setBorderBottom NOTIFY
borderBottomChanged)
public:
explicit BlobInvertedRect2(QQuickItem* parent = nullptr);
~BlobInvertedRect2() override;
[[nodiscard]] qreal borderLeft() const { return m_borderLeft; }
void setBorderLeft(qreal v);
[[nodiscard]] qreal borderRight() const { return m_borderRight; }
void setBorderRight(qreal v);
[[nodiscard]] qreal borderTop() const { return m_borderTop; }
void setBorderTop(qreal v);
[[nodiscard]] qreal borderBottom() const { return m_borderBottom; }
void setBorderBottom(qreal v);
signals:
void borderLeftChanged();
void borderRightChanged();
void borderTopChanged();
void borderBottomChanged();
protected:
void registerWithGroup() override;
void unregisterFromGroup() override;
private:
qreal m_borderLeft = 0;
qreal m_borderRight = 0;
qreal m_borderTop = 0;
qreal m_borderBottom = 0;
};
+7 -2
View File
@@ -33,7 +33,7 @@ bool BlobMaterialShader::updateUniformData(
Q_UNUSED(oldMaterial); Q_UNUSED(oldMaterial);
auto* mat = static_cast<BlobMaterial*>(newMaterial); auto* mat = static_cast<BlobMaterial*>(newMaterial);
QByteArray* buf = state.uniformData(); QByteArray* buf = state.uniformData();
Q_ASSERT(buf->size() >= 1440); Q_ASSERT(buf->size() >= 1472);
if (state.isMatrixDirty()) { if (state.isMatrixDirty()) {
const QMatrix4x4 m = state.combinedMatrix(); const QMatrix4x4 m = state.combinedMatrix();
@@ -56,7 +56,7 @@ bool BlobMaterialShader::updateUniformData(
// Rect count (offset 88) // Rect count (offset 88)
memcpy(buf->data() + 88, &mat->m_rectCount, 4); memcpy(buf->data() + 88, &mat->m_rectCount, 4);
// My index (offset 92) // My index (offset 92). -2 skips the ownership test in the shader.
memcpy(buf->data() + 92, &mat->m_myIndex, 4); memcpy(buf->data() + 92, &mat->m_myIndex, 4);
// Color as vec4 (offset 96, 16 bytes) // Color as vec4 (offset 96, 16 bytes)
@@ -100,5 +100,10 @@ bool BlobMaterialShader::updateUniformData(
memcpy(buf->data() + base + 64, r.radius, 16); memcpy(buf->data() + base + 64, r.radius, 16);
} }
// Shadow: after rectData, which ends at 1440. shadowColor is 16-byte aligned,
// so it lands at 1456.
memcpy(buf->data() + 1440, &mat->m_shadowRadius, 4);
memcpy(buf->data() + 1456, mat->m_shadowColor, 16);
return true; return true;
} }
+5
View File
@@ -32,12 +32,17 @@ class BlobMaterial : public QSGMaterial {
float m_paddedH = 0; float m_paddedH = 0;
float m_smoothFactor = 32.0f; float m_smoothFactor = 32.0f;
int m_rectCount = 0; int m_rectCount = 0;
// -2 means no owner test: the single group draw writes every pixel it covers.
int m_myIndex = -2; int m_myIndex = -2;
QColor m_color{0x44, 0x88, 0xff}; QColor m_color{0x44, 0x88, 0xff};
int m_hasInverted = 0; int m_hasInverted = 0;
float m_invertedRadius = 0; float m_invertedRadius = 0;
float m_invertedOuter[4] = {}; float m_invertedOuter[4] = {};
float m_invertedInner[4] = {}; float m_invertedInner[4] = {};
// Phase 2: shadow around the material edges. Matches the uniform block, which
// puts these after rectData.
float m_shadowRadius = 0.0f;
float m_shadowColor[4] = {0.0f, 0.0f, 0.0f, 0.5f};
BlobRectData m_rects[16] = {}; BlobRectData m_rects[16] = {};
}; };
+73 -46
View File
@@ -5,6 +5,10 @@
#include <cmath> #include <cmath>
#include <qnamespace.h> #include <qnamespace.h>
namespace {
constexpr float k_retireMs = 120.0f;
}
BlobRect::BlobRect(QQuickItem* parent) : BlobShape(parent) {} BlobRect::BlobRect(QQuickItem* parent) : BlobShape(parent) {}
BlobRect::~BlobRect() { BlobRect::~BlobRect() {
@@ -12,41 +16,75 @@ BlobRect::~BlobRect() {
} }
void BlobRect::updatePolish() { void BlobRect::updatePolish() {
if (m_physicsActive) { BlobShape::updatePolish();
float totalDelta = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) +
std::abs(m_dm11 - 1.0f);
float totalVel =
std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11);
if (totalDelta < 0.004f && totalVel < 0.05f) { if (!m_physicsActive) return;
if (m_retiring) {
const float t = std::clamp(
static_cast<float>(m_retireElapsed.elapsed()) / k_retireMs,
0.0f,
1.0f);
const float s = t * t * (3.0f - 2.0f * t);
if (t >= 1.0f) {
m_dm00 = 1.0f; m_dm00 = 1.0f;
m_dm01 = 0.0f; m_dm01 = 0.0f;
m_dm11 = 1.0f; m_dm11 = 1.0f;
m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f;
m_deformMatrix = QMatrix4x4(); m_deformMatrix = QMatrix4x4();
emit rawDeformMatrixChanged(); m_retiring = false;
updateCenteredDeformMatrix();
m_physicsActive = false; m_physicsActive = false;
if (m_group) {
QMetaObject::invokeMethod(
this,
[this]() {
if (m_group) m_group->markDirty();
},
Qt::QueuedConnection);
}
} else { } else {
QMetaObject::invokeMethod( m_dm00 = m_retire00 + (1.0f - m_retire00) * s;
this, m_dm01 = m_retire01 - m_retire01 * s;
[this]() { m_dm11 = m_retire11 + (1.0f - m_retire11) * s;
if (m_physicsActive && m_group) m_group->markDirty(); m_deformMatrix = QMatrix4x4(
}, m_dm00,
Qt::QueuedConnection); m_dm01,
0,
0,
m_dm01,
m_dm11,
0,
0,
0,
0,
1,
0,
0,
0,
0,
1);
} }
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
if (!m_physicsActive) return;
} else {
const float totalDelta = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) +
std::abs(m_dm11 - 1.0f);
const float totalVel =
std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11);
if (totalDelta < 0.004f && totalVel < 0.05f) beginRetire();
} }
BlobShape::updatePolish(); QMetaObject::invokeMethod(
this,
[this]() {
if (m_physicsActive && m_group) m_group->markDirty();
},
Qt::QueuedConnection);
}
void BlobRect::beginRetire() {
m_retire00 = m_dm00;
m_retire01 = m_dm01;
m_retire11 = m_dm11;
m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f;
m_retireElapsed.start();
m_retiring = true;
} }
void BlobRect::updatePhysics() { void BlobRect::updatePhysics() {
@@ -74,6 +112,13 @@ void BlobRect::updatePhysics() {
const float speed = std::sqrt(velX * velX + velY * velY); const float speed = std::sqrt(velX * velX + velY * velY);
if (m_retiring) {
if (speed > 5.0f)
m_retiring = false;
else
return;
}
if (!m_physicsActive) { if (!m_physicsActive) {
if (speed < 5.0f) return; if (speed < 5.0f) return;
m_physicsActive = true; m_physicsActive = true;
@@ -297,6 +342,8 @@ void BlobRect::excludeCornersRemoveLast(QQmlListProperty<BlobRect>* prop) {
} }
void BlobRect::checkAtRest(float speed) { void BlobRect::checkAtRest(float speed) {
if (m_retiring) return;
constexpr float kEpsilon = 0.002f; constexpr float kEpsilon = 0.002f;
const bool atRest = const bool atRest =
std::abs(m_dm00 - 1.0f) < kEpsilon && std::abs(m_dm01) < kEpsilon && std::abs(m_dm00 - 1.0f) < kEpsilon && std::abs(m_dm01) < kEpsilon &&
@@ -304,25 +351,5 @@ void BlobRect::checkAtRest(float speed) {
std::abs(m_dmVel01) < kEpsilon && std::abs(m_dmVel11) < kEpsilon && std::abs(m_dmVel01) < kEpsilon && std::abs(m_dmVel11) < kEpsilon &&
speed < 5.0f; speed < 5.0f;
if (atRest) { if (atRest) beginRetire();
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
m_dmVel00 = 0.0f;
m_dmVel01 = 0.0f;
m_dmVel11 = 0.0f;
m_deformMatrix = QMatrix4x4();
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
m_physicsActive = false;
if (m_group) {
QMetaObject::invokeMethod(
this,
[this]() {
if (m_group) m_group->markDirty();
},
Qt::QueuedConnection);
}
}
} }
+10 -7
View File
@@ -50,9 +50,9 @@ class BlobRect : public BlobShape {
[[nodiscard]] qreal damping() const { return m_damping; } [[nodiscard]] qreal damping() const { return m_damping; }
void setDamping(qreal d) { void setDamping(qreal value) {
if (!qFuzzyCompare(m_damping, d)) { if (!qFuzzyCompare(m_damping, value)) {
m_damping = d; m_damping = value;
emit dampingChanged(); emit dampingChanged();
} }
} }
@@ -108,20 +108,23 @@ class BlobRect : public BlobShape {
private: private:
void checkAtRest(float speed); void checkAtRest(float speed);
void beginRetire();
// Physics state
QPointF m_prevScenePos; QPointF m_prevScenePos;
QElapsedTimer m_elapsed; QElapsedTimer m_elapsed;
bool m_physicsActive = false; bool m_physicsActive = false;
bool m_hasPrevPos = false; bool m_hasPrevPos = false;
// Symmetric 2x2 deformation matrix components (3 independent: m00, m01, bool m_retiring = false;
// m11) Rest state is identity: m00=1, m01=0, m11=1 QElapsedTimer m_retireElapsed;
float m_retire00 = 1.0f;
float m_retire01 = 0.0f;
float m_retire11 = 1.0f;
float m_dm00 = 1.0f; float m_dm00 = 1.0f;
float m_dm01 = 0.0f; float m_dm01 = 0.0f;
float m_dm11 = 1.0f; float m_dm11 = 1.0f;
// Spring velocities for each component
float m_dmVel00 = 0.0f; float m_dmVel00 = 0.0f;
float m_dmVel01 = 0.0f; float m_dmVel01 = 0.0f;
float m_dmVel11 = 0.0f; float m_dmVel11 = 0.0f;
+353
View File
@@ -0,0 +1,353 @@
#include "blobrect2.hpp"
#include "blobgroup2.hpp"
#include <algorithm>
#include <cmath>
namespace {
constexpr float k_retireMs = 120.0f;
} // namespace
BlobRect2::BlobRect2(QQuickItem* parent) : BlobShape2(parent) {}
BlobRect2::~BlobRect2() {
if (m_group) m_group->removeShape(this);
}
void BlobRect2::finishAtIdentity() {
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
m_deformMatrix = QMatrix4x4();
m_retiring = false;
m_physicsActive = false;
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
}
void BlobRect2::beginRetire() {
// The settle check would otherwise re-enter this every frame and restart the
// curve timer, so the curve never reaches t = 1.
if (m_retiring) return;
m_retire00 = m_dm00;
m_retire01 = m_dm01;
m_retire11 = m_dm11;
m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f;
m_retireElapsed.start();
m_retiring = true;
}
void BlobRect2::updatePhysics() {
// Shapes are data only, so the group drives physics here.
if (m_retiring) {
const float t = std::clamp(
static_cast<float>(m_retireElapsed.elapsed()) / k_retireMs,
0.0f,
1.0f);
const float s = t * t * (3.0f - 2.0f * t);
if (t >= 1.0f) {
finishAtIdentity();
return;
}
m_dm00 = m_retire00 + (1.0f - m_retire00) * s;
m_dm01 = m_retire01 - m_retire01 * s;
m_dm11 = m_retire11 + (1.0f - m_retire11) * s;
m_deformMatrix = QMatrix4x4(
m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
if (!m_physicsActive) return;
}
// No "not active" guard here: an inactive shape still has to be evaluated so it
// can see the panel start moving and become active. The frame loop is only kept
// alive while active, and it is requested before anything can return early —
// requesting it at the end lets a shape stop being evaluated with the matrix
// still off identity, which is what left the panel contents blurry.
if (m_physicsActive && m_group) m_group->markDirty();
const QPointF scenePos = mapToScene(QPointF(width() / 2.0, height() / 2.0));
if (!m_hasPrevPos) {
m_prevScenePos = scenePos;
m_elapsed.start();
m_hasPrevPos = true;
return;
}
const float dt = static_cast<float>(m_elapsed.restart()) / 1000.0f;
if (dt > 0.1f || dt < 0.001f) {
m_prevScenePos = scenePos;
if (m_physicsActive) checkAtRest(0.0f);
return;
}
const float velX =
static_cast<float>(scenePos.x() - m_prevScenePos.x()) / dt;
const float velY =
static_cast<float>(scenePos.y() - m_prevScenePos.y()) / dt;
m_prevScenePos = scenePos;
const float speed = std::sqrt(velX * velX + velY * velY);
if (m_retiring) {
if (speed > 5.0f)
m_retiring = false;
else
return;
}
if (!m_physicsActive) {
if (speed < 5.0f) return;
m_physicsActive = true;
}
const float kStretchFactor = static_cast<float>(m_deformScale);
constexpr float kMaxStretch = 0.35f;
float target00 = 1.0f;
float target01 = 0.0f;
float target11 = 1.0f;
if (speed > 5.0f) {
const float targetStretch =
1.0f + std::min(speed * kStretchFactor, kMaxStretch);
const float targetCompress = 1.0f / targetStretch;
const float cosA = velX / speed;
const float sinA = velY / speed;
const float cos2 = cosA * cosA;
const float sin2 = sinA * sinA;
const float cs = cosA * sinA;
target00 = targetStretch * cos2 + targetCompress * sin2;
target01 = (targetStretch - targetCompress) * cs;
target11 = targetStretch * sin2 + targetCompress * cos2;
}
const float kStiffness = static_cast<float>(m_stiffness);
const float kDamping = static_cast<float>(m_damping);
const float invDamp = 1.0f / (1.0f + kDamping * dt);
m_dmVel00 = (m_dmVel00 - kStiffness * (m_dm00 - target00) * dt) * invDamp;
m_dm00 += m_dmVel00 * dt;
m_dmVel01 = (m_dmVel01 - kStiffness * (m_dm01 - target01) * dt) * invDamp;
m_dm01 += m_dmVel01 * dt;
m_dmVel11 = (m_dmVel11 - kStiffness * (m_dm11 - target11) * dt) * invDamp;
m_dm11 += m_dmVel11 * dt;
m_deformMatrix = QMatrix4x4(
m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
// Start the retire as soon as the matrix is close to identity, as in v1. Waiting
// for every term to fall below kEpsilon keeps the matrix just off identity for
// longer, and the panel content is transformed by it, which reads as blur on
// text.
const float totalDelta =
std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + std::abs(m_dm11 - 1.0f);
const float totalVel =
std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11);
// The residual is already inside the rest epsilon, so the eased curve would only
// be aborted or restarted every frame by the panel creeping. Snap instead.
if (totalDelta < 0.004f && totalVel < 0.05f) {
finishAtIdentity();
return;
}
checkAtRest(speed);
}
void BlobRect2::setTopLeftRadius(qreal r) {
if (!qFuzzyCompare(m_topLeftRadius, r)) {
m_topLeftRadius = r;
emit topLeftRadiusChanged();
if (m_group) m_group->markDirty();
}
}
void BlobRect2::setTopRightRadius(qreal r) {
if (!qFuzzyCompare(m_topRightRadius, r)) {
m_topRightRadius = r;
emit topRightRadiusChanged();
if (m_group) m_group->markDirty();
}
}
void BlobRect2::setBottomLeftRadius(qreal r) {
if (!qFuzzyCompare(m_bottomLeftRadius, r)) {
m_bottomLeftRadius = r;
emit bottomLeftRadiusChanged();
if (m_group) m_group->markDirty();
}
}
void BlobRect2::setBottomRightRadius(qreal r) {
if (!qFuzzyCompare(m_bottomRightRadius, r)) {
m_bottomRightRadius = r;
emit bottomRightRadiusChanged();
if (m_group) m_group->markDirty();
}
}
void BlobRect2::cornerRadii(float out[4]) const {
const auto maxR = static_cast<float>(std::min(width(), height())) * 0.5f;
const auto base = std::min(static_cast<float>(m_radius), maxR);
out[0] = std::min(
m_topRightRadius >= 0 ? static_cast<float>(m_topRightRadius) : base,
maxR);
out[1] = std::min(
m_bottomRightRadius >= 0 ? static_cast<float>(m_bottomRightRadius)
: base,
maxR);
out[2] = std::min(
m_bottomLeftRadius >= 0 ? static_cast<float>(m_bottomLeftRadius) : base,
maxR);
out[3] = std::min(
m_topLeftRadius >= 0 ? static_cast<float>(m_topLeftRadius) : base,
maxR);
}
bool BlobRect2::isExcluded(const BlobShape2* other) const {
for (const auto& ptr : m_exclude) {
if (ptr == other) return true;
}
return false;
}
bool BlobRect2::isCornerExcluded(const BlobShape2* other) const {
for (const auto& ptr : m_excludeCorners) {
if (ptr == other) return true;
}
return false;
}
QQmlListProperty<BlobRect2> BlobRect2::exclude() {
return QQmlListProperty<BlobRect2>(
this,
nullptr,
&excludeAppend,
&excludeCount,
&excludeAt,
&excludeClear,
&excludeReplace,
&excludeRemoveLast);
}
QQmlListProperty<BlobRect2> BlobRect2::excludeCorners() {
return QQmlListProperty<BlobRect2>(
this,
nullptr,
&excludeCornersAppend,
&excludeCornersCount,
&excludeCornersAt,
&excludeCornersClear,
&excludeCornersReplace,
&excludeCornersRemoveLast);
}
void BlobRect2::excludeAppend(
QQmlListProperty<BlobRect2>* prop, BlobRect2* rect) {
auto* self = static_cast<BlobRect2*>(prop->object);
self->m_exclude.append(rect);
if (self->m_group) self->m_group->markDirty();
emit self->excludeChanged();
}
qsizetype BlobRect2::excludeCount(QQmlListProperty<BlobRect2>* prop) {
auto* self = static_cast<BlobRect2*>(prop->object);
return self->m_exclude.size();
}
BlobRect2* BlobRect2::excludeAt(
QQmlListProperty<BlobRect2>* prop, qsizetype index) {
auto* self = static_cast<BlobRect2*>(prop->object);
return self->m_exclude.at(index);
}
void BlobRect2::excludeClear(QQmlListProperty<BlobRect2>* prop) {
auto* self = static_cast<BlobRect2*>(prop->object);
if (self->m_exclude.isEmpty()) return;
self->m_exclude.clear();
if (self->m_group) self->m_group->markDirty();
emit self->excludeChanged();
}
void BlobRect2::excludeReplace(
QQmlListProperty<BlobRect2>* prop, qsizetype index, BlobRect2* rect) {
auto* self = static_cast<BlobRect2*>(prop->object);
self->m_exclude[index] = rect;
if (self->m_group) self->m_group->markDirty();
emit self->excludeChanged();
}
void BlobRect2::excludeRemoveLast(QQmlListProperty<BlobRect2>* prop) {
auto* self = static_cast<BlobRect2*>(prop->object);
if (self->m_exclude.isEmpty()) return;
self->m_exclude.removeLast();
if (self->m_group) self->m_group->markDirty();
emit self->excludeChanged();
}
void BlobRect2::excludeCornersAppend(
QQmlListProperty<BlobRect2>* prop, BlobRect2* rect) {
auto* self = static_cast<BlobRect2*>(prop->object);
self->m_excludeCorners.append(rect);
if (self->m_group) self->m_group->markDirty();
emit self->excludeCornersChanged();
}
qsizetype BlobRect2::excludeCornersCount(QQmlListProperty<BlobRect2>* prop) {
auto* self = static_cast<BlobRect2*>(prop->object);
return self->m_excludeCorners.size();
}
BlobRect2* BlobRect2::excludeCornersAt(
QQmlListProperty<BlobRect2>* prop, qsizetype index) {
auto* self = static_cast<BlobRect2*>(prop->object);
return self->m_excludeCorners.at(index);
}
void BlobRect2::excludeCornersClear(QQmlListProperty<BlobRect2>* prop) {
auto* self = static_cast<BlobRect2*>(prop->object);
if (self->m_excludeCorners.isEmpty()) return;
self->m_excludeCorners.clear();
if (self->m_group) self->m_group->markDirty();
emit self->excludeCornersChanged();
}
void BlobRect2::excludeCornersReplace(
QQmlListProperty<BlobRect2>* prop, qsizetype index, BlobRect2* rect) {
auto* self = static_cast<BlobRect2*>(prop->object);
self->m_excludeCorners[index] = rect;
if (self->m_group) self->m_group->markDirty();
emit self->excludeCornersChanged();
}
void BlobRect2::excludeCornersRemoveLast(QQmlListProperty<BlobRect2>* prop) {
auto* self = static_cast<BlobRect2*>(prop->object);
if (self->m_excludeCorners.isEmpty()) return;
self->m_excludeCorners.removeLast();
if (self->m_group) self->m_group->markDirty();
emit self->excludeCornersChanged();
}
void BlobRect2::checkAtRest(float speed) {
if (m_retiring) return;
constexpr float kEpsilon = 0.002f;
const bool atRest =
std::abs(m_dm00 - 1.0f) < kEpsilon && std::abs(m_dm01) < kEpsilon &&
std::abs(m_dm11 - 1.0f) < kEpsilon && std::abs(m_dmVel00) < kEpsilon &&
std::abs(m_dmVel01) < kEpsilon && std::abs(m_dmVel11) < kEpsilon &&
speed < 5.0f;
if (atRest) beginRetire();
}
+191
View File
@@ -0,0 +1,191 @@
#pragma once
#include "blobshape2.hpp"
#include <qelapsedtimer.h>
#include <qpointer.h>
#include <qqmlengine.h>
#include <qqmllist.h>
class BlobRect2 : public BlobShape2 {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(
qreal stiffness READ stiffness WRITE setStiffness NOTIFY
stiffnessChanged)
Q_PROPERTY(qreal damping READ damping WRITE setDamping NOTIFY dampingChanged)
Q_PROPERTY(
qreal deformScale READ deformScale WRITE setDeformScale NOTIFY
deformScaleChanged)
Q_PROPERTY(
QQmlListProperty<BlobRect2> exclude READ exclude NOTIFY excludeChanged)
Q_PROPERTY(
QQmlListProperty<BlobRect2> excludeCorners READ excludeCorners NOTIFY
excludeCornersChanged)
Q_PROPERTY(
bool atRestOverride READ atRestOverride WRITE setAtRestOverride NOTIFY
atRestOverrideChanged)
Q_PROPERTY(
qreal topLeftRadius READ topLeftRadius WRITE setTopLeftRadius NOTIFY
topLeftRadiusChanged)
Q_PROPERTY(
qreal topRightRadius READ topRightRadius WRITE setTopRightRadius NOTIFY
topRightRadiusChanged)
Q_PROPERTY(
qreal bottomLeftRadius READ bottomLeftRadius WRITE setBottomLeftRadius
NOTIFY bottomLeftRadiusChanged)
Q_PROPERTY(
qreal bottomRightRadius READ bottomRightRadius WRITE
setBottomRightRadius NOTIFY bottomRightRadiusChanged)
public:
explicit BlobRect2(QQuickItem* parent = nullptr);
~BlobRect2() override;
[[nodiscard]] qreal stiffness() const { return m_stiffness; }
void setStiffness(qreal s) {
if (!qFuzzyCompare(m_stiffness, s)) {
m_stiffness = s;
emit stiffnessChanged();
}
}
[[nodiscard]] qreal damping() const { return m_damping; }
void setDamping(qreal value) {
if (!qFuzzyCompare(m_damping, value)) {
m_damping = value;
emit dampingChanged();
}
}
[[nodiscard]] qreal deformScale() const { return m_deformScale; }
void setDeformScale(qreal s) {
if (!qFuzzyCompare(m_deformScale, s)) {
m_deformScale = s;
emit deformScaleChanged();
}
}
QQmlListProperty<BlobRect2> exclude();
QQmlListProperty<BlobRect2> excludeCorners();
[[nodiscard]] bool atRestOverride() const { return m_atRestOverride; }
void setAtRestOverride(bool o) {
if (m_atRestOverride == o) return;
m_atRestOverride = o;
emit atRestOverrideChanged();
if (m_group) m_group->markDirty();
}
bool isExcluded(const BlobShape2* other) const override;
bool isCornerExcluded(const BlobShape2* other) const override;
// The shape that is pushing is the one that can shape the bar edge. A shape that is
// retiring still counts, because the retire curve is still moving the surface.
// atRestOverride keeps the shape in the group's data even at rest; when it is false
// the actual spring state decides, so it is not an override of the state itself.
bool isMoving() const override {
return m_atRestOverride || m_physicsActive || m_retiring;
}
void cornerRadii(float out[4]) const override;
[[nodiscard]] qreal topLeftRadius() const { return m_topLeftRadius; }
void setTopLeftRadius(qreal r);
[[nodiscard]] qreal topRightRadius() const { return m_topRightRadius; }
void setTopRightRadius(qreal r);
[[nodiscard]] qreal bottomLeftRadius() const { return m_bottomLeftRadius; }
void setBottomLeftRadius(qreal r);
[[nodiscard]] qreal bottomRightRadius() const {
return m_bottomRightRadius;
}
void setBottomRightRadius(qreal r);
signals:
void stiffnessChanged();
void dampingChanged();
void deformScaleChanged();
void excludeChanged();
void excludeCornersChanged();
void atRestOverrideChanged();
void topLeftRadiusChanged();
void topRightRadiusChanged();
void bottomLeftRadiusChanged();
void bottomRightRadiusChanged();
protected:
void updatePhysics() override;
private:
void checkAtRest(float speed);
void beginRetire();
void finishAtIdentity();
// Physics state
QPointF m_prevScenePos;
QElapsedTimer m_elapsed;
bool m_physicsActive = false;
bool m_hasPrevPos = false;
// Set from QML. Keeps the shape in the group's data while it is at rest; when it is
// false the spring state decides on its own.
bool m_atRestOverride = false;
bool m_retiring = false;
QElapsedTimer m_retireElapsed;
float m_retire00 = 1.0f;
float m_retire01 = 0.0f;
float m_retire11 = 1.0f;
// Symmetric 2x2 deformation matrix components (3 independent: m00, m01,
// m11) Rest state is identity: m00=1, m01=0, m11=1
float m_dm00 = 1.0f;
float m_dm01 = 0.0f;
float m_dm11 = 1.0f;
// Spring velocities for each component
float m_dmVel00 = 0.0f;
float m_dmVel01 = 0.0f;
float m_dmVel11 = 0.0f;
qreal m_stiffness = 200.0;
qreal m_damping = 16.0;
qreal m_deformScale = 0.0005;
qreal m_topLeftRadius = -1;
qreal m_topRightRadius = -1;
qreal m_bottomLeftRadius = -1;
qreal m_bottomRightRadius = -1;
QList<QPointer<BlobRect2>> m_exclude;
QList<QPointer<BlobRect2>> m_excludeCorners;
static void excludeAppend(
QQmlListProperty<BlobRect2>* prop, BlobRect2* rect);
static qsizetype excludeCount(QQmlListProperty<BlobRect2>* prop);
static BlobRect2* excludeAt(
QQmlListProperty<BlobRect2>* prop, qsizetype index);
static void excludeClear(QQmlListProperty<BlobRect2>* prop);
static void excludeReplace(
QQmlListProperty<BlobRect2>* prop, qsizetype index, BlobRect2* rect);
static void excludeRemoveLast(QQmlListProperty<BlobRect2>* prop);
static void excludeCornersAppend(
QQmlListProperty<BlobRect2>* prop, BlobRect2* rect);
static qsizetype excludeCornersCount(QQmlListProperty<BlobRect2>* prop);
static BlobRect2* excludeCornersAt(
QQmlListProperty<BlobRect2>* prop, qsizetype index);
static void excludeCornersClear(QQmlListProperty<BlobRect2>* prop);
static void excludeCornersReplace(
QQmlListProperty<BlobRect2>* prop, qsizetype index, BlobRect2* rect);
static void excludeCornersRemoveLast(QQmlListProperty<BlobRect2>* prop);
};
+6 -8
View File
@@ -168,8 +168,6 @@ void BlobShape::updatePolish() {
if (other->width() <= 0 || other->height() <= 0) continue; if (other->width() <= 0 || other->height() <= 0) continue;
if (isExcluded(other)) continue;
const QPointF otherScene = other->mapToScene(QPointF(0, 0)); const QPointF otherScene = other->mapToScene(QPointF(0, 0));
bool include = false; bool include = false;
@@ -194,7 +192,7 @@ void BlobShape::updatePolish() {
const QMatrix4x4& dm = other->m_deformMatrix; const QMatrix4x4& dm = other->m_deformMatrix;
const float a = dm(0, 0), b = dm(1, 0); const float a = dm(0, 0), b = dm(1, 0);
const float c = dm(0, 1), d = dm(1, 1); const float c = dm(0, 1), e = dm(1, 1);
BlobRectData r; BlobRectData r;
r.cx = static_cast<float>(otherScene.x() + other->width() / 2.0); r.cx = static_cast<float>(otherScene.x() + other->width() / 2.0);
@@ -205,19 +203,19 @@ void BlobShape::updatePolish() {
r.offsetX = dm(0, 3); r.offsetX = dm(0, 3);
r.offsetY = dm(1, 3); r.offsetY = dm(1, 3);
const float det = a * d - c * b; const float det = a * e - c * b;
const float invDet = std::abs(det) > 1e-6f ? 1.0f / det : 1.0f; const float invDet = std::abs(det) > 1e-6f ? 1.0f / det : 1.0f;
r.invDeform[0] = d * invDet; r.invDeform[0] = e * invDet;
r.invDeform[1] = -b * invDet; r.invDeform[1] = -b * invDet;
r.invDeform[2] = -c * invDet; r.invDeform[2] = -c * invDet;
r.invDeform[3] = a * invDet; r.invDeform[3] = a * invDet;
const float halfTr = 0.5f * (a + d); const float halfTr = 0.5f * (a + e);
const float halfDiff = 0.5f * (a - d); const float halfDiff = 0.5f * (a - e);
r.minEig = halfTr - std::sqrt(halfDiff * halfDiff + c * c); r.minEig = halfTr - std::sqrt(halfDiff * halfDiff + c * c);
r.screenHalfX = std::abs(a) * r.hw + std::abs(c) * r.hh; r.screenHalfX = std::abs(a) * r.hw + std::abs(c) * r.hh;
r.screenHalfY = std::abs(b) * r.hw + std::abs(d) * r.hh; r.screenHalfY = std::abs(b) * r.hw + std::abs(e) * r.hh;
m_cachedRects.append(r); m_cachedRects.append(r);
rectShapes.append(other); rectShapes.append(other);
+67
View File
@@ -0,0 +1,67 @@
#include "blobshape2.hpp"
#include "blobgroup2.hpp"
#include <algorithm>
#include <cmath>
BlobShape2::BlobShape2(QQuickItem* parent) : QQuickItem(parent) {}
void BlobShape2::setGroup(BlobGroup2* g) {
if (m_group == g) return;
if (m_group && isComponentComplete()) unregisterFromGroup();
m_group = g;
if (m_group && isComponentComplete()) registerWithGroup();
emit groupChanged();
if (m_group) m_group->markDirty();
}
void BlobShape2::setRadius(qreal r) {
if (qFuzzyCompare(m_radius, r)) return;
m_radius = r;
emit radiusChanged();
if (m_group) m_group->markDirty();
}
void BlobShape2::componentComplete() {
QQuickItem::componentComplete();
if (m_group) registerWithGroup();
}
void BlobShape2::geometryChange(
const QRectF& newGeometry, const QRectF& oldGeometry) {
QQuickItem::geometryChange(newGeometry, oldGeometry);
updateCenteredDeformMatrix();
// One draw covers the whole group, so any shape change is a group change.
if (m_group) m_group->markDirty();
}
void BlobShape2::updateCenteredDeformMatrix() {
const auto cx = static_cast<float>(width()) * 0.5f;
const auto cy = static_cast<float>(height()) * 0.5f;
QMatrix4x4 result;
result.translate(cx, cy);
result *= m_deformMatrix;
result.translate(-cx, -cy);
if (m_centeredDeformMatrix != result) {
m_centeredDeformMatrix = result;
emit deformMatrixChanged();
}
}
void BlobShape2::cornerRadii(float out[4]) const {
const auto maxR = static_cast<float>(std::min(width(), height())) * 0.5f;
const auto r = std::min(static_cast<float>(m_radius), maxR);
out[0] = r;
out[1] = r;
out[2] = r;
out[3] = r;
}
void BlobShape2::registerWithGroup() {
if (m_group) m_group->addShape(this);
}
void BlobShape2::unregisterFromGroup() {
if (m_group) m_group->removeShape(this);
}
+80
View File
@@ -0,0 +1,80 @@
#pragma once
#include "blobgroup2.hpp"
#include <qmatrix4x4.h>
#include <qquickitem.h>
class BlobGroup2;
// Data-only shape.
//
// The group draws a single node for the whole group, so a shape here contributes
// only what the shader needs: position, radii, exclusion, physics. A shape does
// not create a render node, so no pixel can be evaluated by one shape and missed
// by another.
class BlobShape2 : public QQuickItem {
Q_OBJECT
Q_PROPERTY(BlobGroup2* group READ group WRITE setGroup NOTIFY groupChanged)
Q_PROPERTY(qreal radius READ radius WRITE setRadius NOTIFY radiusChanged)
Q_PROPERTY(
QMatrix4x4 deformMatrix READ deformMatrix NOTIFY deformMatrixChanged)
Q_PROPERTY(
QMatrix4x4 rawDeformMatrix READ rawDeformMatrix NOTIFY
rawDeformMatrixChanged)
friend class BlobGroup2;
public:
explicit BlobShape2(QQuickItem* parent = nullptr);
~BlobShape2() override = default;
[[nodiscard]] BlobGroup2* group() const { return m_group; }
void setGroup(BlobGroup2* g);
[[nodiscard]] qreal radius() const { return m_radius; }
void setRadius(qreal r);
[[nodiscard]] QMatrix4x4 deformMatrix() const {
return m_centeredDeformMatrix;
}
[[nodiscard]] QMatrix4x4 rawDeformMatrix() const { return m_deformMatrix; }
signals:
void groupChanged();
void radiusChanged();
void deformMatrixChanged();
void rawDeformMatrixChanged();
protected:
void componentComplete() override;
void geometryChange(
const QRectF& newGeometry, const QRectF& oldGeometry) override;
virtual bool isExcluded(const BlobShape2*) const { return false; }
// Whether this shape is part of the group's data. Only a pushing shape is, so a shape
// at rest has no part in the draw. BlobRect2 also honours the QML atRestOverride.
virtual bool isMoving() const { return false; }
virtual bool isCornerExcluded(const BlobShape2* /*other*/) const {
return false;
}
virtual void cornerRadii(float out[4]) const;
// Called by the group when it rebuilds the shared data, once per frame.
virtual void updatePhysics() {}
virtual void registerWithGroup();
virtual void unregisterFromGroup();
void updateCenteredDeformMatrix();
BlobGroup2* m_group = nullptr;
qreal m_radius = 0;
QMatrix4x4 m_deformMatrix; // identity by default
QMatrix4x4 m_centeredDeformMatrix;
};
+43 -13
View File
@@ -19,6 +19,8 @@ layout(std140, binding = 0) uniform buf {
vec4 invertedOuter; vec4 invertedOuter;
vec4 invertedInner; vec4 invertedInner;
vec4 rectData[80]; vec4 rectData[80];
float shadowRadius;
vec4 shadowColor;
}; };
float sdRoundedBox(vec2 p, vec2 center, vec2 halfSize, float radius) { float sdRoundedBox(vec2 p, vec2 center, vec2 halfSize, float radius) {
@@ -63,9 +65,10 @@ float smaxSharpA(float a, float b, float k) {
void main() { void main() {
vec2 pixel = vec2(paddedX, paddedY) + qt_TexCoord0 * vec2(paddedW, paddedH); vec2 pixel = vec2(paddedX, paddedY) + qt_TexCoord0 * vec2(paddedW, paddedH);
// Phase 1: compute per-rect SDF, track owner. We can't smin yet because // Phase 1: compute per-rect SDF, and track which rect owns the pixel for the
// excluded pairs need to skip the smooth blend, which requires pairwise pass // per-shape path (BlobGroup). The single group draw (BlobGroup2) has no owner:
// below. // myIndex stays -2, so the ownership test is skipped and this pass writes every
// pixel the quad covers. Excluded pairs still need the pairwise pass below.
float dArr[16]; float dArr[16];
int owner = -2; int owner = -2;
float minDist = 1e10; float minDist = 1e10;
@@ -82,8 +85,10 @@ void main() {
// Offset center for asymmetric deformation // Offset center for asymmetric deformation
vec2 center = rect.xy + props.yz; vec2 center = rect.xy + props.yz;
// AABB early-out: skip rects far from this pixel // AABB early-out: skip rects far from this pixel. The reach has to include the
vec2 extent = sh.xy + vec2(smoothFactor * 1.5); // shadow, otherwise a rect that is only inside the shadow range would be
// skipped and its distance could not be used.
vec2 extent = sh.xy + vec2(smoothFactor * 1.5 + shadowRadius);
if (abs(pixel.x - center.x) > extent.x || if (abs(pixel.x - center.x) > extent.x ||
abs(pixel.y - center.y) > extent.y) { abs(pixel.y - center.y) > extent.y) {
dArr[i] = 1e10; dArr[i] = 1e10;
@@ -251,14 +256,39 @@ void main() {
} }
} }
// Each renderer only outputs pixels it owns, but allow rendering // A pixel this far from every rect cannot have a value, so it is not written.
// blend zones to prevent gaps (mergedSdf < smoothFactor means in blend) // The one-unit margin keeps the outermost pixels of the quad out of the AA: their
// myIndex == -1: inverted rect renders border-owned pixels // derivatives are undefined (the quad neighbour is outside the primitive), which
// myIndex >= 0: individual rect renders its owned pixels // is what drew a line along the coverage edge.
if (owner != myIndex && mergedSdf > smoothFactor) float reach = max(smoothFactor, shadowRadius);
if (mergedSdf > reach - 1.0)
discard; discard;
float fw = fwidth(mergedSdf); // Only the owner writes a pixel, which is what makes the per-shape path look
float alpha = 1.0 - smoothstep(-fw, fw, mergedSdf); // like one surface: qt_Opacity is applied once per pixel instead of once per
fragColor = vec4(color.rgb * alpha, alpha) * qt_Opacity; // overlapping draw. The single group draw has no owner (myIndex == -2), so the
// test is skipped and the value below is the only value a pixel can have.
if (myIndex >= -1 && owner != myIndex)
discard;
// Fixed-width AA: fwidth is capped at one unit, so a bad derivative near the quad
// boundary cannot make a wide edge.
float fw = min(fwidth(mergedSdf), 1.0);
float materialAlpha = 1.0 - smoothstep(-fw, fw, mergedSdf);
// Phase 2: the shadow, as a distance falloff from the merged surface. The
// quadratic reaches zero with zero slope at its outer edge, so that edge needs no
// AA of its own. Only the single group draw writes a shadow: in the per-shape
// path overlapping draws would darken the same area twice.
float shadowAlpha = 0.0;
if (shadowRadius > 0.0 && myIndex == -2) {
float t = clamp(mergedSdf / shadowRadius, 0.0, 1.0);
shadowAlpha = (1.0 - t) * (1.0 - t) * shadowColor.a;
}
// Material over shadow, premultiplied.
float totalAlpha = materialAlpha + shadowAlpha * (1.0 - materialAlpha);
vec3 rgb = color.rgb * materialAlpha
+ shadowColor.rgb * shadowAlpha * (1.0 - materialAlpha);
fragColor = vec4(rgb, totalAlpha) * qt_Opacity;
} }
+2
View File
@@ -4,6 +4,7 @@ qml_module(ZShell-components
lazylistview.hpp lazylistview.cpp lazylistview.hpp lazylistview.cpp
chatlistview.hpp chatlistview.cpp chatlistview.hpp chatlistview.cpp
wavyline.hpp wavyline.cpp wavyline.hpp wavyline.cpp
wavylinerenderer.hpp wavylinerenderer.cpp
buttonrow.hpp buttonrow.cpp buttonrow.hpp buttonrow.cpp
carouselview.hpp carouselview.cpp carouselview.hpp carouselview.cpp
sparklinecontent.hpp sparklinecontent.cpp sparklinecontent.hpp sparklinecontent.cpp
@@ -13,5 +14,6 @@ qml_module(ZShell-components
LIBRARIES LIBRARIES
Qt::Quick Qt::Quick
Qt::QuickPrivate Qt::QuickPrivate
Qt::CanvasPainter
ZShell-internal ZShell-internal
) )
+11 -11
View File
@@ -187,7 +187,7 @@ void CarouselView::setModel(const QVariantList& m) {
emit modelChanged(); emit modelChanged();
const int newCount = m_model.size(); const int newCount = static_cast<int>(m_model.size());
if (newCount == 0) { if (newCount == 0) {
m_currentRealIndex = -1; m_currentRealIndex = -1;
@@ -647,7 +647,7 @@ qreal CarouselView::sampleSize(qreal childLoc) const {
} }
int CarouselView::realIndexOf(int virtualIndex) const { int CarouselView::realIndexOf(int virtualIndex) const {
const int count = m_model.size(); const int count = static_cast<int>(m_model.size());
if (count == 0) return -1; if (count == 0) return -1;
@@ -668,7 +668,7 @@ qreal CarouselView::snapTargetX(qreal x) const {
} }
void CarouselView::wrapContentIfNeeded() { void CarouselView::wrapContentIfNeeded() {
const int count = m_model.size(); const int count = static_cast<int>(m_model.size());
if (count == 0) return; if (count == 0) return;
@@ -707,7 +707,7 @@ void CarouselView::wrapContentIfNeeded() {
} }
void CarouselView::updateCurrentIndexFromContentX(bool emitPreview) { void CarouselView::updateCurrentIndexFromContentX(bool emitPreview) {
const int count = m_model.size(); const int count = static_cast<int>(m_model.size());
if (count == 0) return; if (count == 0) return;
@@ -856,8 +856,8 @@ void CarouselView::positionSlot(const Slot& slot) const {
return rightIndex == n ? m_arrangement.outerSpacing : m_itemSpacing; return rightIndex == n ? m_arrangement.outerSpacing : m_itemSpacing;
}; };
const auto leftGap = [this, n](int leftIndex) { const auto leftGap = [this, n](int gapIndex) {
return leftIndex == -n ? m_arrangement.outerSpacing : m_itemSpacing; return gapIndex == -n ? m_arrangement.outerSpacing : m_itemSpacing;
}; };
qreal center = 0; qreal center = 0;
@@ -974,7 +974,7 @@ void CarouselView::releaseAllItems() {
} }
void CarouselView::relayout() { void CarouselView::relayout() {
const int count = m_model.size(); const int count = static_cast<int>(m_model.size());
if (count == 0 || width() <= 0 || m_focalWidth <= 0) { if (count == 0 || width() <= 0 || m_focalWidth <= 0) {
for (Slot& s : m_slots) { for (Slot& s : m_slots) {
@@ -1038,7 +1038,7 @@ void CarouselView::relayout() {
m_slots.append(Slot{}); m_slots.append(Slot{});
slotUsedThisPass.append(false); slotUsedThisPass.append(false);
freeSlot = m_slots.size() - 1; freeSlot = static_cast<int>(m_slots.size()) - 1;
} }
updateSlotContent(m_slots[freeSlot], v); updateSlotContent(m_slots[freeSlot], v);
@@ -1092,7 +1092,7 @@ void CarouselView::snapToNearest() {
} }
void CarouselView::goToIndex(int realIndex) { void CarouselView::goToIndex(int realIndex) {
const int count = m_model.size(); const int count = static_cast<int>(m_model.size());
if (count == 0) return; if (count == 0) return;
@@ -1126,7 +1126,7 @@ void CarouselView::decrementCurrentIndex() {
} }
void CarouselView::centerInstantlyOnReal(int realIndex) { void CarouselView::centerInstantlyOnReal(int realIndex) {
const int count = m_model.size(); const int count = static_cast<int>(m_model.size());
if (count == 0) return; if (count == 0) return;
@@ -1220,7 +1220,7 @@ qreal CarouselView::sampledVelocity() const {
const VelocitySample& oldest = m_velocitySamples.first(); const VelocitySample& oldest = m_velocitySamples.first();
const VelocitySample& newest = m_velocitySamples.last(); const VelocitySample& newest = m_velocitySamples.last();
const qreal dt = newest.t - oldest.t; const qreal dt = static_cast<qreal>(newest.t - oldest.t);
if (dt < 8) return 0; if (dt < 8) return 0;
@@ -252,8 +252,6 @@ int ChatListView::count() const {
} }
bool ChatListView::event(QEvent* e) { bool ChatListView::event(QEvent* e) {
// qDebug() << "ChatListView::event() runs";
return QQuickItem::event(e); return QQuickItem::event(e);
} }
@@ -366,7 +364,6 @@ void ChatListView::updatePolish() {
m_animateY = false; m_animateY = false;
const int layoutSize = static_cast<int>(m_layout.size()); const int layoutSize = static_cast<int>(m_layout.size());
const qint64 now = m_clock.elapsed(); const qint64 now = m_clock.elapsed();
bool wrote = false;
for (auto& entry : m_delegates) { for (auto& entry : m_delegates) {
if (!entry.item || entry.pendingRemoval || entry.pendingInsert) if (!entry.item || entry.pendingRemoval || entry.pendingInsert)
continue; continue;
@@ -397,7 +394,6 @@ void ChatListView::updatePolish() {
entry.item->setY(target); entry.item->setY(target);
entry.hasPropY = false; entry.hasPropY = false;
} }
wrote = true;
} }
applyAnchor(); applyAnchor();
@@ -50,8 +50,6 @@ void SparklineContent::setLine2(internal::CircularBuffer* buffer) {
void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) { void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) {
if (!buffer) return; if (!buffer) return;
// New data, or the buffer's own smoothingRadius changed (it re-emits
// valuesChanged in that case too) -> geometry is stale.
connect( connect(
buffer, buffer,
&internal::CircularBuffer::valuesChanged, &internal::CircularBuffer::valuesChanged,
@@ -67,7 +65,7 @@ void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) {
void SparklineContent::setLine1Color(const QColor& color) { void SparklineContent::setLine1Color(const QColor& color) {
if (m_line1Color == color) return; if (m_line1Color == color) return;
m_line1Color = color; m_line1Color = color;
update(); // visual-only, no geometry rebuild needed update();
} }
void SparklineContent::setLine2Color(const QColor& color) { void SparklineContent::setLine2Color(const QColor& color) {
@@ -173,7 +171,7 @@ SparklineContent::LineGeometry SparklineContent::buildGeometry(
if (m_curveSmoothing && points.size() > 2) { if (m_curveSmoothing && points.size() > 2) {
const qreal tension = 1.0; const qreal tension = 1.0;
const qreal k = tension / 6.0; const qreal k = tension / 6.0;
const int n = points.size(); const int n = static_cast<int>(points.size());
for (int i = 0; i < n - 1; ++i) { for (int i = 0; i < n - 1; ++i) {
const QPointF& p0 = points[qMax(i - 1, 0)]; const QPointF& p0 = points[qMax(i - 1, 0)];
@@ -84,7 +84,6 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample(
points.reserve(len); points.reserve(len);
for (int i = 0; i < len; ++i) { for (int i = 0; i < len; ++i) {
// Already smoothed once, incrementally, by the buffer itself.
const qreal value = const qreal value =
qBound<qreal>(0.0, buffer->smoothedAt(i), m_maxValue); qBound<qreal>(0.0, buffer->smoothedAt(i), m_maxValue);
@@ -99,7 +98,7 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample(
if (m_curveSmoothing && points.size() > 2) { if (m_curveSmoothing && points.size() > 2) {
const qreal tension = 1.0; const qreal tension = 1.0;
const qreal k = tension / 6.0; const qreal k = tension / 6.0;
const int n = points.size(); const int n = static_cast<int>(points.size());
for (int i = 0; i < n - 1; ++i) { for (int i = 0; i < n - 1; ++i) {
const QPointF& p0 = points[qMax(i - 1, 0)]; const QPointF& p0 = points[qMax(i - 1, 0)];
const QPointF& p1 = points[i]; const QPointF& p1 = points[i];
@@ -125,9 +124,6 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample(
} }
} }
// Flatten the curve to a polyline once, here, on data change only.
// This is CPU math only -- no QPainter draw calls, no image, no
// texture upload happen as a result of this.
const auto subpaths = path.toSubpathPolygons(); const auto subpaths = path.toSubpathPolygons();
if (subpaths.isEmpty()) return sample; if (subpaths.isEmpty()) return sample;
@@ -139,7 +135,7 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample(
void SparklineSceneItem::writeFillGeometry( void SparklineSceneItem::writeFillGeometry(
QSGGeometryNode* node, const LineSample& sample) { QSGGeometryNode* node, const LineSample& sample) {
const int n = sample.polyline.size(); const int n = static_cast<int>(sample.polyline.size());
auto* geometry = node->geometry(); auto* geometry = node->geometry();
geometry->allocate(n * 2); geometry->allocate(n * 2);
geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip);
@@ -147,8 +143,10 @@ void SparklineSceneItem::writeFillGeometry(
auto* vertices = geometry->vertexDataAsPoint2D(); auto* vertices = geometry->vertexDataAsPoint2D();
for (int i = 0; i < n; ++i) { for (int i = 0; i < n; ++i) {
const QPointF& p = sample.polyline[i]; const QPointF& p = sample.polyline[i];
vertices[i * 2].set(p.x(), p.y()); vertices[i * 2].set(
vertices[i * 2 + 1].set(p.x(), sample.fillBottom); static_cast<float>(p.x()), static_cast<float>(p.y()));
vertices[i * 2 + 1].set(
static_cast<float>(p.x()), static_cast<float>(sample.fillBottom));
} }
node->markDirty(QSGNode::DirtyGeometry); node->markDirty(QSGNode::DirtyGeometry);
@@ -156,7 +154,7 @@ void SparklineSceneItem::writeFillGeometry(
void SparklineSceneItem::writeStrokeGeometry( void SparklineSceneItem::writeStrokeGeometry(
QSGGeometryNode* node, const LineSample& sample, qreal lineWidth) { QSGGeometryNode* node, const LineSample& sample, qreal lineWidth) {
const int n = sample.polyline.size(); const int n = static_cast<int>(sample.polyline.size());
auto* geometry = node->geometry(); auto* geometry = node->geometry();
geometry->allocate(n * 2); geometry->allocate(n * 2);
geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip); geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip);
@@ -179,8 +177,12 @@ void SparklineSceneItem::writeStrokeGeometry(
const QPointF& p = sample.polyline[i]; const QPointF& p = sample.polyline[i];
const QPointF offset = normal * half; const QPointF offset = normal * half;
vertices[i * 2].set(p.x() + offset.x(), p.y() + offset.y()); vertices[i * 2].set(
vertices[i * 2 + 1].set(p.x() - offset.x(), p.y() - offset.y()); static_cast<float>(p.x() + offset.x()),
static_cast<float>(p.y() + offset.y()));
vertices[i * 2 + 1].set(
static_cast<float>(p.x() - offset.x()),
static_cast<float>(p.y() - offset.y()));
} }
node->markDirty(QSGNode::DirtyGeometry); node->markDirty(QSGNode::DirtyGeometry);
@@ -243,7 +245,7 @@ QSGNode* SparklineSceneItem::updatePaintNode(
} }
QMatrix4x4 matrix; QMatrix4x4 matrix;
matrix.translate(-stepX() * m_slideProgress, 0); matrix.translate(static_cast<float>(-stepX() * m_slideProgress), 0.0f);
transformNode->setMatrix(matrix); transformNode->setMatrix(matrix);
const bool needsGeometryWrite = samplesRebuilt || justCreated; const bool needsGeometryWrite = samplesRebuilt || justCreated;
+9 -96
View File
@@ -1,12 +1,10 @@
#include "wavyline.hpp" #include "wavyline.hpp"
#include "wavylinerenderer.hpp"
#include <qpainter.h>
#include <qpainterpath.h>
namespace ZShell::controls { namespace ZShell::controls {
WavyLine::WavyLine(QQuickItem* parent) WavyLine::WavyLine(QQuickItem* parent)
: QQuickPaintedItem(parent) : QCanvasPainterItem(parent)
, m_lineWidth(4) , m_lineWidth(4)
, m_amplitudeMultiplier(0.5) , m_amplitudeMultiplier(0.5)
, m_frequency(6) , m_frequency(6)
@@ -18,10 +16,13 @@ WavyLine::WavyLine(QQuickItem* parent)
, m_startAngle(0) , m_startAngle(0)
, m_fullAngle(360) , m_fullAngle(360)
, m_radius(-1) , m_radius(-1)
, m_value(1) , m_value(1) {
, m_startAngleRad(0) setFillColor(Qt::transparent);
, m_fullAngleRad(2 * M_PI) { setAlphaBlending(true);
setAntialiasing(true); }
QCanvasPainterItemRenderer* WavyLine::createItemRenderer() const {
return new WavyLineRenderer;
} }
int WavyLine::lineWidth() const { int WavyLine::lineWidth() const {
@@ -127,7 +128,6 @@ qreal WavyLine::startAngle() const {
void WavyLine::setStartAngle(qreal startAngle) { void WavyLine::setStartAngle(qreal startAngle) {
if (!qFuzzyCompare(m_startAngle + 1.0, startAngle + 1.0)) { if (!qFuzzyCompare(m_startAngle + 1.0, startAngle + 1.0)) {
m_startAngle = startAngle; m_startAngle = startAngle;
m_startAngleRad = startAngle * M_PI / 180.0;
emit startAngleChanged(); emit startAngleChanged();
update(); update();
} }
@@ -140,7 +140,6 @@ qreal WavyLine::fullAngle() const {
void WavyLine::setFullAngle(qreal fullAngle) { void WavyLine::setFullAngle(qreal fullAngle) {
if (!qFuzzyCompare(m_fullAngle + 1.0, fullAngle + 1.0)) { if (!qFuzzyCompare(m_fullAngle + 1.0, fullAngle + 1.0)) {
m_fullAngle = fullAngle; m_fullAngle = fullAngle;
m_fullAngleRad = fullAngle * M_PI / 180.0;
emit fullAngleChanged(); emit fullAngleChanged();
update(); update();
} }
@@ -170,90 +169,4 @@ void WavyLine::setValue(qreal value) {
} }
} }
void WavyLine::paint(QPainter* painter) {
painter->setRenderHint(QPainter::Antialiasing);
painter->setPen(QPen(m_color, m_lineWidth, Qt::SolidLine, Qt::RoundCap));
if (m_pathType == Arc) {
paintArc(painter);
} else {
paintLinear(painter);
}
}
void WavyLine::paintLinear(QPainter* painter) {
const auto amplitude = m_lineWidth * m_amplitudeMultiplier;
const auto phase = m_waveProgress * 2 * M_PI;
const auto centerY = height() / 2;
const auto len = m_fullLength > 0 ? m_fullLength : 1;
const auto start = m_lineWidth / 2.0;
const auto fullEnd = width() - m_lineWidth / 2.0;
const auto drawEnd = start + (fullEnd - start) * m_value;
QPainterPath path;
bool first = true;
for (int x = m_lineWidth / 2; x <= drawEnd; ++x) {
const auto theta =
m_frequency * 2 * M_PI * (x + m_startX) / len + phase;
const auto waveY = centerY + amplitude * qSin(theta);
if (first) {
path.moveTo(x, waveY);
first = false;
} else {
path.lineTo(x, waveY);
}
}
painter->drawPath(path);
}
void WavyLine::paintArc(QPainter* painter) {
if (m_fullAngleRad <= 0) {
return;
}
const auto amplitude = m_lineWidth * m_amplitudeMultiplier;
const auto cx = width() / 2.0;
const auto cy = height() / 2.0;
const auto radius =
m_radius > 0
? m_radius
: (qMin(width(), height()) - m_lineWidth - 2 * amplitude) / 2.0;
if (radius <= 0) {
return;
}
const auto phase = m_waveProgress * 2 * M_PI;
const auto arcLen = radius * m_fullAngleRad;
const auto len = m_fullLength > 0 ? m_fullLength : arcLen;
const auto drawAngleRad = m_fullAngleRad * m_value;
if (drawAngleRad <= 0) {
return;
}
const auto N = qMax(64, qCeil(radius * drawAngleRad));
const auto dTheta = drawAngleRad / N;
QPainterPath path;
for (int i = 0; i <= N; ++i) {
const auto theta = m_startAngleRad + i * dTheta;
const auto s = i * dTheta * radius;
const auto phi = m_frequency * 2 * M_PI * (s + m_startX) / len + phase;
const auto r = radius + amplitude * qSin(phi);
const auto px = cx + r * qCos(theta);
const auto py = cy + r * qSin(theta);
if (i == 0) {
path.moveTo(px, py);
} else {
path.lineTo(px, py);
}
}
painter->drawPath(path);
}
} // namespace ZShell::controls } // namespace ZShell::controls
+10 -8
View File
@@ -1,11 +1,14 @@
#pragma once #pragma once
#include <qqmlintegration.h> #include <qqmlintegration.h>
#include <qquickpainteditem.h> #include <QCanvasPainterItem>
#include <QColor>
namespace ZShell::controls { namespace ZShell::controls {
class WavyLine : public QQuickPaintedItem { class WavyLineRenderer;
class WavyLine : public QCanvasPainterItem {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
@@ -82,8 +85,6 @@ class WavyLine : public QQuickPaintedItem {
[[nodiscard]] qreal value() const; [[nodiscard]] qreal value() const;
void setValue(qreal value); void setValue(qreal value);
void paint(QPainter* painter) override;
signals: signals:
void lineWidthChanged(); void lineWidthChanged();
void amplitudeMultiplierChanged(); void amplitudeMultiplierChanged();
@@ -98,9 +99,12 @@ class WavyLine : public QQuickPaintedItem {
void radiusChanged(); void radiusChanged();
void valueChanged(); void valueChanged();
protected:
[[nodiscard]] QCanvasPainterItemRenderer* createItemRenderer()
const override;
private: private:
void paintLinear(QPainter* painter); friend class WavyLineRenderer;
void paintArc(QPainter* painter);
int m_lineWidth; int m_lineWidth;
qreal m_amplitudeMultiplier; qreal m_amplitudeMultiplier;
@@ -114,8 +118,6 @@ class WavyLine : public QQuickPaintedItem {
qreal m_fullAngle; qreal m_fullAngle;
qreal m_radius; qreal m_radius;
qreal m_value; qreal m_value;
qreal m_startAngleRad;
qreal m_fullAngleRad;
}; };
} // namespace ZShell::controls } // namespace ZShell::controls
@@ -0,0 +1,116 @@
#include "wavylinerenderer.hpp"
#include "wavyline.hpp"
#include <qcanvaspainter.h>
namespace ZShell::controls {
void WavyLineRenderer::synchronizeData(QCanvasPainterItem* item) {
auto* wavyLine = static_cast<WavyLine*>(item);
m_lineWidth = wavyLine->m_lineWidth;
m_amplitudeMultiplier = wavyLine->m_amplitudeMultiplier;
m_frequency = wavyLine->m_frequency;
m_startX = wavyLine->m_startX;
m_fullLength = wavyLine->m_fullLength;
m_color = wavyLine->m_color;
m_waveProgress = wavyLine->m_waveProgress;
m_pathType = wavyLine->m_pathType;
m_startAngle = wavyLine->m_startAngle;
m_fullAngle = wavyLine->m_fullAngle;
m_radius = wavyLine->m_radius;
m_value = wavyLine->m_value;
}
void WavyLineRenderer::paint(QCanvasPainter* painter) {
painter->clearRect(0, 0, width(), height());
painter->setStrokeStyle(m_color);
painter->setLineWidth(m_lineWidth);
painter->setLineCap(QCanvasPainter::LineCap::Round);
if (m_pathType == WavyLine::Arc) {
paintArc(painter);
} else {
paintLinear(painter);
}
}
void WavyLineRenderer::paintLinear(QCanvasPainter* painter) {
const auto amplitude = m_lineWidth * m_amplitudeMultiplier;
const auto phase = m_waveProgress * 2 * M_PI;
const auto centerY = height() / 2;
const auto len = m_fullLength > 0 ? m_fullLength : 1;
const auto start = m_lineWidth / 2.0;
const auto fullEnd = width() - m_lineWidth / 2.0;
const auto drawEnd = start + (fullEnd - start) * m_value;
painter->beginPath();
bool first = true;
for (int x = m_lineWidth / 2; x <= drawEnd; ++x) {
const auto theta =
m_frequency * 2 * M_PI * (x + m_startX) / len + phase;
const auto waveY = centerY + amplitude * qSin(theta);
if (first) {
painter->moveTo(x, waveY);
first = false;
} else {
painter->lineTo(x, waveY);
}
}
painter->stroke();
}
void WavyLineRenderer::paintArc(QCanvasPainter* painter) {
if (m_fullAngle <= 0) {
return;
}
const auto startAngleRad = m_startAngle * M_PI / 180.0;
const auto fullAngleRad = m_fullAngle * M_PI / 180.0;
const auto amplitude = m_lineWidth * m_amplitudeMultiplier;
const auto cx = width() / 2.0;
const auto cy = height() / 2.0;
const auto radius =
m_radius > 0
? m_radius
: (qMin(width(), height()) - m_lineWidth - 2 * amplitude) / 2.0;
if (radius <= 0) {
return;
}
const auto phase = m_waveProgress * 2 * M_PI;
const auto arcLen = radius * fullAngleRad;
const auto len = m_fullLength > 0 ? m_fullLength : arcLen;
const auto drawAngleRad = fullAngleRad * m_value;
if (drawAngleRad <= 0) {
return;
}
const auto N = qMax(64, qCeil(radius * drawAngleRad));
const auto dTheta = drawAngleRad / N;
painter->beginPath();
for (int i = 0; i <= N; ++i) {
const auto theta = startAngleRad + i * dTheta;
const auto s = i * dTheta * radius;
const auto phi = m_frequency * 2 * M_PI * (s + m_startX) / len + phase;
const auto r = radius + amplitude * qSin(phi);
const auto px = cx + r * qCos(theta);
const auto py = cy + r * qSin(theta);
if (i == 0) {
painter->moveTo(px, py);
} else {
painter->lineTo(px, py);
}
}
painter->stroke();
}
} // namespace ZShell::controls
@@ -0,0 +1,32 @@
#pragma once
#include <QCanvasPainterItemRenderer>
#include <QColor>
#include "wavyline.hpp"
namespace ZShell::controls {
class WavyLineRenderer final : public QCanvasPainterItemRenderer {
public:
void synchronizeData(QCanvasPainterItem* item) override;
void paint(QCanvasPainter* painter) override;
private:
void paintLinear(QCanvasPainter* painter);
void paintArc(QCanvasPainter* painter);
int m_lineWidth;
qreal m_amplitudeMultiplier;
int m_frequency;
qreal m_startX;
qreal m_fullLength;
QColor m_color;
qreal m_waveProgress;
WavyLine::PathType m_pathType;
qreal m_startAngle;
qreal m_fullAngle;
qreal m_radius;
qreal m_value;
};
} // namespace ZShell::controls
+12 -20
View File
@@ -37,12 +37,10 @@ static std::filesystem::path g_runtimeDir{"/tmp"};
static std::filesystem::path g_cacheBase{"/tmp/.cache"}; static std::filesystem::path g_cacheBase{"/tmp/.cache"};
static void initEnvPaths() { static void initEnvPaths() {
if (const char* env = getenv("XDG_RUNTIME_DIR")) if (const char* env = getenv("XDG_RUNTIME_DIR")) g_runtimeDir = env;
g_runtimeDir = env;
if (const char* env = getenv("XDG_CACHE_HOME")) { if (const char* env = getenv("XDG_CACHE_HOME")) {
if (*env) if (*env) g_cacheBase = env;
g_cacheBase = env;
} else if (const char* home = getenv("HOME")) { } else if (const char* home = getenv("HOME")) {
g_cacheBase = std::filesystem::path(home) / ".cache"; g_cacheBase = std::filesystem::path(home) / ".cache";
} }
@@ -50,8 +48,7 @@ static void initEnvPaths() {
static std::ofstream& debugLog() { static std::ofstream& debugLog() {
static std::ofstream log( static std::ofstream log(
g_runtimeDir / "zshell-nightlight-debug.log", g_runtimeDir / "zshell-nightlight-debug.log", std::ios::app);
std::ios::app);
return log; return log;
} }
@@ -173,15 +170,14 @@ static bool hkSaveBufferForMirror(CHyprOpenGLImpl* thisptr, const CBox& box) {
static void installShader() { static void installShader() {
auto* gl = g_pHyprOpenGL.get(); auto* gl = g_pHyprOpenGL.get();
if (!gl) if (!gl) return;
return;
ensureShaderFile(shaderPath(), defaultShaderSource()); ensureShaderFile(shaderPath(), defaultShaderSource());
gl->applyScreenShader(shaderPath().string()); gl->applyScreenShader(shaderPath().string());
auto* shader = gl->m_finalScreenShader.get(); auto* shader = gl->m_finalScreenShader.get();
if (shader && shader->program() >= 1 && if (shader && shader->program() >= 1 &&
shader->getUniformLocation(SHADER_TINT) != -1) { shader->getUniformLocation(SHADER_TINT) != -1) {
g_uniformMode = true; g_uniformMode = true;
} else { } else {
g_uniformMode = false; g_uniformMode = false;
@@ -196,8 +192,7 @@ static void installShader() {
static void setUniformGain(const std::array<float, 3>& gain) { static void setUniformGain(const std::array<float, 3>& gain) {
auto* gl = g_pHyprOpenGL.get(); auto* gl = g_pHyprOpenGL.get();
auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr; auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr;
if (!gl || !shader) if (!gl || !shader) return;
return;
gl->useShader(gl->m_finalScreenShader); gl->useShader(gl->m_finalScreenShader);
shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]); shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]);
@@ -214,8 +209,7 @@ static void hkBegin(
if (fb) { if (fb) {
auto* renderer = g_pHyprRenderer.get(); auto* renderer = g_pHyprRenderer.get();
if (renderer) if (renderer) renderer->m_renderData.blockScreenShader = true;
renderer->m_renderData.blockScreenShader = true;
} }
if (!g_active) return; if (!g_active) return;
@@ -223,17 +217,15 @@ static void hkBegin(
auto* gl = g_pHyprOpenGL.get(); auto* gl = g_pHyprOpenGL.get();
if (!gl) return; if (!gl) return;
if (gl->m_finalScreenShader.get() && if (gl->m_finalScreenShader.get() && gl->m_finalScreenShader->program() < 1)
gl->m_finalScreenShader->program() < 1)
installShader(); installShader();
if (!gl->m_finalScreenShader.get() || if (!gl->m_finalScreenShader.get() ||
gl->m_finalScreenShader->program() < 1) gl->m_finalScreenShader->program() < 1)
return; return;
if (g_transition.active) { if (g_transition.active) {
auto* monPtr = mon.get(); auto* monPtr = mon.get();
if (!monPtr) if (!monPtr) return;
return;
const float elapsed = const float elapsed =
std::chrono::duration<float>( std::chrono::duration<float>(
std::chrono::steady_clock::now() - g_transition.start) std::chrono::steady_clock::now() - g_transition.start)
@@ -255,9 +247,9 @@ static void hkBegin(
ensureShaderFile( ensureShaderFile(
bakedShaderPath(g_currentGain), bakedShaderPath(g_currentGain),
bakedShaderSource(g_currentGain)); bakedShaderSource(g_currentGain));
gl->applyScreenShader(bakedShaderPath(g_currentGain).string()); gl->applyScreenShader(bakedShaderPath(g_currentGain).string());
}
} }
}
if (t >= 1.f) { if (t >= 1.f) {
g_transition.active = false; g_transition.active = false;
+16
View File
@@ -1,3 +1,8 @@
wl_proto(wlp-layer-shell wlr-layer-shell-unstable-v1 "${CMAKE_CURRENT_SOURCE_DIR}/protocols")
wl_proto(wlp-viewporter viewporter "${WAYLAND_PROTOCOLS}/stable/viewporter")
wl_proto(wlp-fractional-scale fractional-scale-v1 "${WAYLAND_PROTOCOLS}/staging/fractional-scale")
wl_proto(wlp-xdg-shell xdg-shell "${WAYLAND_PROTOCOLS}/stable/xdg-shell")
qml_module(ZShell-internal qml_module(ZShell-internal
URI ZShell.Internal URI ZShell.Internal
INCLUDE_PREFIX internal INCLUDE_PREFIX internal
@@ -9,6 +14,9 @@ qml_module(ZShell-internal
circularbuffer.hpp circularbuffer.cpp circularbuffer.hpp circularbuffer.cpp
arcgauge.hpp arcgauge.cpp arcgauge.hpp arcgauge.cpp
wallpaperimage.hpp wallpaperimage.cpp wallpaperimage.hpp wallpaperimage.cpp
wallpaperservice.hpp wallpaperservice.cpp
wallpapersurface.hpp wallpapersurface.cpp
wallpaperobject.hpp wallpaperobject.cpp
lidwatcher.hpp lidwatcher.cpp lidwatcher.hpp lidwatcher.cpp
visualizerbars.hpp visualizerbars.cpp visualizerbars.hpp visualizerbars.cpp
linearindicatormanager.hpp linearindicatormanager.cpp linearindicatormanager.hpp linearindicatormanager.cpp
@@ -23,4 +31,12 @@ qml_module(ZShell-internal
Qt::Network Qt::Network
Qt::DBus Qt::DBus
Qt::CanvasPainter Qt::CanvasPainter
Qt::GuiPrivate
wlp-layer-shell
wlp-xdg-shell
wlp-viewporter
wlp-fractional-scale
PkgConfig::EGL
PkgConfig::WAYLAND_EGL
PkgConfig::GL
) )
+4 -4
View File
@@ -182,10 +182,10 @@ static QString buildHlConfigCall(const QString& key, const QVariant& value) {
} }
static QColor colorFromInt(quint32 value) { static QColor colorFromInt(quint32 value) {
const int a = (value >> 24) & 0xFF; const int a = static_cast<int>(value >> 24) & 0xFF;
const int r = (value >> 16) & 0xFF; const int r = static_cast<int>(value >> 16) & 0xFF;
const int g = (value >> 8) & 0xFF; const int g = static_cast<int>(value >> 8) & 0xFF;
const int b = value & 0xFF; const int b = static_cast<int>(value) & 0xFF;
return QColor(r, g, b, a); return QColor(r, g, b, a);
} }
@@ -0,0 +1,407 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="wlr_layer_shell_unstable_v1">
<copyright>
Copyright © 2017 Drew DeVault
Permission to use, copy, modify, distribute, and sell this
software and its documentation for any purpose is hereby granted
without fee, provided that the above copyright notice appear in
all copies and that both that copyright notice and this permission
notice appear in supporting documentation, and that the name of
the copyright holders not be used in advertising or publicity
pertaining to distribution of the software without specific,
written prior permission. The copyright holders make no
representations about the suitability of this software for any
purpose. It is provided "as is" without express or implied
warranty.
THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
</copyright>
<interface name="zwlr_layer_shell_v1" version="5">
<description summary="create surfaces that are layers of the desktop">
Clients can use this interface to assign the surface_layer role to
wl_surfaces. Such surfaces are assigned to a "layer" of the output and
rendered with a defined z-depth respective to each other. They may also be
anchored to the edges and corners of a screen and specify input handling
semantics. This interface should be suitable for the implementation of
many desktop shell components, and a broad number of other applications
that interact with the desktop.
</description>
<request name="get_layer_surface">
<description summary="create a layer_surface from a surface">
Create a layer surface for an existing surface. This assigns the role of
layer_surface, or raises a protocol error if another role is already
assigned.
Creating a layer surface from a wl_surface which has a buffer attached
or committed is a client error, and any attempts by a client to attach
or manipulate a buffer prior to the first layer_surface.configure call
must also be treated as errors.
After creating a layer_surface object and setting it up, the client
must perform an initial commit without any buffer attached.
The compositor will reply with a layer_surface.configure event.
The client must acknowledge it and is then allowed to attach a buffer
to map the surface.
You may pass NULL for output to allow the compositor to decide which
output to use. Generally this will be the one that the user most
recently interacted with.
Clients can specify a namespace that defines the purpose of the layer
surface.
</description>
<arg name="id" type="new_id" interface="zwlr_layer_surface_v1"/>
<arg name="surface" type="object" interface="wl_surface"/>
<arg name="output" type="object" interface="wl_output" allow-null="true"/>
<arg name="layer" type="uint" enum="layer" summary="layer to add this surface to"/>
<arg name="namespace_" type="string" summary="namespace for the layer surface"/>
</request>
<enum name="error">
<entry name="role" value="0" summary="wl_surface has another role"/>
<entry name="invalid_layer" value="1" summary="layer value is invalid"/>
<entry name="already_constructed" value="2" summary="wl_surface has a buffer attached or committed"/>
</enum>
<enum name="layer">
<description summary="available layers for surfaces">
These values indicate which layers a surface can be rendered in. They
are ordered by z depth, bottom-most first. Traditional shell surfaces
will typically be rendered between the bottom and top layers.
Fullscreen shell surfaces are typically rendered at the top layer.
Multiple surfaces can share a single layer, and ordering within a
single layer is undefined.
</description>
<entry name="background" value="0"/>
<entry name="bottom" value="1"/>
<entry name="top" value="2"/>
<entry name="overlay" value="3"/>
</enum>
<!-- Version 3 additions -->
<request name="destroy" type="destructor" since="3">
<description summary="destroy the layer_shell object">
This request indicates that the client will not use the layer_shell
object any more. Objects that have been created through this instance
are not affected.
</description>
</request>
</interface>
<interface name="zwlr_layer_surface_v1" version="5">
<description summary="layer metadata interface">
An interface that may be implemented by a wl_surface, for surfaces that
are designed to be rendered as a layer of a stacked desktop-like
environment.
Layer surface state (layer, size, anchor, exclusive zone,
margin, interactivity) is double-buffered, and will be applied at the
time wl_surface.commit of the corresponding wl_surface is called.
Attaching a null buffer to a layer surface unmaps it.
Unmapping a layer_surface means that the surface cannot be shown by the
compositor until it is explicitly mapped again. The layer_surface
returns to the state it had right after layer_shell.get_layer_surface.
The client can re-map the surface by performing a commit without any
buffer attached, waiting for a configure event and handling it as usual.
</description>
<request name="set_size">
<description summary="sets the size of the surface">
Sets the size of the surface in surface-local coordinates. The
compositor will display the surface centered with respect to its
anchors.
If you pass 0 for either value, the compositor will assign it and
inform you of the assignment in the configure event. You must set your
anchor to opposite edges in the dimensions you omit; not doing so is a
protocol error. Both values are 0 by default.
Size is double-buffered, see wl_surface.commit.
</description>
<arg name="width" type="uint"/>
<arg name="height" type="uint"/>
</request>
<request name="set_anchor">
<description summary="configures the anchor point of the surface">
Requests that the compositor anchor the surface to the specified edges
and corners. If two orthogonal edges are specified (e.g. 'top' and
'left'), then the anchor point will be the intersection of the edges
(e.g. the top left corner of the output); otherwise the anchor point
will be centered on that edge, or in the center if none is specified.
Anchor is double-buffered, see wl_surface.commit.
</description>
<arg name="anchor" type="uint" enum="anchor"/>
</request>
<request name="set_exclusive_zone">
<description summary="configures the exclusive geometry of this surface">
Requests that the compositor avoids occluding an area with other
surfaces. The compositor's use of this information is
implementation-dependent - do not assume that this region will not
actually be occluded.
A positive value is only meaningful if the surface is anchored to one
edge or an edge and both perpendicular edges. If the surface is not
anchored, anchored to only two perpendicular edges (a corner), anchored
to only two parallel edges or anchored to all edges, a positive value
will be treated the same as zero.
A positive zone is the distance from the edge in surface-local
coordinates to consider exclusive.
Surfaces that do not wish to have an exclusive zone may instead specify
how they should interact with surfaces that do. If set to zero, the
surface indicates that it would like to be moved to avoid occluding
surfaces with a positive exclusive zone. If set to -1, the surface
indicates that it would not like to be moved to accommodate for other
surfaces, and the compositor should extend it all the way to the edges
it is anchored to.
For example, a panel might set its exclusive zone to 10, so that
maximized shell surfaces are not shown on top of it. A notification
might set its exclusive zone to 0, so that it is moved to avoid
occluding the panel, but shell surfaces are shown underneath it. A
wallpaper or lock screen might set their exclusive zone to -1, so that
they stretch below or over the panel.
The default value is 0.
Exclusive zone is double-buffered, see wl_surface.commit.
</description>
<arg name="zone" type="int"/>
</request>
<request name="set_margin">
<description summary="sets a margin from the anchor point">
Requests that the surface be placed some distance away from the anchor
point on the output, in surface-local coordinates. Setting this value
for edges you are not anchored to has no effect.
The exclusive zone includes the margin.
Margin is double-buffered, see wl_surface.commit.
</description>
<arg name="top" type="int"/>
<arg name="right" type="int"/>
<arg name="bottom" type="int"/>
<arg name="left" type="int"/>
</request>
<enum name="keyboard_interactivity">
<description summary="types of keyboard interaction possible for a layer shell surface">
Types of keyboard interaction possible for layer shell surfaces. The
rationale for this is twofold: (1) some applications are not interested
in keyboard events and not allowing them to be focused can improve the
desktop experience; (2) some applications will want to take exclusive
keyboard focus.
</description>
<entry name="none" value="0">
<description summary="no keyboard focus is possible">
This value indicates that this surface is not interested in keyboard
events and the compositor should never assign it the keyboard focus.
This is the default value, set for newly created layer shell surfaces.
This is useful for e.g. desktop widgets that display information or
only have interaction with non-keyboard input devices.
</description>
</entry>
<entry name="exclusive" value="1">
<description summary="request exclusive keyboard focus">
Request exclusive keyboard focus if this surface is above the shell surface layer.
For the top and overlay layers, the seat will always give
exclusive keyboard focus to the top-most layer which has keyboard
interactivity set to exclusive. If this layer contains multiple
surfaces with keyboard interactivity set to exclusive, the compositor
determines the one receiving keyboard events in an implementation-
defined manner. In this case, no guarantee is made when this surface
will receive keyboard focus (if ever).
For the bottom and background layers, the compositor is allowed to use
normal focus semantics.
This setting is mainly intended for applications that need to ensure
they receive all keyboard events, such as a lock screen or a password
prompt.
</description>
</entry>
<entry name="on_demand" value="2" since="4">
<description summary="request regular keyboard focus semantics">
This requests the compositor to allow this surface to be focused and
unfocused by the user in an implementation-defined manner. The user
should be able to unfocus this surface even regardless of the layer
it is on.
Typically, the compositor will want to use its normal mechanism to
manage keyboard focus between layer shell surfaces with this setting
and regular toplevels on the desktop layer (e.g. click to focus).
Nevertheless, it is possible for a compositor to require a special
interaction to focus or unfocus layer shell surfaces (e.g. requiring
a click even if focus follows the mouse normally, or providing a
keybinding to switch focus between layers).
This setting is mainly intended for desktop shell components (e.g.
panels) that allow keyboard interaction. Using this option can allow
implementing a desktop shell that can be fully usable without the
mouse.
</description>
</entry>
</enum>
<request name="set_keyboard_interactivity">
<description summary="requests keyboard events">
Set how keyboard events are delivered to this surface. By default,
layer shell surfaces do not receive keyboard events; this request can
be used to change this.
This setting is inherited by child surfaces set by the get_popup
request.
Layer surfaces receive pointer, touch, and tablet events normally. If
you do not want to receive them, set the input region on your surface
to an empty region.
Keyboard interactivity is double-buffered, see wl_surface.commit.
</description>
<arg name="keyboard_interactivity" type="uint" enum="keyboard_interactivity"/>
</request>
<request name="get_popup">
<description summary="assign this layer_surface as an xdg_popup parent">
This assigns an xdg_popup's parent to this layer_surface. This popup
should have been created via xdg_surface::get_popup with the parent set
to NULL, and this request must be invoked before committing the popup's
initial state.
See the documentation of xdg_popup for more details about what an
xdg_popup is and how it is used.
</description>
<arg name="popup" type="object" interface="xdg_popup"/>
</request>
<request name="ack_configure">
<description summary="ack a configure event">
When a configure event is received, if a client commits the
surface in response to the configure event, then the client
must make an ack_configure request sometime before the commit
request, passing along the serial of the configure event.
If the client receives multiple configure events before it
can respond to one, it only has to ack the last configure event.
A client is not required to commit immediately after sending
an ack_configure request - it may even ack_configure several times
before its next surface commit.
A client may send multiple ack_configure requests before committing, but
only the last request sent before a commit indicates which configure
event the client really is responding to.
</description>
<arg name="serial" type="uint" summary="the serial from the configure event"/>
</request>
<request name="destroy" type="destructor">
<description summary="destroy the layer_surface">
This request destroys the layer surface.
</description>
</request>
<event name="configure">
<description summary="suggest a surface change">
The configure event asks the client to resize its surface.
Clients should arrange their surface for the new states, and then send
an ack_configure request with the serial sent in this configure event at
some point before committing the new surface.
The client is free to dismiss all but the last configure event it
received.
The width and height arguments specify the size of the window in
surface-local coordinates.
The size is a hint, in the sense that the client is free to ignore it if
it doesn't resize, pick a smaller size (to satisfy aspect ratio or
resize in steps of NxM pixels). If the client picks a smaller size and
is anchored to two opposite anchors (e.g. 'top' and 'bottom'), the
surface will be centered on this axis.
If the width or height arguments are zero, it means the client should
decide its own window dimension.
</description>
<arg name="serial" type="uint"/>
<arg name="width" type="uint"/>
<arg name="height" type="uint"/>
</event>
<event name="closed">
<description summary="surface should be closed">
The closed event is sent by the compositor when the surface will no
longer be shown. The output may have been destroyed or the user may
have asked for it to be removed. Further changes to the surface will be
ignored. The client should destroy the resource after receiving this
event, and create a new surface if they so choose.
</description>
</event>
<enum name="error">
<entry name="invalid_surface_state" value="0" summary="provided surface state is invalid"/>
<entry name="invalid_size" value="1" summary="size is invalid"/>
<entry name="invalid_anchor" value="2" summary="anchor bitfield is invalid"/>
<entry name="invalid_keyboard_interactivity" value="3" summary="keyboard interactivity is invalid"/>
<entry name="invalid_exclusive_edge" value="4" summary="exclusive edge is invalid given the surface anchors"/>
</enum>
<enum name="anchor" bitfield="true">
<entry name="top" value="1" summary="the top edge of the anchor rectangle"/>
<entry name="bottom" value="2" summary="the bottom edge of the anchor rectangle"/>
<entry name="left" value="4" summary="the left edge of the anchor rectangle"/>
<entry name="right" value="8" summary="the right edge of the anchor rectangle"/>
</enum>
<!-- Version 2 additions -->
<request name="set_layer" since="2">
<description summary="change the layer of the surface">
Change the layer that the surface is rendered on.
Layer is double-buffered, see wl_surface.commit.
</description>
<arg name="layer" type="uint" enum="zwlr_layer_shell_v1.layer" summary="layer to move this surface to"/>
</request>
<!-- Version 5 additions -->
<request name="set_exclusive_edge" since="5">
<description summary="set the edge the exclusive zone will be applied to">
Requests an edge for the exclusive zone to apply. The exclusive
edge will be automatically deduced from anchor points when possible,
but when the surface is anchored to a corner, it will be necessary
to set it explicitly to disambiguate, as it is not possible to deduce
which one of the two corner edges should be used.
The edge must be one the surface is anchored to, otherwise the
invalid_exclusive_edge protocol error will be raised.
</description>
<arg name="edge" type="uint" enum="anchor"/>
</request>
</interface>
</protocol>
+1 -1
View File
@@ -72,7 +72,7 @@ static QCanvasPath buildStrokePath(
void StrokeCanvasItem::undoLastGroup() { void StrokeCanvasItem::undoLastGroup() {
if (m_strokes.isEmpty()) return; if (m_strokes.isEmpty()) return;
const int lastGroup = m_strokes.last().groupId; const int lastGroup = m_strokes.last().groupId;
for (int i = m_strokes.size() - 1; for (int i = static_cast<int>(m_strokes.size()) - 1;
i >= 0 && m_strokes[i].groupId == lastGroup; i >= 0 && m_strokes[i].groupId == lastGroup;
--i) --i)
m_strokes.remove(i); m_strokes.remove(i);
@@ -1,6 +1,7 @@
#include "strokecanvasrenderer.hpp" #include "strokecanvasrenderer.hpp"
#include "strokecanvasitem.hpp" #include "strokecanvasitem.hpp"
#include <qcolor.h> #include <qcolor.h>
#include <QSet>
namespace ZShell::internal { namespace ZShell::internal {
@@ -148,14 +149,27 @@ void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem* item) {
m_penColor = canvas->m_penColor; m_penColor = canvas->m_penColor;
m_penWidth = canvas->m_penWidth; m_penWidth = canvas->m_penWidth;
while (m_strokes.size() < canvas->m_strokes.size()) // Detect removed strokes: present in renderer but no longer in canvas
m_strokes.append(canvas->m_strokes[m_strokes.size()]); QSet<int> canvasGroupIds;
for (const auto& stroke : canvas->m_strokes)
canvasGroupIds.insert(stroke.groupId);
if (canvas->m_strokes.isEmpty() && !m_strokes.isEmpty()) { for (int i = static_cast<int>(m_strokes.size()) - 1; i >= 0; --i) {
for (const auto& stroke : m_strokes) if (!canvasGroupIds.contains(m_strokes[i].groupId)) {
if (stroke.groupId >= 0) if (m_strokes[i].groupId >= 0)
m_pendingGroupRemovals.append(stroke.groupId); m_pendingGroupRemovals.append(m_strokes[i].groupId);
m_strokes.clear(); m_strokes.remove(i);
}
}
// Detect added strokes: present in canvas but not yet in renderer
QSet<int> rendererGroupIds;
for (const auto& stroke : m_strokes)
rendererGroupIds.insert(stroke.groupId);
for (const auto& stroke : canvas->m_strokes) {
if (!rendererGroupIds.contains(stroke.groupId))
m_strokes.append(stroke);
} }
m_currentStroke = canvas->m_currentStroke; m_currentStroke = canvas->m_currentStroke;
+3 -1
View File
@@ -285,13 +285,15 @@ QSGNode* WallpaperImage::updatePaintNode(
Q_UNUSED(oldNode); Q_UNUSED(oldNode);
if (m_hasPending) { if (m_hasPending) {
qDebug() << "pending format" << m_pendingImage.format()
<< double(m_pendingImage.sizeInBytes()) / 1048576.0 << "MB";
if (m_previousTexture) { if (m_previousTexture) {
delete m_previousTexture; delete m_previousTexture;
m_previousTexture = nullptr; m_previousTexture = nullptr;
} }
m_previousTexture = m_texture; m_previousTexture = m_texture;
m_texture = window()->createTextureFromImage( m_texture = window()->createTextureFromImage(
m_pendingImage, QQuickWindow::TextureHasAlphaChannel); m_pendingImage, QQuickWindow::TextureIsOpaque);
m_pendingImage = QImage(); m_pendingImage = QImage();
m_hasPending = false; m_hasPending = false;
@@ -0,0 +1,83 @@
#include "wallpaperobject.hpp"
namespace ZShell::internal {
WallpaperObject::WallpaperObject(QObject* parent) : QObject(parent) {}
WallpaperObject::~WallpaperObject() {
if (m_service && !m_screen.isEmpty()) m_service->removeScreen(m_screen);
}
void WallpaperObject::registerScreen() {
if (!m_service || m_screen.isEmpty()) return;
m_service->addScreen(m_screen);
}
void WallpaperObject::setService(WallpaperService* service) {
if (m_service == service) return;
m_service = service;
emit serviceChanged();
registerScreen();
push();
}
void WallpaperObject::setScreen(const QString& screen) {
if (m_screen == screen) return;
m_screen = screen;
emit screenChanged();
registerScreen();
push();
}
void WallpaperObject::setCropX(qreal x) {
if (qFuzzyCompare(m_cropX, x)) return;
m_cropX = x;
emit cropXChanged();
push();
}
void WallpaperObject::setCropY(qreal y) {
if (qFuzzyCompare(m_cropY, y)) return;
m_cropY = y;
emit cropYChanged();
push();
}
void WallpaperObject::setCropWidth(qreal w) {
if (qFuzzyCompare(m_cropWidth, w)) return;
m_cropWidth = w;
emit cropWidthChanged();
push();
}
void WallpaperObject::setCropHeight(qreal h) {
if (qFuzzyCompare(m_cropHeight, h)) return;
m_cropHeight = h;
emit cropHeightChanged();
push();
}
void WallpaperObject::setZoom(qreal zoom) {
if (qFuzzyCompare(m_zoom, zoom)) return;
m_zoom = zoom;
emit zoomChanged();
push();
}
void WallpaperObject::push() {
if (!m_service || m_screen.isEmpty()) return;
QVariantMap crop;
crop.insert("x", m_cropX);
crop.insert("y", m_cropY);
crop.insert("width", m_cropWidth);
crop.insert("height", m_cropHeight);
crop.insert("zoom", m_zoom);
m_service->setCrop(m_screen, crop);
}
} // namespace ZShell::internal
@@ -0,0 +1,82 @@
#pragma once
#include <QObject>
#include <QPointer>
#include <QString>
#include <QVariantMap>
#include <QtQml/qqml.h>
#include <qtmetamacros.h>
#include "wallpaperservice.hpp"
namespace ZShell::internal {
class WallpaperObject : public QObject {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(
WallpaperService* service READ service WRITE setService NOTIFY
serviceChanged)
Q_PROPERTY(QString screen READ screen WRITE setScreen NOTIFY screenChanged)
Q_PROPERTY(qreal cropX READ cropX WRITE setCropX NOTIFY cropXChanged)
Q_PROPERTY(qreal cropY READ cropY WRITE setCropY NOTIFY cropYChanged)
Q_PROPERTY(
qreal cropWidth READ cropWidth WRITE setCropWidth NOTIFY
cropWidthChanged)
Q_PROPERTY(
qreal cropHeight READ cropHeight WRITE setCropHeight NOTIFY
cropHeightChanged)
Q_PROPERTY(qreal zoom READ zoom WRITE setZoom NOTIFY zoomChanged)
public:
explicit WallpaperObject(QObject* parent = nullptr);
~WallpaperObject() override;
WallpaperService* service() const { return m_service; }
void setService(WallpaperService* service);
QString screen() const { return m_screen; }
void setScreen(const QString& screen);
qreal cropX() const { return m_cropX; }
void setCropX(qreal x);
qreal cropY() const { return m_cropY; }
void setCropY(qreal y);
qreal cropWidth() const { return m_cropWidth; }
void setCropWidth(qreal w);
qreal cropHeight() const { return m_cropHeight; }
void setCropHeight(qreal h);
qreal zoom() const { return m_zoom; }
void setZoom(qreal zoom);
signals:
void serviceChanged();
void screenChanged();
void cropXChanged();
void cropYChanged();
void cropWidthChanged();
void cropHeightChanged();
void zoomChanged();
private:
void registerScreen();
void push();
QPointer<WallpaperService> m_service;
QString m_screen;
qreal m_cropX = 0.0;
qreal m_cropY = 0.0;
qreal m_cropWidth = 1.0;
qreal m_cropHeight = 1.0;
qreal m_zoom = 1.0;
};
} // namespace ZShell::internal
@@ -0,0 +1,743 @@
#define EGL_EGLEXT_PROTOTYPES
#define GL_GLEXT_PROTOTYPES
#include "wallpaperservice.hpp"
#include <EGL/eglext.h>
#include <GL/gl.h>
#include <GL/glext.h>
#include <fcntl.h>
#include <poll.h>
#include <unistd.h>
#include <QDebug>
#include <QGuiApplication>
#include <QImageReader>
#include <QScreen>
#include <qguiapplication_platform.h>
#include <private/qwaylandscreen_p.h>
#include <cstring>
namespace ZShell::internal {
namespace {
const char* kVertexShader = R"(
#version 300 es
void main() {
// The corners are (-1,-1), (3,-1) and (-1,3), which cover the whole buffer.
vec2 corner = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(corner * 2.0 - 1.0, 0.0, 1.0);
}
)";
const char* kFragmentShader = R"(
#version 300 es
precision highp float;
uniform sampler2D source;
uniform vec2 size;
uniform vec4 crop;
out vec4 outColor;
void main() {
// The pixels are stored top-down, so the fragment coordinate is flipped.
vec2 p = vec2(gl_FragCoord.x, size.y - gl_FragCoord.y) / size;
outColor = texture(source, crop.xy + p * crop.zw);
}
)";
constexpr int kWaitTimeoutMs = 20;
void logShader(uint32_t object, bool program) {
int ok = 0;
if (program)
glGetProgramiv(object, GL_LINK_STATUS, &ok);
else
glGetShaderiv(object, GL_COMPILE_STATUS, &ok);
if (ok) return;
char info[512] = {};
if (program)
glGetProgramInfoLog(object, int(sizeof(info)), nullptr, info);
else
glGetShaderInfoLog(object, int(sizeof(info)), nullptr, info);
qWarning() << "WallpaperService:" << (program ? "linking" : "compiling")
<< "failed:" << info;
}
QVariantMap cropFor(const QVariantMap& crops, const QString& screen) {
QVariantMap crop = crops.value(screen).toMap();
if (crop.isEmpty()) {
crop.insert("x", 0.0);
crop.insert("y", 0.0);
crop.insert("width", 1.0);
crop.insert("height", 1.0);
crop.insert("zoom", 1.0);
}
return crop;
}
} // namespace
WallpaperService::WallpaperService(QObject* parent) : QObject(parent) {}
WallpaperService::~WallpaperService() {
m_stop = true;
wake();
if (m_thread.joinable()) m_thread.join();
for (auto* surface : m_surfaces)
delete surface;
m_surfaces.clear();
if (m_eglDisplay != EGL_NO_DISPLAY && m_context != EGL_NO_CONTEXT) {
if (eglMakeCurrent(
m_eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, m_context)) {
if (m_texture) glDeleteTextures(1, &m_texture);
if (m_program) glDeleteProgram(m_program);
}
eglDestroyContext(m_eglDisplay, m_context);
}
if (m_queue) wl_event_queue_destroy(m_queue);
if (m_wake[0] >= 0) {
close(m_wake[0]);
close(m_wake[1]);
}
if (m_layerShell) m_layerShell->destroy();
if (m_viewporter) m_viewporter->destroy();
if (m_scaleManager) m_scaleManager->destroy();
if (m_registry) wl_registry_destroy(m_registry);
}
QString WallpaperService::path() const {
std::lock_guard<std::recursive_mutex> lock(m_mutex);
return m_path;
}
void WallpaperService::setPath(const QString& path) {
if (m_path == path) return;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
m_path = path;
m_forceDecode = true;
m_imageChanged = true;
}
emit pathChanged();
start();
queueAll();
}
void WallpaperService::addScreen(const QString& screen) {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (m_screens.contains(screen)) return;
m_screens.append(screen);
}
start();
queueAll();
}
void WallpaperService::removeScreen(const QString& screen) {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (!m_screens.removeOne(screen)) return;
}
queueAll();
}
const QVariantMap& WallpaperService::crops() const {
std::lock_guard<std::recursive_mutex> lock(m_mutex);
return m_crops;
}
void WallpaperService::setCrops(const QVariantMap& crops) {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (m_crops == crops) return;
m_crops = crops;
}
emit cropsChanged();
queueAll();
}
void WallpaperService::setCrop(const QString& screen, const QVariantMap& crop) {
WallpaperSurface* surface = nullptr;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (m_crops.value(screen) == crop) return;
m_crops.insert(screen, crop);
for (auto* candidate : m_surfaces) {
if (candidate->screen() == screen) {
surface = candidate;
break;
}
}
}
emit cropsChanged();
if (surface) queue(surface);
}
void WallpaperService::redraw() {
queueAll();
}
void WallpaperService::start() {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (started || m_path.isEmpty() || m_screens.isEmpty()) return;
started = true;
}
auto* interface =
qApp->nativeInterface<QNativeInterface::QWaylandApplication>();
if (!interface) {
qWarning() << "WallpaperService: this is not a Wayland session";
return;
}
m_display = interface->display();
m_compositor = interface->compositor();
if (!m_display || !m_compositor) return;
m_queue = wl_display_create_queue(m_display);
if (!m_queue) return;
if (pipe2(m_wake, O_CLOEXEC) < 0) {
qWarning() << "WallpaperService: could not make the pipe to wake on";
return;
}
bindGlobals();
if (!setupGl()) return;
m_drawing = true;
m_thread = std::thread([this] { loop(); });
wake();
}
void WallpaperService::wake() {
if (m_wake[1] < 0) return;
char byte = 0;
if (::write(m_wake[1], &byte, 1) < 0)
qWarning() << "WallpaperService: could not wake the drawing thread";
}
void WallpaperService::queue(WallpaperSurface* surface) {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
m_pending.push_back(surface);
}
wake();
}
void WallpaperService::queueAll() {
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
for (auto* surface : m_surfaces)
m_pending.push_back(surface);
}
wake();
}
void WallpaperService::loop() {
while (!m_stop) {
present();
if (wl_display_prepare_read(m_display) == 0) {
wl_display_flush(m_display);
struct pollfd fds[2] = {
{wl_display_get_fd(m_display), POLLIN, 0},
{m_wake[0], POLLIN, 0},
};
int ready = poll(fds, 2, kWaitTimeoutMs);
if (ready > 0 && fds[0].revents) {
wl_display_read_events(m_display);
continue;
}
if (ready > 0 && fds[1].revents) {
char byte = 0;
::read(m_wake[0], &byte, 1);
}
wl_display_cancel_read(m_display);
continue;
}
struct pollfd fds[1] = {{m_wake[0], POLLIN, 0}};
if (poll(fds, 1, kWaitTimeoutMs) <= 0) continue;
char byte = 0;
::read(m_wake[0], &byte, 1);
}
}
void WallpaperService::present() {
wl_display_dispatch_queue_pending(m_display, m_queue);
addSurfaces();
ensureDecodeSize();
std::vector<WallpaperSurface*> pending;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
pending.swap(m_pending);
}
for (auto* surface : pending)
drawSurface(surface);
}
void WallpaperService::addSurfaces() {
if (!started || !m_layerShell || !m_viewporter || !m_scaleManager) return;
QList<WallpaperSurface*> gone;
QList<QString> needed;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
QList<WallpaperSurface*> kept;
for (auto* surface : m_surfaces) {
if (m_screens.contains(surface->screen()))
kept.append(surface);
else
gone.append(surface);
}
m_surfaces = kept;
for (const auto& screen : m_screens) {
bool exists = false;
for (auto* surface : m_surfaces) {
if (surface->screen() == screen) {
exists = true;
break;
}
}
if (!exists) needed.append(screen);
}
}
for (auto* surface : gone)
delete surface;
for (const auto& screen : needed)
addSurface(screen);
}
void WallpaperService::registryGlobal(
void* data,
struct wl_registry* registry,
uint32_t name,
const char* interface,
uint32_t version) {
auto* service = static_cast<WallpaperService*>(data);
void* proxy = nullptr;
if (strcmp(interface, "zwlr_layer_shell_v1") == 0) {
proxy = new QtWayland::zwlr_layer_shell_v1(
registry, int(name), int(version));
} else if (strcmp(interface, "wp_viewporter") == 0) {
proxy = new QtWayland::wp_viewporter(registry, int(name), int(version));
} else if (strcmp(interface, "wp_fractional_scale_manager_v1") == 0) {
proxy = new QtWayland::wp_fractional_scale_manager_v1(
registry, int(name), int(version));
} else {
return;
}
service->storeGlobal(interface, proxy);
service->queueAll();
}
void WallpaperService::storeGlobal(const char* interface, void* proxy) {
std::lock_guard<std::recursive_mutex> lock(m_mutex);
if (strcmp(interface, "zwlr_layer_shell_v1") == 0)
m_layerShell = static_cast<QtWayland::zwlr_layer_shell_v1*>(proxy);
else if (strcmp(interface, "wp_viewporter") == 0)
m_viewporter = static_cast<QtWayland::wp_viewporter*>(proxy);
else if (strcmp(interface, "wp_fractional_scale_manager_v1") == 0)
m_scaleManager =
static_cast<QtWayland::wp_fractional_scale_manager_v1*>(proxy);
}
void WallpaperService::bindGlobals() {
m_registry = wl_display_get_registry(m_display);
static const struct wl_registry_listener listener = {
registryGlobal,
nullptr,
};
wl_registry_add_listener(m_registry, &listener, this);
}
bool WallpaperService::setupGl() {
const char* extensions = eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS);
if (extensions && strstr(extensions, "EGL_EXT_platform_wayland")) {
m_eglDisplay =
eglGetPlatformDisplay(EGL_PLATFORM_WAYLAND_EXT, m_display, nullptr);
} else {
m_eglDisplay =
eglGetDisplay(reinterpret_cast<EGLNativeDisplayType>(m_display));
}
if (m_eglDisplay == EGL_NO_DISPLAY) {
qWarning() << "WallpaperService: no EGL display";
return false;
}
EGLint major = 0, minor = 0;
if (!eglInitialize(m_eglDisplay, &major, &minor)) {
qWarning() << "WallpaperService: eglInitialize failed";
return false;
}
const EGLint configAttributes[] = {
EGL_SURFACE_TYPE,
EGL_WINDOW_BIT,
EGL_RENDERABLE_TYPE,
EGL_OPENGL_ES2_BIT,
EGL_RED_SIZE,
8,
EGL_GREEN_SIZE,
8,
EGL_BLUE_SIZE,
8,
EGL_ALPHA_SIZE,
8,
EGL_NONE};
EGLint count = 0;
if (!eglChooseConfig(m_eglDisplay, configAttributes, &m_config, 1, &count) ||
count == 0) {
qWarning() << "WallpaperService: no EGL config for window surfaces";
return false;
}
const EGLint contextAttributes[] = {EGL_CONTEXT_MAJOR_VERSION, 3, EGL_NONE};
m_context = eglCreateContext(
m_eglDisplay, m_config, EGL_NO_CONTEXT, contextAttributes);
if (m_context == EGL_NO_CONTEXT) {
qWarning() << "WallpaperService: eglCreateContext failed";
return false;
}
return true;
}
uint32_t WallpaperService::createProgram() {
uint32_t vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &kVertexShader, nullptr);
glCompileShader(vertex);
logShader(vertex, false);
uint32_t fragment = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragment, 1, &kFragmentShader, nullptr);
glCompileShader(fragment);
logShader(fragment, false);
uint32_t program = glCreateProgram();
glAttachShader(program, vertex);
glAttachShader(program, fragment);
glLinkProgram(program);
logShader(program, true);
if (glGetUniformLocation(program, "size") < 0 ||
glGetUniformLocation(program, "crop") < 0) {
qWarning() << "WallpaperService: the program does not have the "
"uniforms it needs";
glDeleteProgram(program);
return 0;
}
m_sizeUniform = glGetUniformLocation(program, "size");
m_cropUniform = glGetUniformLocation(program, "crop");
glDeleteShader(vertex);
glDeleteShader(fragment);
return program;
}
void WallpaperService::uploadImage() {
if (m_image.isNull()) return;
if (m_texture == 0) {
glGenTextures(1, &m_texture);
glBindTexture(GL_TEXTURE_2D, m_texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
} else {
glBindTexture(GL_TEXTURE_2D, m_texture);
}
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(
GL_TEXTURE_2D,
0,
GL_RGBA8,
m_image.width(),
m_image.height(),
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
m_image.constBits());
}
bool WallpaperService::decode(const QString& path) {
if (path.isEmpty()) return false;
QImageReader reader(path);
reader.setAutoTransform(true);
QSize size = reader.size();
if (size.isValid()) m_sourceSize = size;
if (!m_decodeSize.isEmpty() && !m_sourceSize.isEmpty())
reader.setScaledSize(
m_sourceSize.scaled(m_decodeSize, Qt::KeepAspectRatioByExpanding));
QImage image = reader.read();
if (image.isNull()) {
qWarning() << "WallpaperService: could not read the image" << path;
return false;
}
m_image = image.convertToFormat(QImage::Format_RGBA8888);
return !m_image.isNull();
}
bool WallpaperService::ensureDecodeSize() {
QSize largest;
QString path;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
largest = largestBufferSize();
if (largest.isEmpty()) return false;
if (!m_forceDecode && !m_decodeSize.isEmpty() &&
qint64(largest.width()) * largest.height() <=
qint64(m_decodeSize.width()) * m_decodeSize.height())
return false;
QSize target =
m_sourceSize.isEmpty()
? largest
: m_sourceSize.scaled(largest, Qt::KeepAspectRatioByExpanding);
if (!m_forceDecode && !m_image.isNull() && target == m_image.size())
return false;
m_forceDecode = false;
m_decodeSize = largest;
path = m_path;
}
if (!decode(path)) return false;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
m_imageChanged = true;
}
return true;
}
QSize WallpaperService::largestBufferSize() {
QSize largest;
for (auto* surface : m_surfaces) {
QSize buffer = surface->bufferSize();
if (buffer.isEmpty()) continue;
if (largest.isEmpty() || qint64(buffer.width()) * buffer.height() >
qint64(largest.width()) * largest.height())
largest = buffer;
}
return largest;
}
QRectF WallpaperService::sourceRect(
const QVariantMap& crops, const QString& screen, const QSize& size) const {
QVariantMap crop = cropFor(crops, screen);
QRectF rect(
crop.value("x", 0.0).toDouble(),
crop.value("y", 0.0).toDouble(),
crop.value("width", 1.0).toDouble(),
crop.value("height", 1.0).toDouble());
if (size.isEmpty() || rect.isEmpty() || m_image.isNull()) return rect;
qreal imageWidth = qreal(m_image.width());
qreal imageHeight = qreal(m_image.height());
QRectF pixels(
rect.x() * imageWidth,
rect.y() * imageHeight,
rect.width() * imageWidth,
rect.height() * imageHeight);
qreal targetRatio = qreal(size.width()) / qreal(size.height());
qreal rectRatio = pixels.width() / pixels.height();
if (rectRatio > targetRatio) {
qreal newWidth = pixels.height() * targetRatio;
pixels.setX(pixels.x() + (pixels.width() - newWidth) / 2.0);
pixels.setWidth(newWidth);
} else if (rectRatio < targetRatio) {
qreal newHeight = pixels.width() / targetRatio;
pixels.setY(pixels.y() + (pixels.height() - newHeight) / 2.0);
pixels.setHeight(newHeight);
}
return QRectF(
pixels.x() / imageWidth,
pixels.y() / imageHeight,
pixels.width() / imageWidth,
pixels.height() / imageHeight);
}
void WallpaperService::drawSurface(WallpaperSurface* surface) {
EGLSurface target = surface->eglSurface();
if (m_context == EGL_NO_CONTEXT || target == EGL_NO_SURFACE ||
surface->bufferSize().isEmpty())
return;
if (!eglMakeCurrent(m_eglDisplay, target, target, m_context)) {
qWarning() << "WallpaperService: the context cannot be made current";
return;
}
QVariantMap crops;
QSize size;
bool upload = false;
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
crops = m_crops;
size = surface->bufferSize();
upload = m_imageChanged;
m_imageChanged = false;
}
if (size.isEmpty()) return;
if (m_program == 0) m_program = createProgram();
if (m_texture == 0 || upload) uploadImage();
if (m_program == 0 || m_texture == 0 || m_image.isNull()) return;
glUseProgram(m_program);
glBindTexture(GL_TEXTURE_2D, m_texture);
glUniform2f(
GLint(m_sizeUniform), GLfloat(size.width()), GLfloat(size.height()));
QRectF rect = sourceRect(crops, surface->screen(), size);
glUniform4f(
GLint(m_cropUniform),
GLfloat(rect.x()),
GLfloat(rect.y()),
GLfloat(rect.width()),
GLfloat(rect.height()));
glViewport(0, 0, size.width(), size.height());
glDrawArrays(GL_TRIANGLES, 0, 3);
if (!eglSwapBuffers(m_eglDisplay, target))
qWarning() << "WallpaperService: eglSwapBuffers failed";
}
void WallpaperService::addSurface(const QString& screen) {
if (!m_layerShell || !m_viewporter || !m_scaleManager) return;
QScreen* qscreen = nullptr;
for (auto* candidate : QGuiApplication::screens()) {
if (candidate->name() == screen) {
qscreen = candidate;
break;
}
}
if (!qscreen) {
qWarning() << "WallpaperService:" << screen << "is not a screen";
return;
}
auto* waylandScreen =
dynamic_cast<QtWaylandClient::QWaylandScreen*>(qscreen->handle());
if (!waylandScreen || !waylandScreen->output()) {
qWarning() << "WallpaperService:" << screen
<< "is not a Wayland screen";
return;
}
wl_surface* wlSurface = wl_compositor_create_surface(m_compositor);
if (!wlSurface) return;
auto* surface =
new WallpaperSurface(this, wlSurface, waylandScreen->output(), screen);
{
std::lock_guard<std::recursive_mutex> lock(m_mutex);
m_surfaces.append(surface);
}
}
} // namespace ZShell::internal
@@ -0,0 +1,147 @@
#pragma once
#include <EGL/egl.h>
#include <cstdint>
#include <mutex>
#include <thread>
#include <vector>
#include <QImage>
#include <QList>
#include <QRectF>
#include <QSize>
#include <QString>
#include <QStringList>
#include <QVariantMap>
#include <QtQml/qqml.h>
#include <qtmetamacros.h>
#include <qwayland-wlr-layer-shell-unstable-v1.h>
#include <wayland-client-protocol.h>
#include "wallpapersurface.hpp"
namespace ZShell::internal {
class WallpaperSurface;
class WallpaperService : public QObject {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED)
Q_PROPERTY(QVariantMap crops READ crops WRITE setCrops NOTIFY cropsChanged)
public:
explicit WallpaperService(QObject* parent = nullptr);
~WallpaperService() override;
[[nodiscard]] QString path() const;
void setPath(const QString& path);
void addScreen(const QString& screen);
void removeScreen(const QString& screen);
[[nodiscard]] const QVariantMap& crops() const;
void setCrops(const QVariantMap& crops);
Q_INVOKABLE void redraw();
Q_INVOKABLE void setCrop(const QString& screen, const QVariantMap& crop);
signals:
void pathChanged();
void cropsChanged();
private:
friend class WallpaperSurface;
void bindGlobals();
static void registryGlobal(
void* data,
struct wl_registry* registry,
uint32_t name,
const char* interface,
uint32_t version);
void storeGlobal(const char* interface, void* proxy);
[[nodiscard]] bool setupGl();
[[nodiscard]] uint32_t createProgram();
void uploadImage();
bool decode(const QString& path);
bool ensureDecodeSize();
[[nodiscard]] QSize largestBufferSize();
void queue(WallpaperSurface* surface);
void queueAll();
void startDrawing();
void loop();
void present();
void drawSurface(WallpaperSurface* surface);
void wake();
[[nodiscard]] wl_event_queue* eventQueue() const { return m_queue; }
[[nodiscard]] QRectF sourceRect(
const QVariantMap& crops,
const QString& screen,
const QSize& size) const;
void start();
void addSurfaces();
void addSurface(const QString& screen);
QString m_path;
QStringList m_screens;
QVariantMap m_crops;
QImage m_image;
QSize m_sourceSize;
QSize m_decodeSize;
wl_display* m_display = nullptr;
wl_registry* m_registry = nullptr;
wl_compositor* m_compositor = nullptr;
QtWayland::zwlr_layer_shell_v1* m_layerShell = nullptr;
QtWayland::wp_viewporter* m_viewporter = nullptr;
QtWayland::wp_fractional_scale_manager_v1* m_scaleManager = nullptr;
EGLDisplay m_eglDisplay = EGL_NO_DISPLAY;
EGLConfig m_config = nullptr;
EGLContext m_context = EGL_NO_CONTEXT;
uint32_t m_texture = 0;
uint32_t m_program = 0;
int m_sizeUniform = -1;
int m_cropUniform = -1;
QList<WallpaperSurface*> m_surfaces;
std::thread m_thread;
mutable std::recursive_mutex m_mutex;
std::vector<WallpaperSurface*> m_pending;
wl_event_queue* m_queue = nullptr;
int m_wake[2] = {-1, -1};
bool m_stop = false;
bool m_drawing = false;
bool started = false;
bool m_forceDecode = false;
bool m_imageChanged = false;
};
} // namespace ZShell::internal
@@ -0,0 +1,147 @@
#include "wallpapersurface.hpp"
#include <QDebug>
#include <cmath>
#include "wallpaperservice.hpp"
namespace ZShell::internal {
const QString kNamespace = "ZShell-Wallpaper";
void setQueue(void* proxy, struct wl_event_queue* queue) {
wl_proxy_set_queue(static_cast<struct wl_proxy*>(proxy), queue);
}
WallpaperSurface::WallpaperSurface(
WallpaperService* service,
wl_surface* surface,
wl_output* output,
const QString& screen)
: m_service(service), m_screen(screen), m_surface(surface) {
if (!surface) {
qWarning() << "WallpaperSurface: no surface for" << screen;
return;
}
setQueue(m_surface, m_service->eventQueue());
auto* layer = m_service->m_layerShell->get_layer_surface(
m_surface,
output,
QtWayland::zwlr_layer_shell_v1::layer_background,
kNamespace);
setQueue(layer, m_service->eventQueue());
QtWayland::zwlr_layer_surface_v1::init(layer);
set_size(0, 0);
set_anchor(anchor_top | anchor_bottom | anchor_left | anchor_right);
set_exclusive_zone(-1);
set_keyboard_interactivity(keyboard_interactivity_none);
set_margin(0, 0, 0, 0);
auto* viewport = m_service->m_viewporter->get_viewport(m_surface);
setQueue(viewport, m_service->eventQueue());
QtWayland::wp_viewport::init(viewport);
auto* scale = m_service->m_scaleManager->get_fractional_scale(m_surface);
setQueue(scale, m_service->eventQueue());
QtWayland::wp_fractional_scale_v1::init(scale);
wl_surface_commit(m_surface);
}
WallpaperSurface::~WallpaperSurface() {
if (m_eglSurface != EGL_NO_SURFACE)
eglDestroySurface(m_service->m_eglDisplay, m_eglSurface);
if (m_window) wl_egl_window_destroy(m_window);
if (QtWayland::wp_viewport::isInitialized())
QtWayland::wp_viewport::destroy();
if (QtWayland::wp_fractional_scale_v1::isInitialized())
QtWayland::wp_fractional_scale_v1::destroy();
if (QtWayland::zwlr_layer_surface_v1::isInitialized())
QtWayland::zwlr_layer_surface_v1::destroy();
if (m_surface) wl_surface_destroy(m_surface);
}
void WallpaperSurface::redraw() {
m_service->queue(this);
}
void WallpaperSurface::zwlr_layer_surface_v1_configure(
uint32_t serial, uint32_t width, uint32_t height) {
ack_configure(serial);
if (width == 0 || height == 0) return;
m_size = QSize(int(width), int(height));
if (!configured) configured = true;
updateBuffer();
}
void WallpaperSurface::wp_fractional_scale_v1_preferred_scale(uint32_t scale) {
if (scale == 0) return;
qreal newScale = qreal(scale) / 120.0;
if (qFuzzyCompare(newScale, m_scale)) return;
m_scale = newScale;
updateBuffer();
}
void WallpaperSurface::zwlr_layer_surface_v1_closed() {}
void WallpaperSurface::updateBuffer() {
if (m_size.isEmpty()) return;
QSize buffer = QSize(
int(std::llround(m_size.width() * m_scale)),
int(std::llround(m_size.height() * m_scale)));
if (buffer.isEmpty()) return;
if (m_bufferSize != buffer) {
m_bufferSize = buffer;
if (m_window) {
wl_egl_window_resize(
m_window, buffer.width(), buffer.height(), 0, 0);
} else {
m_window =
wl_egl_window_create(m_surface, buffer.width(), buffer.height());
if (!m_window) {
qWarning()
<< "WallpaperSurface: could not make a window surface for"
<< m_screen;
return;
}
}
if (m_eglSurface == EGL_NO_SURFACE) {
m_eglSurface = eglCreateWindowSurface(
m_service->m_eglDisplay,
m_service->m_config,
reinterpret_cast<EGLNativeWindowType>(m_window),
nullptr);
if (m_eglSurface == EGL_NO_SURFACE) {
qWarning()
<< "WallpaperSurface: eglCreateWindowSurface failed for"
<< m_screen;
return;
}
}
}
if (QtWayland::wp_viewport::isInitialized())
set_destination(m_size.width(), m_size.height());
m_service->queue(this);
}
} // namespace ZShell::internal
@@ -0,0 +1,58 @@
#pragma once
#include <EGL/egl.h>
#include <wayland-egl.h>
#include <QSize>
#include <QString>
#include <qwayland-fractional-scale-v1.h>
#include <qwayland-viewporter.h>
#include <qwayland-wlr-layer-shell-unstable-v1.h>
#include <wayland-client-protocol.h>
namespace ZShell::internal {
class WallpaperService;
class WallpaperSurface : public QtWayland::zwlr_layer_surface_v1,
public QtWayland::wp_viewport,
public QtWayland::wp_fractional_scale_v1 {
public:
WallpaperSurface(
WallpaperService* service,
wl_surface* surface,
wl_output* output,
const QString& screen);
~WallpaperSurface() override;
[[nodiscard]] const QString& screen() const { return m_screen; }
[[nodiscard]] const QSize& size() const { return m_size; }
[[nodiscard]] const QSize& bufferSize() const { return m_bufferSize; }
[[nodiscard]] EGLSurface eglSurface() const { return m_eglSurface; }
void redraw();
private:
void zwlr_layer_surface_v1_configure(
uint32_t serial, uint32_t width, uint32_t height) override;
void zwlr_layer_surface_v1_closed() override;
void wp_fractional_scale_v1_preferred_scale(uint32_t scale) override;
void updateBuffer();
WallpaperService* m_service;
QString m_screen;
wl_surface* m_surface;
wl_egl_window* m_window = nullptr;
EGLSurface m_eglSurface = EGL_NO_SURFACE;
QSize m_size;
QSize m_bufferSize;
qreal m_scale = 1.0;
bool configured = false;
};
} // namespace ZShell::internal
+5 -2
View File
@@ -232,22 +232,25 @@ qml_module(ZShell-llm
URI ZShell.Llm URI ZShell.Llm
SOURCES SOURCES
attachment.hpp attachment.cpp attachment.hpp attachment.cpp
chat.hpp chat.cpp
chatstore.hpp chatstore.cpp chatstore.hpp chatstore.cpp
clipboard.hpp clipboard.cpp
codehighlighter.hpp codehighlighter.cpp codehighlighter.hpp codehighlighter.cpp
filetool.hpp filetool.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 markdownblock.cpp
markdownparser.hpp markdownparser.cpp markdownparser.hpp markdownparser.cpp
mathtext.hpp mathtext.cpp mathtext.hpp mathtext.cpp
message.hpp message.cpp message.hpp message.cpp
messagemodel.hpp messagemodel.cpp messagemodel.hpp messagemodel.cpp
prompts.hpp prompts.cpp
segment.hpp segment.cpp segment.hpp segment.cpp
session.hpp session.cpp session.hpp session.cpp
shelltool.hpp shelltool.cpp shelltool.hpp shelltool.cpp
tool.hpp tool.cpp tool.hpp tool.cpp
turn.hpp turn.cpp
webfetchtool.hpp webfetchtool.cpp webfetchtool.hpp webfetchtool.cpp
workspace.hpp workspace.cpp
LIBRARIES LIBRARIES
Qt::Network Qt::Network
Qt::Sql Qt::Sql
-201
View File
@@ -1,201 +0,0 @@
#include "chat.hpp"
#include "filetool.hpp"
#include "llmclient.hpp"
#include "shelltool.hpp"
#include "webfetchtool.hpp"
#include "config/rootnodes.hpp"
#include "config/llm.hpp"
#include <QDebug>
namespace ZShell::llm {
Chat::Chat(QObject* parent)
: QObject(parent)
, m_store(new ChatStore(this))
, m_client(new LlmClient(this)) {
m_store->setLlmClient(m_client);
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
m_client->tools()->registerTool(new FileReadTool(m_client->tools()));
m_client->tools()->registerTool(new ShellTool(m_client, m_client->tools()));
const auto* llm = config::ConfigSingleton::instance()->llm();
m_client->setEndpoint(llm->endpoint());
m_client->setModel(llm->model());
m_client->setTemperature(llm->temperature());
m_client->setToolsEnabled(llm->tools());
m_client->setSandboxEnabled(llm->sandbox());
connect(llm, &config::Llm::endpointChanged, this, [this, llm]() {
m_client->setEndpoint(llm->endpoint());
});
connect(llm, &config::Llm::modelChanged, this, [this, llm]() {
m_client->setModel(llm->model());
});
connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() {
m_client->setTemperature(llm->temperature());
});
connect(llm, &config::Llm::toolsChanged, this, [this, llm]() {
m_client->setToolsEnabled(llm->tools());
});
connect(llm, &config::Llm::sandboxChanged, this, [this, llm]() {
m_client->setSandboxEnabled(llm->sandbox());
Q_EMIT sandboxEnabledChanged();
});
connect(m_client, &LlmClient::busyChanged, this, [this]() {
if (m_client->busy() && (m_hasError || !m_errorMessage.isEmpty())) {
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
}
Q_EMIT busyChanged();
});
connect(m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged);
connect(m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
connect(
m_client,
&LlmClient::availableModelsChanged,
this,
&Chat::availableModelsChanged);
connect(
m_client,
&LlmClient::contextSizeChanged,
this,
&Chat::contextSizeChanged);
connect(
m_client,
&LlmClient::streamingChatIdChanged,
this,
&Chat::streamingChatIdChanged);
connect(
m_client,
&LlmClient::toolsEnabledChanged,
this,
&Chat::toolsEnabledChanged);
connect(
m_client,
&LlmClient::errorOccurred,
this,
[this](const QString& message) {
m_hasError = !message.isEmpty();
m_errorMessage = message;
Q_EMIT errorChanged();
Q_EMIT errorOccurred(message);
});
connect(
m_client,
&LlmClient::serverErrorChanged,
this,
&Chat::serverErrorChanged);
connect(
m_store,
&ChatStore::sessionRemoved,
this,
[this](ChatSession* session) { m_client->sessionRemoved(session); });
connect(
m_client,
&LlmClient::titleSuggested,
this,
[this](ChatSession* session, const QString& title) {
qInfo() << "Chat: applying generated title" << session->id()
<< title << "(was" << session->title() << ")";
session->setTitle(title);
m_store->saveMeta(session);
});
connect(
m_client,
&LlmClient::iconSuggested,
this,
[this](ChatSession* session, const QString& icon) {
qInfo() << "Chat: applying generated icon" << session->id() << icon
<< "(was" << session->icon() << ")";
session->setIcon(icon);
m_store->saveMeta(session);
});
}
bool Chat::busy() const {
return m_client->busy();
}
QString Chat::endpoint() const {
return m_client->endpoint();
}
QString Chat::model() const {
return m_client->model();
}
QStringList Chat::availableModels() const {
return m_client->availableModels();
}
int Chat::contextSize() const {
return m_client->contextSize();
}
bool Chat::toolsEnabled() const {
return m_client->toolsEnabled();
}
void Chat::setToolsEnabled(bool value) {
m_client->setToolsEnabled(value);
if (auto* config = config::ConfigSingleton::instance())
config->llm()->set_tools(value);
}
bool Chat::sandboxEnabled() const {
return m_client->sandboxEnabled();
}
void Chat::setSandboxEnabled(bool value) {
m_client->setSandboxEnabled(value);
if (auto* config = config::ConfigSingleton::instance())
config->llm()->set_sandbox(value);
}
QString Chat::serverError() const {
return m_client->serverError();
}
QString Chat::streamingChatId() const {
return m_client->streamingChatId();
}
Chat* Chat::s_instance = nullptr;
Chat* Chat::create(QQmlEngine*, QJSEngine*) {
if (!s_instance) s_instance = new Chat();
return s_instance;
}
void Chat::stop() {
m_client->stop();
}
void Chat::dismissError() {
if (!m_hasError && m_errorMessage.isEmpty()) return;
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
}
void Chat::refreshModels() {
m_client->refreshModels();
}
void Chat::refreshFromServer() {
m_client->refreshAllStates();
}
void Chat::selectModel(const QString& id) {
if (id.isEmpty()) return;
m_client->setModel(id);
if (auto* config = config::ConfigSingleton::instance())
config->llm()->set_model(id);
}
} // namespace ZShell::llm
-91
View File
@@ -1,91 +0,0 @@
#pragma once
#include <QObject>
#include <QString>
#include <QStringList>
#include <QtQml>
#include "chatstore.hpp"
class QQmlEngine;
class QJSEngine;
namespace ZShell::llm {
class LlmClient;
class Chat : public QObject {
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged)
Q_PROPERTY(QString model READ model NOTIFY modelChanged)
Q_PROPERTY(
QStringList availableModels READ availableModels NOTIFY
availableModelsChanged)
Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged)
Q_PROPERTY(
bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY
toolsEnabledChanged)
Q_PROPERTY(
bool sandboxEnabled READ sandboxEnabled WRITE setSandboxEnabled NOTIFY
sandboxEnabledChanged)
Q_PROPERTY(bool error READ error NOTIFY errorChanged)
Q_PROPERTY(QString errorMessage READ errorMessage NOTIFY errorChanged)
Q_PROPERTY(QString serverError READ serverError NOTIFY serverErrorChanged)
Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT)
Q_PROPERTY(
QString streamingChatId READ streamingChatId NOTIFY
streamingChatIdChanged)
public:
explicit Chat(QObject* parent = nullptr);
[[nodiscard]] bool busy() const;
[[nodiscard]] QString endpoint() const;
[[nodiscard]] QString model() const;
[[nodiscard]] QStringList availableModels() const;
[[nodiscard]] int contextSize() const;
[[nodiscard]] bool toolsEnabled() const;
void setToolsEnabled(bool value);
[[nodiscard]] bool sandboxEnabled() const;
void setSandboxEnabled(bool value);
[[nodiscard]] bool error() const { return m_hasError; }
[[nodiscard]] QString errorMessage() const { return m_errorMessage; }
[[nodiscard]] QString serverError() const;
[[nodiscard]] ChatStore* chats() const { return m_store; }
[[nodiscard]] QString streamingChatId() const;
Q_INVOKABLE void stop();
Q_INVOKABLE void dismissError();
Q_INVOKABLE void refreshModels();
Q_INVOKABLE void refreshFromServer();
Q_INVOKABLE void selectModel(const QString& id);
static Chat* create(QQmlEngine*, QJSEngine*);
Q_SIGNALS:
void busyChanged();
void endpointChanged();
void modelChanged();
void availableModelsChanged();
void contextSizeChanged();
void toolsEnabledChanged();
void sandboxEnabledChanged();
void errorOccurred(const QString& message);
void errorChanged();
void serverErrorChanged();
void streamingChatIdChanged();
private:
ChatStore* m_store = nullptr;
LlmClient* m_client = nullptr;
bool m_hasError = false;
QString m_errorMessage;
static Chat* s_instance;
};
} // namespace ZShell::llm
+240 -146
View File
@@ -1,6 +1,8 @@
#include "chatstore.hpp" #include "chatstore.hpp"
#include "attachment.hpp"
#include "llmclient.hpp" #include "llmclient.hpp"
#include "markdownblock.hpp"
#include "message.hpp" #include "message.hpp"
#include "segment.hpp" #include "segment.hpp"
@@ -11,15 +13,12 @@
#include <QFileInfo> #include <QFileInfo>
#include <QPointer> #include <QPointer>
#include <QStandardPaths> #include <QStandardPaths>
#include <QSqlDatabase>
#include <QSqlError> #include <QSqlError>
#include <QSqlQuery> #include <QSqlQuery>
#include <QThreadPool> #include <QThreadPool>
#include <QVector> #include <QVector>
#include <QUuid> #include <QUuid>
#include <algorithm>
namespace ZShell::llm { namespace ZShell::llm {
namespace { namespace {
@@ -76,22 +75,25 @@ struct AttachmentRow {
struct MessageRow { struct MessageRow {
bool user = false; bool user = false;
qint64 timestamp = 0; qint64 timestamp = 0;
QString content;
QVector<GenerationRow> generations; QVector<GenerationRow> generations;
QVector<AttachmentRow> attachments; QVector<AttachmentRow> attachments;
}; };
} // namespace } // namespace
ChatStore::ChatStore(QObject* parent) ChatStore::ChatStore(LlmClient* client, QObject* parent)
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) { : QAbstractListModel(parent)
, m_client(client)
, m_connectionName(QUuid::createUuid().toString()) {
openDb(); openDb();
load(); load();
} }
ChatStore::~ChatStore() { ChatStore::~ChatStore() {
if (m_connectionName.isEmpty()) return; if (m_connectionName.isEmpty()) return;
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false); QSqlDatabase handle = QSqlDatabase::database(m_connectionName, false);
db.close(); handle.close();
QSqlDatabase::removeDatabase(m_connectionName); QSqlDatabase::removeDatabase(m_connectionName);
} }
@@ -105,20 +107,20 @@ void ChatStore::openDb() {
QStringLiteral("/zshell/chats.sqlite"); QStringLiteral("/zshell/chats.sqlite");
QDir().mkpath(QFileInfo(m_dbPath).absolutePath()); QDir().mkpath(QFileInfo(m_dbPath).absolutePath());
QSqlDatabase db = QSqlDatabase handle =
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
db.setDatabaseName(m_dbPath); handle.setDatabaseName(m_dbPath);
if (!db.open()) { if (!handle.open()) {
qWarning() << "ChatStore: failed to open database" << m_dbPath << ":" qWarning() << "ChatStore: failed to open database" << m_dbPath << ":"
<< db.lastError().text(); << handle.lastError().text();
return; return;
} }
{ {
QSqlQuery pragma(db); QSqlQuery pragma(handle);
pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON")); pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(handle);
query.exec(QStringLiteral( query.exec(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"
@@ -130,18 +132,23 @@ void ChatStore::openDb() {
")")); ")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(handle);
query.exec(QStringLiteral( query.exec(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"
" content TEXT NOT NULL DEFAULT '',\n"
" timestamp INTEGER NOT NULL\n" " timestamp INTEGER NOT NULL\n"
")")); ")"));
if (!query.exec(QStringLiteral(
"ALTER TABLE messages ADD COLUMN content TEXT "
"NOT NULL DEFAULT ''"))) {
}
} }
{ {
QSqlQuery query(db); QSqlQuery query(handle);
query.exec(QStringLiteral( query.exec(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"
@@ -152,7 +159,7 @@ void ChatStore::openDb() {
")")); ")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(handle);
query.exec(QStringLiteral( query.exec(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"
@@ -170,7 +177,7 @@ void ChatStore::openDb() {
")")); ")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(handle);
query.exec(QStringLiteral( query.exec(QStringLiteral(
"CREATE TABLE IF NOT EXISTS attachments (\n" "CREATE TABLE IF NOT EXISTS attachments (\n"
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n" " id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
@@ -184,7 +191,7 @@ void ChatStore::openDb() {
")")); ")"));
} }
{ {
QSqlQuery query(db); QSqlQuery query(handle);
query.exec(QStringLiteral( query.exec(QStringLiteral(
"CREATE INDEX IF NOT EXISTS idx_messages_session " "CREATE INDEX IF NOT EXISTS idx_messages_session "
"ON messages (session_id)")); "ON messages (session_id)"));
@@ -200,93 +207,156 @@ void ChatStore::openDb() {
} }
} }
int ChatStore::count() const { int ChatStore::rowCount(const QModelIndex& parent) const {
if (parent.isValid()) return 0;
return static_cast<int>(m_sessions.size()); return static_cast<int>(m_sessions.size());
} }
QVariantList ChatStore::values() const { QVariant ChatStore::data(const QModelIndex& index, int role) const {
QVariantList vals; if (role != Qt::UserRole || !index.isValid() || index.row() < 0 ||
vals.reserve(m_sessions.size()); index.row() >= m_sessions.size())
for (const auto* session : m_sessions) return {};
vals.append(QVariant::fromValue(session)); return QVariant::fromValue(m_sessions.at(index.row()));
return vals;
} }
ChatSession* ChatStore::at(int index) const { QHash<int, QByteArray> ChatStore::roleNames() const {
if (index < 0 || index >= m_sessions.size()) return nullptr; return {{Qt::UserRole, "modelData"}};
return m_sessions.at(index);
} }
ChatSession* ChatStore::insert(int index) { int ChatStore::rowOf(const ChatSession* session) const {
for (int i = 0; i < m_sessions.size(); ++i)
if (m_sessions.at(i) == session) return i;
return -1;
}
bool ChatStore::firstAfter(const ChatSession* a, const ChatSession* b) {
if (a->pinned() != b->pinned()) return a->pinned();
return a->updatedAtMs() > b->updatedAtMs();
}
int ChatStore::sortedRow(const ChatSession* session) const {
int row = 0;
while (row < m_sessions.size() && !firstAfter(session, m_sessions.at(row)))
++row;
return row;
}
void ChatStore::insertRow(ChatSession* session) {
const int row = sortedRow(session);
beginInsertRows(QModelIndex(), row, row);
m_sessions.insert(row, session);
endInsertRows();
}
void ChatStore::moveRow(int from, int to) {
if (from < 0 || from >= m_sessions.size() || to < 0 ||
to > m_sessions.size() || from == to || to == from + 1)
return;
if (!beginMoveRows(QModelIndex(), from, from, QModelIndex(), to)) return;
m_sessions.move(from, to > from ? to - 1 : to);
endMoveRows();
}
void ChatStore::reposition(ChatSession* session) {
if (m_loading) return;
const int row = rowOf(session);
if (row < 0) return;
const int target = sortedRow(session);
if (target == row) return;
moveRow(row, target);
}
ChatSession* ChatStore::draft() {
if (m_draft) return m_draft;
const qint64 now = QDateTime::currentMSecsSinceEpoch(); const qint64 now = QDateTime::currentMSecsSinceEpoch();
const QString id = QUuid::createUuid().toString(); m_draft = new ChatSession(this, QUuid::createUuid().toString(), this);
{ m_draft->hydrate(QString(), QString(), now, now, 0, false);
QSqlQuery query(db()); return m_draft;
query.prepare( }
"INSERT INTO sessions (id, title, created_at, updated_at) "
"VALUES (:id, '', :created_at, :updated_at)"); void ChatStore::commitDraft(ChatSession* session) {
query.bindValue(":id", id); if (!session || session->isCommitted()) return;
query.bindValue(":created_at", now); if (m_draft == session) m_draft = nullptr;
query.bindValue(":updated_at", now);
if (!query.exec()) if (!writeSessionRow(session)) return;
qWarning() << "ChatStore: failed to insert session" << id << ":" session->setCommitted(true);
<< query.lastError().text(); insertRow(session);
}
bool ChatStore::writeSessionRow(ChatSession* session) {
QSqlQuery query(db());
query.prepare(
"INSERT INTO sessions (id, title, icon, created_at, updated_at, "
"pinned) VALUES (:id, :title, :icon, :created_at, :updated_at, "
":pinned)");
query.bindValue(":id", session->id());
query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":created_at", session->createdAtMs());
query.bindValue(":updated_at", session->updatedAtMs());
query.bindValue(":pinned", session->pinned() ? 1 : 0);
if (!query.exec()) {
qWarning() << "ChatStore: failed to insert session" << session->id()
<< ":" << query.lastError().text();
return false;
} }
auto* session = new ChatSession(id, this); return true;
session->setMeta(QString(), now, now, 0);
const int pos = index >= 0 && index <= m_sessions.size() ? index : 0;
m_sessions.insert(pos, session);
Q_EMIT countChanged();
Q_EMIT valuesChanged();
return session;
} }
void ChatStore::remove(int index) { void ChatStore::removeRow(ChatSession* session) {
removeSession(at(index)); if (!session) return;
} const int row = rowOf(session);
void ChatStore::remove(ChatSession* chat) {
removeSession(chat);
}
void ChatStore::removeSession(ChatSession* session) {
if (!session || !m_sessions.contains(session)) return;
const QList<ChatSession*> before = m_sessions;
Q_EMIT sessionRemoved(session); Q_EMIT sessionRemoved(session);
{ {
QSqlQuery query(db()); QSqlQuery query(db());
query.prepare("DELETE FROM sessions WHERE id = :id"); query.prepare("DELETE FROM sessions WHERE id = :id");
query.bindValue(":id", session->id()); query.bindValue(":id", session->id());
query.exec(); query.exec();
} }
QDir(ChatSession::sandboxDirFor(session->id())).removeRecursively();
m_sessions.removeOne(session); if (row >= 0) {
beginRemoveRows(QModelIndex(), row, row);
m_sessions.removeAt(row);
endRemoveRows();
}
session->deleteLater(); session->deleteLater();
notify(before);
} }
void ChatStore::move(int from, int to) { void ChatStore::remove(ChatSession* chat) {
if (from < 0 || from >= m_sessions.size() || to < 0 || if (!chat) return;
to >= m_sessions.size() || from == to) m_pendingPersists.remove(chat);
m_pendingClears.remove(chat);
if (m_draft == chat) m_draft = nullptr;
if (!chat->isCommitted()) {
Q_EMIT sessionRemoved(chat);
chat->deleteLater();
return; return;
m_sessions.move(from, to); }
Q_EMIT valuesChanged();
removeRow(chat);
} }
void ChatStore::clear() { void ChatStore::clear() {
const QList<ChatSession*> sessions = m_sessions; const QList<ChatSession*> sessions = m_sessions;
for (ChatSession* session : sessions) for (ChatSession* session : sessions)
removeSession(session); remove(session);
} }
ChatSession* ChatStore::sessionById(const QString& id) { void ChatStore::requestClear(ChatSession* session) {
for (auto* session : m_sessions) if (!session) return;
if (session->id() == id) return session; m_pendingClears.insert(session);
return nullptr; if (session->isLoaded() && m_client->streamingSession() != session)
applyPendingClear(session);
} }
void ChatStore::setLlmClient(LlmClient* client) { void ChatStore::applyPendingClear(ChatSession* session) {
m_llmClient = client; if (!session || !m_pendingClears.remove(session)) return;
if (!session->isLoaded()) return;
session->clearMessages();
persist(session);
} }
void ChatStore::persist(ChatSession* session) { void ChatStore::persist(ChatSession* session) {
@@ -297,21 +367,23 @@ void ChatStore::persist(ChatSession* session) {
} }
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch()); session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
if (!saveSession(session)) return; if (!saveSession(session)) return;
sortAndNotify(); reposition(session);
} }
void ChatStore::saveMeta(ChatSession* session) { void ChatStore::saveFlags(ChatSession* session) {
if (!session || !m_sessions.contains(session)) return; if (!session || !m_sessions.contains(session)) return;
QSqlQuery query(db()); QSqlQuery query(db());
query.prepare( query.prepare(
"UPDATE sessions SET title = :title, icon = :icon " "UPDATE sessions SET title = :title, icon = :icon, "
"WHERE id = :id"); "pinned = :pinned 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(":pinned", session->pinned() ? 1 : 0);
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 flags for" << session->id()
<< ":" << query.lastError().text(); << ":" << query.lastError().text();
reposition(session);
} }
bool ChatStore::saveSession(ChatSession* session) { bool ChatStore::saveSession(ChatSession* session) {
@@ -326,10 +398,12 @@ bool ChatStore::saveSession(ChatSession* session) {
{ {
QSqlQuery query(handle); QSqlQuery query(handle);
query.prepare( query.prepare(
"UPDATE sessions SET title = :title, updated_at = :updated_at " "UPDATE sessions SET title = :title, icon = :icon, "
"WHERE id = :id"); "updated_at = :updated_at, pinned = :pinned WHERE id = :id");
query.bindValue(":title", sqlText(session->title())); query.bindValue(":title", sqlText(session->title()));
query.bindValue(":icon", sqlText(session->icon()));
query.bindValue(":updated_at", session->updatedAtMs()); query.bindValue(":updated_at", session->updatedAtMs());
query.bindValue(":pinned", session->pinned() ? 1 : 0);
query.bindValue(":id", session->id()); query.bindValue(":id", session->id());
ok = query.exec(); ok = query.exec();
} }
@@ -342,8 +416,8 @@ bool ChatStore::saveSession(ChatSession* session) {
if (ok) { if (ok) {
QSqlQuery messageInsert(handle); QSqlQuery messageInsert(handle);
ok = messageInsert.prepare( ok = messageInsert.prepare(
"INSERT INTO messages (session_id, role, timestamp) " "INSERT INTO messages (session_id, role, content, timestamp) "
"VALUES (:id, :role, :timestamp)"); "VALUES (:id, :role, :content, :timestamp)");
QSqlQuery generationInsert(handle); QSqlQuery generationInsert(handle);
ok = ok && ok = ok &&
generationInsert.prepare( generationInsert.prepare(
@@ -371,6 +445,7 @@ bool ChatStore::saveSession(ChatSession* session) {
message->role() == ChatMessage::Role::User message->role() == ChatMessage::Role::User
? QStringLiteral("user") ? QStringLiteral("user")
: QStringLiteral("assistant")); : QStringLiteral("assistant"));
messageInsert.bindValue(":content", sqlText(message->content()));
messageInsert.bindValue(":timestamp", message->timestamp()); messageInsert.bindValue(":timestamp", message->timestamp());
if (!messageInsert.exec()) { if (!messageInsert.exec()) {
ok = false; ok = false;
@@ -401,16 +476,24 @@ bool ChatStore::saveSession(ChatSession* session) {
segmentInsert.bindValue(":gid", generationId); segmentInsert.bindValue(":gid", generationId);
segmentInsert.bindValue( segmentInsert.bindValue(
":type", segmentTypeName(segment->type())); ":type", segmentTypeName(segment->type()));
segmentInsert.bindValue(":text", sqlText(segment->text())); const auto* text =
segmentInsert.bindValue(":name", sqlText(segment->name())); qobject_cast<const LlmTextSegment*>(segment);
segmentInsert.bindValue( segmentInsert.bindValue(
":tool_call_id", sqlText(segment->toolCallId())); ":text", sqlText(text ? text->text() : QString()));
const auto* call =
qobject_cast<const LlmToolCall*>(segment);
segmentInsert.bindValue( segmentInsert.bindValue(
":arguments", sqlText(segment->arguments())); ":name", sqlText(call ? call->name() : QString()));
segmentInsert.bindValue( segmentInsert.bindValue(
":result", sqlText(segment->result())); ":tool_call_id",
sqlText(call ? call->toolCallId() : QString()));
segmentInsert.bindValue( segmentInsert.bindValue(
":status", static_cast<int>(segment->status())); ":arguments",
sqlText(call ? call->arguments() : QString()));
segmentInsert.bindValue(
":result", sqlText(call ? call->result() : QString()));
segmentInsert.bindValue(
":status", call ? static_cast<int>(call->status()) : 0);
segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs()); segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs());
segmentInsert.bindValue(":timestamp", segment->timestamp()); segmentInsert.bindValue(":timestamp", segment->timestamp());
if (!segmentInsert.exec()) { if (!segmentInsert.exec()) {
@@ -448,12 +531,13 @@ bool ChatStore::saveSession(ChatSession* session) {
qWarning() << "ChatStore: saveSession" << id qWarning() << "ChatStore: saveSession" << id
<< "commit failed, rolling back"; << "commit failed, rolling back";
handle.rollback(); handle.rollback();
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 false;
} }
return ok; return true;
} }
void ChatStore::loadMessagesInto(ChatSession* session) { void ChatStore::loadMessagesInto(ChatSession* session) {
if (!session) return; if (!session) return;
const QString sessionId = session->id(); const QString sessionId = session->id();
@@ -467,15 +551,15 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
QList<MessageRow> rows; QList<MessageRow> rows;
const QString connName = QUuid::createUuid().toString(); const QString connName = QUuid::createUuid().toString();
{ {
QSqlDatabase db = QSqlDatabase handle =
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName); QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName);
db.setDatabaseName(path); handle.setDatabaseName(path);
if (db.open()) { if (handle.open()) {
QSqlQuery busy(db); QSqlQuery busy(handle);
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000")); busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
QSqlQuery query(db); QSqlQuery query(handle);
query.prepare( query.prepare(
"SELECT id, role, timestamp FROM messages " "SELECT id, role, content, timestamp FROM messages "
"WHERE session_id = :id ORDER BY rowid DESC"); "WHERE session_id = :id ORDER BY rowid DESC");
query.bindValue(":id", sessionId); query.bindValue(":id", sessionId);
if (!query.exec()) { if (!query.exec()) {
@@ -487,8 +571,9 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
MessageRow message; MessageRow message;
message.user = query.value(1).toString() == message.user = query.value(1).toString() ==
QLatin1String("user"); QLatin1String("user");
message.timestamp = query.value(2).toLongLong(); message.content = query.value(2).toString();
QSqlQuery generationQuery(db); message.timestamp = query.value(3).toLongLong();
QSqlQuery generationQuery(handle);
generationQuery.prepare( generationQuery.prepare(
"SELECT id, timestamp, is_active FROM " "SELECT id, timestamp, is_active FROM "
"generations WHERE message_id = :mid " "generations WHERE message_id = :mid "
@@ -501,7 +586,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
generationQuery.value(1).toLongLong(); generationQuery.value(1).toLongLong();
generation.active = generation.active =
generationQuery.value(2).toInt() != 0; generationQuery.value(2).toInt() != 0;
QSqlQuery segmentQuery(db); QSqlQuery segmentQuery(handle);
segmentQuery.prepare( segmentQuery.prepare(
"SELECT type, text, name, tool_call_id, " "SELECT type, text, name, tool_call_id, "
"arguments, result, status, elapsed_ms, " "arguments, result, status, elapsed_ms, "
@@ -549,7 +634,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
<< messageId << ":" << messageId << ":"
<< generationQuery.lastError().text(); << generationQuery.lastError().text();
} }
QSqlQuery attachmentQuery(db); QSqlQuery attachmentQuery(handle);
attachmentQuery.prepare( attachmentQuery.prepare(
"SELECT name, path, kind, mime, size FROM " "SELECT name, path, kind, mime, size FROM "
"attachments WHERE message_id = :mid " "attachments WHERE message_id = :mid "
@@ -580,10 +665,10 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
rows.append(message); rows.append(message);
} }
} }
db.close(); handle.close();
} else { } else {
qWarning() << "ChatStore: failed to open database for load:" qWarning() << "ChatStore: failed to open database for load:"
<< db.lastError().text(); << handle.lastError().text();
} }
} }
QSqlDatabase::removeDatabase(connName); QSqlDatabase::removeDatabase(connName);
@@ -595,7 +680,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
ChatSession* s = session; ChatSession* s = session;
if (!st || !s) return; if (!st || !s) return;
auto* model = s->model(); auto* model = s->messagesModel();
if (!model) return; if (!model) return;
QList<ChatMessage*> messages; QList<ChatMessage*> messages;
for (const MessageRow& row : rows) { for (const MessageRow& row : rows) {
@@ -603,6 +688,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
row.user ? ChatMessage::Role::User row.user ? ChatMessage::Role::User
: ChatMessage::Role::Assistant, : ChatMessage::Role::Assistant,
row.timestamp); row.timestamp);
if (!row.attachments.isEmpty()) { if (!row.attachments.isEmpty()) {
QList<Attachment*> attachments; QList<Attachment*> attachments;
for (const AttachmentRow& attachmentRow : for (const AttachmentRow& attachmentRow :
@@ -619,27 +705,54 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
} }
message->setAttachments(attachments); message->setAttachments(attachments);
} }
if (row.user) {
QString content = row.content;
if (content.isEmpty() && !row.generations.isEmpty()) {
for (const SegmentRow& segment :
row.generations.first().segments)
if (segmentTypeFromName(segment.type) ==
LlmSegment::Type::Content) {
content = segment.text;
break;
}
}
if (!content.isEmpty()) message->setContent(content);
messages.append(message);
continue;
}
int activeIndex = 0; int activeIndex = 0;
for (int i = 0; i < row.generations.size(); ++i) { for (int i = 0; i < row.generations.size(); ++i) {
const GenerationRow& generationRow = const GenerationRow& generationRow =
row.generations.at(i); row.generations.at(i);
auto* generation = auto* generation =
message->addGeneration(generationRow.timestamp); message->addGeneration(generationRow.timestamp);
if (!generation) continue;
for (const SegmentRow& segmentRow : for (const SegmentRow& segmentRow :
generationRow.segments) { generationRow.segments) {
auto* segment = new LlmSegment( const LlmSegment::Type type =
segmentTypeFromName(segmentRow.type), segmentTypeFromName(segmentRow.type);
segmentRow.timestamp, LlmSegment* segment = nullptr;
generation); if (type == LlmSegment::Type::ToolCall) {
segment->setText(segmentRow.text); auto* call = new LlmToolCall(
segment->setName(segmentRow.name); segmentRow.timestamp, generation);
segment->setToolCallId(segmentRow.toolCallId); call->setName(segmentRow.name);
segment->appendArguments(segmentRow.arguments); call->setToolCallId(segmentRow.toolCallId);
segment->setResult(segmentRow.result); call->appendArguments(segmentRow.arguments);
segment->setStatus( call->setResult(segmentRow.result);
static_cast<LlmSegment::Status>( call->setStatus(
segmentRow.status)); static_cast<LlmToolCall::Status>(
segment->restore(segmentRow.elapsedMs); segmentRow.status));
call->restore(segmentRow.elapsedMs);
segment = call;
} else {
auto* text = new LlmTextSegment(
type, segmentRow.timestamp, generation);
text->setText(segmentRow.text);
text->restore(segmentRow.elapsedMs);
segment = text;
}
generation->addSegment(segment); generation->addSegment(segment);
} }
if (generationRow.active) activeIndex = i; if (generationRow.active) activeIndex = i;
@@ -658,54 +771,35 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
s->markLoaded(); s->markLoaded();
if (s->takeClearPending()) { st->applyPendingClear(s);
s->clear(); if (st->m_pendingPersists.remove(s)) st->persist(s);
} else if (st->m_pendingPersists.remove(s)) {
st->persist(s);
}
}, },
Qt::QueuedConnection); Qt::QueuedConnection);
}); });
} }
void ChatStore::load() { void ChatStore::load() {
m_loading = true;
QSqlQuery query(db()); QSqlQuery query(db());
query.exec( query.exec(
"SELECT s.id, s.title, s.icon, s.created_at, s.updated_at, s.pinned, " "SELECT s.id, s.title, s.icon, s.created_at, s.updated_at, s.pinned, "
"(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS cnt " "(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS cnt "
"FROM sessions s ORDER BY s.pinned DESC, s.updated_at DESC"); "FROM sessions s ORDER BY s.pinned DESC, s.updated_at DESC");
while (query.next()) { while (query.next()) {
auto* session = new ChatSession(query.value(0).toString(), this); auto* session = new ChatSession(this, query.value(0).toString(), this);
session->setMeta( session->hydrate(
query.value(1).toString(), query.value(1).toString(),
query.value(2).toString(),
query.value(3).toLongLong(), query.value(3).toLongLong(),
query.value(4).toLongLong(), query.value(4).toLongLong(),
query.value(6).toInt()); query.value(6).toInt(),
session->setIcon(query.value(2).toString()); query.value(5).toInt() != 0);
session->setPinned(query.value(5).toInt() != 0);
m_sessions.append(session); m_sessions.append(session);
} }
sortAndNotify(); m_loading = false;
}
void ChatStore::sortAndNotify() { beginResetModel();
const QList<ChatSession*> before = m_sessions; endResetModel();
std::stable_sort(
m_sessions.begin(),
m_sessions.end(),
[](const ChatSession* a, const ChatSession* b) {
if (a->pinned() != b->pinned()) return a->pinned() > b->pinned();
return a->updatedAtMs() > b->updatedAtMs();
});
notify(before);
}
void ChatStore::notify(const QList<ChatSession*>& before) {
if (before.size() != m_sessions.size()) Q_EMIT countChanged();
bool same = before.size() == m_sessions.size();
for (int i = 0; same && i < m_sessions.size(); ++i)
if (before.at(i) != m_sessions.at(i)) same = false;
if (!same) Q_EMIT valuesChanged();
} }
} // namespace ZShell::llm } // namespace ZShell::llm
+40 -29
View File
@@ -2,65 +2,76 @@
#include "session.hpp" #include "session.hpp"
#include <QObject> #include <QAbstractListModel>
#include <QHash>
#include <QList>
#include <QSet> #include <QSet>
#include <QSqlDatabase> #include <QSqlDatabase>
#include <QString> #include <QString>
#include <QVariantList> #include <QtQml>
namespace ZShell::llm { namespace ZShell::llm {
class LlmClient; class LlmClient;
class ChatStore : public QObject { class ChatStore : public QAbstractListModel {
Q_OBJECT Q_OBJECT
QML_ANONYMOUS QML_ELEMENT
QML_UNCREATABLE("Chat sessions are owned by the LlmClient singleton")
Q_PROPERTY(int count READ count NOTIFY countChanged)
Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged)
public: public:
explicit ChatStore(QObject* parent = nullptr); explicit ChatStore(LlmClient* client, QObject* parent = nullptr);
~ChatStore() override; ~ChatStore() override;
[[nodiscard]] int count() const; [[nodiscard]] int rowCount(
[[nodiscard]] QVariantList values() const; const QModelIndex& parent = QModelIndex()) const override;
[[nodiscard]] ChatSession* at(int index) const; [[nodiscard]] QVariant data(
const QModelIndex& index, int role = Qt::DisplayRole) const override;
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE ZShell::llm::ChatSession* insert(int index = -1); [[nodiscard]] LlmClient* client() const { return m_client; }
Q_INVOKABLE void remove(int index);
[[nodiscard]] int rowOf(const ChatSession* session) const;
Q_INVOKABLE ZShell::llm::ChatSession* draft();
Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat); Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat);
Q_INVOKABLE void move(int from, int to);
Q_INVOKABLE void clear(); Q_INVOKABLE void clear();
[[nodiscard]] ChatSession* sessionById(const QString& id); void commitDraft(ZShell::llm::ChatSession* session);
[[nodiscard]] LlmClient* llmClient() const { return m_llmClient; }
void setLlmClient(LlmClient* client);
void persist(ChatSession* session); void persist(ZShell::llm::ChatSession* session);
void saveMeta(ChatSession* session); void saveFlags(ZShell::llm::ChatSession* session);
void loadMessagesInto(ChatSession* session); void loadMessagesInto(ZShell::llm::ChatSession* session);
void requestClear(ZShell::llm::ChatSession* session);
void applyPendingClear(ZShell::llm::ChatSession* session);
void reposition(ZShell::llm::ChatSession* session);
Q_SIGNALS: Q_SIGNALS:
void countChanged();
void valuesChanged();
void sessionRemoved(ZShell::llm::ChatSession* session); void sessionRemoved(ZShell::llm::ChatSession* session);
private: private:
void openDb(); void openDb();
void load(); void load();
bool saveSession(ChatSession* session); [[nodiscard]] int sortedRow(const ChatSession* session) const;
void sortAndNotify(); [[nodiscard]] static bool firstAfter(
void removeSession(ChatSession* session); const ChatSession* a, const ChatSession* b);
void notify(const QList<ChatSession*>& before); void insertRow(ChatSession* session);
void moveRow(int from, int to);
void removeRow(ChatSession* session);
[[nodiscard]] bool saveSession(ChatSession* session);
[[nodiscard]] bool writeSessionRow(ChatSession* session);
[[nodiscard]] QSqlDatabase db() const;
LlmClient* m_client;
QList<ChatSession*> m_sessions; QList<ChatSession*> m_sessions;
LlmClient* m_llmClient = nullptr; ChatSession* m_draft = nullptr;
QString m_connectionName; QString m_connectionName;
QString m_dbPath; QString m_dbPath;
QSet<ChatSession*> m_pendingPersists; QSet<ChatSession*> m_pendingPersists;
QSet<ChatSession*> m_pendingClears;
[[nodiscard]] QSqlDatabase db() const; bool m_loading = false;
}; };
} // namespace ZShell::llm } // namespace ZShell::llm
+61
View File
@@ -0,0 +1,61 @@
#include "clipboard.hpp"
#include <QDebug>
#include <QClipboard>
#include <QDateTime>
#include <QDir>
#include <QGuiApplication>
#include <QImage>
#include <QMimeData>
#include <QUrl>
namespace ZShell::llm::clipboard {
bool hasAttachment() {
QClipboard* clipboard = QGuiApplication::clipboard();
if (!clipboard) return false;
const QMimeData* data = clipboard->mimeData();
if (!data) return false;
if (data->hasImage()) return true;
for (const QUrl& url : data->urls())
if (url.isLocalFile()) return true;
return false;
}
QStringList attachmentPaths(QString& error) {
error.clear();
QClipboard* clipboard = QGuiApplication::clipboard();
const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr;
if (!data) {
qWarning() << "clipboard: no clipboard data";
return {};
}
QStringList paths;
if (data->hasImage()) {
const QImage image = clipboard->image();
if (image.isNull()) {
error = QStringLiteral("Clipboard image could not be decoded");
return {};
}
const QString tempPath =
QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png")
.arg(QDateTime::currentMSecsSinceEpoch());
if (!image.save(tempPath)) {
error = QStringLiteral("Could not save the pasted image");
return {};
}
paths.append(tempPath);
} else if (data->hasUrls()) {
for (const QUrl& url : data->urls())
if (url.isLocalFile()) paths.append(url.toLocalFile());
}
if (paths.isEmpty() && (data->hasImage() || data->hasUrls()))
error = QStringLiteral(
"Nothing usable on the clipboard (expected an image "
"or copied files)");
return paths;
}
} // namespace ZShell::llm::clipboard
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include <QString>
#include <QStringList>
namespace ZShell::llm::clipboard {
[[nodiscard]] bool hasAttachment();
[[nodiscard]] QStringList attachmentPaths(QString& error);
} // namespace ZShell::llm::clipboard
+27 -44
View File
@@ -6,23 +6,8 @@
namespace ZShell::llm { namespace ZShell::llm {
namespace { FileReadTool::FileReadTool(ToolRegistry* registry, QObject* parent)
: LlmTool(registry, parent) {}
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 { QString FileReadTool::name() const {
return QStringLiteral("readfile"); return QStringLiteral("readfile");
@@ -34,8 +19,8 @@ QString FileReadTool::description() const {
"its content. At most a limited number of characters is returned " "its content. At most a limited number of characters is returned "
"per call; use offset and limit to page through larger files. " "per call; use offset and limit to page through larger files. "
"Binary files are not supported. Full output of webfetch calls " "Binary files are not supported. Full output of webfetch calls "
"is stored under /tmp/zshell-llm/webfetch/ and can be read this " "is saved in the sandbox directory and can be read this way. "
"way. This tool is read-only."); "This tool is read-only.");
} }
QJsonObject FileReadTool::parameters() const { QJsonObject FileReadTool::parameters() const {
@@ -72,19 +57,16 @@ QJsonObject FileReadTool::parameters() const {
} }
void FileReadTool::execute( void FileReadTool::execute(
const QString& toolCallId, const ToolContext& context, const QJsonObject& args, ToolDone done) {
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject&)> done) {
Q_UNUSED(toolCallId);
const QString path = args[QStringLiteral("path")].toString().trimmed(); const QString path = args[QStringLiteral("path")].toString().trimmed();
if (path.isEmpty()) { if (path.isEmpty()) {
done(makeError(QStringLiteral("Missing required argument 'path'"))); done({false, QStringLiteral("Missing required argument 'path'")});
return; return;
} }
const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0)); const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0));
int budget = inlineBudgetChars(); const int budget = context.outputBudgetChars > 0
if (outputBudgetChars > 0) budget = qMin(budget, outputBudgetChars); ? context.outputBudgetChars
: registry()->outputBudgetChars(1);
const int limit = const int limit =
qBound(1, args[QStringLiteral("limit")].toInt(budget), budget); qBound(1, args[QStringLiteral("limit")].toInt(budget), budget);
@@ -92,33 +74,33 @@ void FileReadTool::execute(
if (!file.open(QIODevice::ReadOnly)) { if (!file.open(QIODevice::ReadOnly)) {
const QFileInfo info(path); const QFileInfo info(path);
if (info.exists() && info.isDir()) { if (info.exists() && info.isDir()) {
done(makeError(QStringLiteral( done(
"Path is a directory, not a " {false,
"file: %1") QStringLiteral("Path is a directory, not a file: %1")
.arg(path))); .arg(path)});
} else { } else {
done(makeError(QStringLiteral("Cannot open file: %1").arg(path))); done({false, QStringLiteral("Cannot open file: %1").arg(path)});
} }
return; return;
} }
const qint64 fileSize = file.size(); const qint64 fileSize = file.size();
if (fileSize == 0) { if (fileSize == 0) {
file.close(); file.close();
done(makeOutput(QString())); done({true, QString()});
return; return;
} }
if (offset >= fileSize) { if (offset >= fileSize) {
file.close(); file.close();
done(makeError(QStringLiteral( done(
"Offset %1 is beyond the end of the " {false,
"file (%2 bytes)") QStringLiteral("Offset %1 is beyond the end of the file (%2 bytes)")
.arg(offset) .arg(offset)
.arg(fileSize))); .arg(fileSize)});
return; return;
} }
if (!file.seek(offset)) { if (!file.seek(offset)) {
file.close(); file.close();
done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path))); done({false, QStringLiteral("Cannot seek in file: %1").arg(path)});
return; return;
} }
const qint64 wanted = qint64(limit) * 4 + 16; const qint64 wanted = qint64(limit) * 4 + 16;
@@ -126,17 +108,18 @@ void FileReadTool::execute(
file.close(); file.close();
if (chunk.contains('\0')) { if (chunk.contains('\0')) {
done(makeError( done(
QStringLiteral("Binary files are not supported: %1").arg(path))); {false,
QStringLiteral("Binary files are not supported: %1").arg(path)});
return; return;
} }
const QString content = QString::fromUtf8(chunk); const QString content = QString::fromUtf8(chunk);
if (content.size() <= limit) { if (content.size() <= limit) {
done(makeOutput(content)); done({true, content});
return; return;
} }
const QString head = content.left(limit); const QString head = content.left(limit);
const int nextOffset = offset + head.toUtf8().size(); const int nextOffset = offset + static_cast<int>(head.toUtf8().size());
const QString output = const QString output =
head + head +
QStringLiteral( QStringLiteral(
@@ -147,7 +130,7 @@ void FileReadTool::execute(
.arg(nextOffset) .arg(nextOffset)
.arg(fileSize) .arg(fileSize)
.arg(nextOffset); .arg(nextOffset);
done(makeOutput(output)); done({true, output});
} }
} // namespace ZShell::llm } // namespace ZShell::llm
+3 -4
View File
@@ -8,16 +8,15 @@ class FileReadTool : public LlmTool {
Q_OBJECT Q_OBJECT
public: public:
explicit FileReadTool(QObject* parent = nullptr); explicit FileReadTool(ToolRegistry* registry, QObject* parent = nullptr);
QString name() const override; QString name() const override;
QString description() const override; QString description() const override;
QJsonObject parameters() const override; QJsonObject parameters() const override;
void execute( void execute(
const QString& toolCallId, const ToolContext& context,
const QJsonObject& args, const QJsonObject& args,
int outputBudgetChars, ToolDone done) override;
std::function<void(const QJsonObject& result)> done) override;
}; };
} // namespace ZShell::llm } // namespace ZShell::llm
+112 -101
View File
@@ -1,37 +1,50 @@
#include "generation.hpp" #include "generation.hpp"
#include "llmclient.hpp" #include "llmclient.hpp"
#include "messagemodel.hpp" #include "message.hpp"
#include "session.hpp" #include "session.hpp"
#include <QDateTime> #include <QDateTime>
namespace ZShell::llm { namespace ZShell::llm {
ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent) ChatGeneration::ChatGeneration(
: QObject(parent), m_timestamp(timestamp) { ChatMessage* message, qint64 timestamp, QObject* parent)
: QObject(parent), m_message(message), m_timestamp(timestamp) {
m_timer.setParent(this); m_timer.setParent(this);
m_timer.setInterval(500); m_timer.setInterval(500);
m_timer.setTimerType(Qt::CoarseTimer); m_timer.setTimerType(Qt::CoarseTimer);
connect(&m_timer, &QTimer::timeout, this, [this]() { connect(&m_timer, &QTimer::timeout, this, [this]() {
bool anyRunning = false; bool runningText = false;
bool runningTool = false;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (!segment->running()) continue; if (!segment->running()) continue;
anyRunning = true; Q_EMIT segment->elapsedMsChanged();
segment->elapsedMsChanged(); if (segment->type() == LlmSegment::Type::ToolCall)
runningTool = true;
else
runningText = true;
} }
if (anyRunning) Q_EMIT elapsedMsChanged(); if (runningText) {
if (!anyRunning && !m_streaming) m_timer.stop(); Q_EMIT reasoningElapsedMsChanged();
Q_EMIT contentElapsedMsChanged();
}
if (runningTool) Q_EMIT toolsElapsedMsChanged();
if (!runningText && !runningTool && !m_streaming) m_timer.stop();
}); });
} }
ChatMessage* ChatGeneration::message() const {
return m_message;
}
QString ChatGeneration::content() const { QString ChatGeneration::content() const {
QStringList parts; QStringList parts;
for (const auto* segment : m_segments) { for (const auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content || if (segment->type() != LlmSegment::Type::Content) continue;
segment->text().isEmpty()) const auto* text = qobject_cast<const LlmTextSegment*>(segment);
continue; if (!text || text->text().isEmpty()) continue;
parts.append(segment->text()); parts.append(text->text());
} }
return parts.join(QStringLiteral("\n\n")); return parts.join(QStringLiteral("\n\n"));
} }
@@ -39,10 +52,10 @@ QString ChatGeneration::content() const {
QString ChatGeneration::reasoning() const { QString ChatGeneration::reasoning() const {
QStringList parts; QStringList parts;
for (const auto* segment : m_segments) { for (const auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Reasoning || if (segment->type() != LlmSegment::Type::Reasoning) continue;
segment->text().isEmpty()) const auto* text = qobject_cast<const LlmTextSegment*>(segment);
continue; if (!text || text->text().isEmpty()) continue;
parts.append(segment->text()); parts.append(text->text());
} }
return parts.join(QStringLiteral("\n\n")); return parts.join(QStringLiteral("\n\n"));
} }
@@ -77,25 +90,27 @@ qint64 ChatGeneration::toolsElapsedMs() const {
return total; return total;
} }
QList<LlmToolCall*> ChatGeneration::toolCalls() const {
QList<LlmToolCall*> calls;
for (auto* segment : m_segments)
if (auto* call = qobject_cast<LlmToolCall*>(segment))
calls.append(call);
return calls;
}
int ChatGeneration::toolCallCount() const { int ChatGeneration::toolCallCount() const {
int count = 0; return static_cast<int>(toolCalls().size());
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall) ++count;
return count;
} }
bool ChatGeneration::hasRunningTool() const { bool ChatGeneration::hasRunningTool() const {
for (const auto* segment : m_segments) for (const auto* call : toolCalls())
if (segment->type() == LlmSegment::Type::ToolCall && segment->running()) if (call->running()) return true;
return true;
return false; return false;
} }
bool ChatGeneration::hasPendingTool() const { bool ChatGeneration::hasPendingTool() const {
for (const auto* segment : m_segments) for (const auto* call : toolCalls())
if (segment->type() == LlmSegment::Type::ToolCall && if (call->status() == LlmToolCall::Status::Pending) return true;
segment->status() == LlmSegment::Status::Pending)
return true;
return false; return false;
} }
@@ -103,30 +118,37 @@ void ChatGeneration::setApprovalPending(bool value) {
if (m_approvalPending == value) return; if (m_approvalPending == value) return;
m_approvalPending = value; m_approvalPending = value;
Q_EMIT toolApprovalPendingChanged(); Q_EMIT toolApprovalPendingChanged();
Q_EMIT pendingToolCallsChanged();
} }
QVariantList ChatGeneration::pendingToolCalls() const { QVariantList ChatGeneration::pendingToolCalls() const {
QVariantList out; QVariantList out;
if (!m_approvalPending) return out; if (!m_approvalPending) return out;
for (const auto* segment : m_segments) for (const auto* call : toolCalls())
if (segment->type() == LlmSegment::Type::ToolCall && if (call->status() == LlmToolCall::Status::Pending)
segment->status() == LlmSegment::Status::Pending) out.append(QVariant::fromValue(call));
out.append(QVariant::fromValue(segment));
return out; return out;
} }
void ChatGeneration::approveTools() { void ChatGeneration::approveTools() {
if (auto* llmClient = client()) llmClient->approveTools(); if (auto* llmClient = client()) llmClient->approveTools(this);
} }
void ChatGeneration::denyTools() { void ChatGeneration::denyTools() {
if (auto* llmClient = client()) llmClient->denyTools(); if (auto* llmClient = client()) llmClient->denyTools(this);
}
void ChatGeneration::dismissError() {
if (!m_hasError && m_errorMessage.isEmpty()) return;
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
Q_EMIT errorMessageChanged();
} }
LlmClient* ChatGeneration::client() const { LlmClient* ChatGeneration::client() const {
if (auto* message = qobject_cast<ChatMessage*>(parent())) if (m_message && m_message->session())
if (auto* model = qobject_cast<ChatMessageModel*>(message->parent())) return m_message->session()->client();
if (auto* session = model->session()) return session->client();
return nullptr; return nullptr;
} }
@@ -138,21 +160,19 @@ void ChatGeneration::updateReasoningActive() {
} }
void ChatGeneration::setContent(const QString& value) { void ChatGeneration::setContent(const QString& value) {
LlmSegment* first = nullptr; LlmTextSegment* first = nullptr;
for (auto* segment : m_segments) { for (auto* segment : m_segments) {
if (segment->type() != LlmSegment::Type::Content) continue; if (segment->type() != LlmSegment::Type::Content) continue;
auto* text = qobject_cast<LlmTextSegment*>(segment);
if (!text) continue;
if (!first) if (!first)
first = segment; first = text;
else else
segment->setText(QString()); text->setText(QString());
} }
if (!first) { if (!first) {
if (value.isEmpty()) return; if (value.isEmpty()) return;
first = new LlmSegment( first = openTextSegment(LlmSegment::Type::Content);
LlmSegment::Type::Content,
QDateTime::currentMSecsSinceEpoch(),
this);
addSegment(first);
} }
first->setText(value); first->setText(value);
} }
@@ -164,7 +184,7 @@ void ChatGeneration::appendContent(const QString& piece) {
segment->running()) segment->running())
segment->close(); segment->close();
} }
openContentSegment()->appendText(piece); openTextSegment(LlmSegment::Type::Content)->appendText(piece);
} }
void ChatGeneration::appendReasoning(const QString& piece) { void ChatGeneration::appendReasoning(const QString& piece) {
@@ -173,7 +193,7 @@ void ChatGeneration::appendReasoning(const QString& piece) {
if (segment->type() == LlmSegment::Type::Content && segment->running()) if (segment->type() == LlmSegment::Type::Content && segment->running())
segment->close(); segment->close();
} }
openReasoningSegment()->appendText(piece); openTextSegment(LlmSegment::Type::Reasoning)->appendText(piece);
} }
void ChatGeneration::setError(const QString& value) { void ChatGeneration::setError(const QString& value) {
@@ -182,13 +202,7 @@ void ChatGeneration::setError(const QString& value) {
m_hasError = hasError; m_hasError = hasError;
m_errorMessage = value; m_errorMessage = value;
Q_EMIT errorChanged(); Q_EMIT errorChanged();
} Q_EMIT errorMessageChanged();
void ChatGeneration::dismissError() {
if (!m_hasError && m_errorMessage.isEmpty()) return;
m_hasError = false;
m_errorMessage.clear();
Q_EMIT errorChanged();
} }
void ChatGeneration::setStreaming(bool value) { void ChatGeneration::setStreaming(bool value) {
@@ -201,70 +215,67 @@ void ChatGeneration::setStreaming(bool value) {
closeOpenSegments(); closeOpenSegments();
m_timer.stop(); m_timer.stop();
} }
Q_EMIT elapsedMsChanged(); Q_EMIT reasoningElapsedMsChanged();
Q_EMIT contentElapsedMsChanged();
Q_EMIT toolsElapsedMsChanged();
updateReasoningActive(); updateReasoningActive();
} }
LlmSegment* ChatGeneration::openContentSegment() { LlmTextSegment* ChatGeneration::openTextSegment(LlmSegment::Type type) {
for (auto* segment : m_segments) for (auto* segment : m_segments) {
if (segment->type() == LlmSegment::Type::Content && segment->running()) if (segment->type() != type) continue;
return segment; auto* text = qobject_cast<LlmTextSegment*>(segment);
auto* segment = new LlmSegment( if (text && text->running()) return text;
LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), this); }
segment->begin(); auto* text =
addSegment(segment); new LlmTextSegment(type, QDateTime::currentMSecsSinceEpoch(), this);
return segment; text->begin();
addSegment(text);
return text;
} }
LlmSegment* ChatGeneration::openReasoningSegment() { LlmToolCall* ChatGeneration::beginToolCall(
for (auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::Reasoning &&
segment->running())
return segment;
auto* segment = new LlmSegment(
LlmSegment::Type::Reasoning, QDateTime::currentMSecsSinceEpoch(), this);
segment->begin();
addSegment(segment);
return segment;
}
LlmSegment* ChatGeneration::beginToolCall(
const QString& name, const QString& toolCallId) { const QString& name, const QString& toolCallId) {
closeOpenSegments(); closeOpenSegments();
auto* segment = new LlmSegment( auto* call = new LlmToolCall(QDateTime::currentMSecsSinceEpoch(), this);
LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this); call->setName(name);
segment->setName(name); call->setToolCallId(toolCallId);
segment->setToolCallId(toolCallId); call->setStatus(LlmToolCall::Status::Pending);
segment->setStatus(LlmSegment::Status::Pending); call->begin();
segment->begin(); addSegment(call);
addSegment(segment); Q_EMIT toolCallCountChanged();
Q_EMIT toolStateChanged(); return call;
return segment;
} }
void ChatGeneration::addSegment(LlmSegment* segment) { void ChatGeneration::addSegment(LlmSegment* segment) {
if (!segment || m_segments.contains(segment)) return; if (!segment || m_segments.contains(segment)) return;
segment->setParent(this); segment->setParent(this);
connect(segment, &LlmSegment::textChanged, this, [this, segment]() { connect(segment, &LlmSegment::runningChanged, this, [this, segment]() {
if (segment->type() == LlmSegment::Type::Reasoning) Q_EMIT toolCallCountChanged();
Q_EMIT reasoningChanged(); if (segment->type() == LlmSegment::Type::ToolCall)
else if (segment->type() == LlmSegment::Type::Content) Q_EMIT hasRunningToolChanged();
Q_EMIT contentChanged();
updateReasoningActive();
});
connect(segment, &LlmSegment::statusChanged, this, [this]() {
Q_EMIT toolStateChanged();
});
connect(segment, &LlmSegment::resultChanged, this, [this]() {
Q_EMIT toolStateChanged();
});
connect(segment, &LlmSegment::runningChanged, this, [this]() {
Q_EMIT elapsedMsChanged();
Q_EMIT toolStateChanged();
updateReasoningActive(); updateReasoningActive();
}); });
if (auto* text = qobject_cast<LlmTextSegment*>(segment))
connect(text, &LlmTextSegment::textChanged, this, [this, text]() {
if (text->type() == LlmSegment::Type::Reasoning)
Q_EMIT reasoningChanged();
else
Q_EMIT contentChanged();
updateReasoningActive();
});
if (auto* call = qobject_cast<LlmToolCall*>(segment)) {
connect(call, &LlmToolCall::statusChanged, this, [this, call]() {
Q_EMIT hasRunningToolChanged();
Q_EMIT pendingToolCallsChanged();
if (call->status() == LlmToolCall::Status::Pending)
Q_EMIT toolApprovalPendingChanged();
updateReasoningActive();
});
}
m_segments.append(segment); m_segments.append(segment);
Q_EMIT segmentsChanged(); Q_EMIT segmentsChanged();
Q_EMIT toolCallCountChanged();
} }
void ChatGeneration::closeOpenSegments() { void ChatGeneration::closeOpenSegments() {

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