Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3178ad9cd | ||
|
|
82ca5b5335 | ||
|
|
02254a10d8 | ||
|
|
54d4676ddf | ||
|
|
bd699e72a7 | ||
|
|
cd3da1941b | ||
|
|
34dde4bbfb | ||
|
|
fcb848b6d2 | ||
|
|
ed7ec326b1 | ||
|
|
ae5c1fa148 | ||
|
|
b254000737 | ||
|
|
04fe8b1e43 | ||
|
|
dfa6b010e6 | ||
|
|
8a4e9bac31 | ||
|
|
f20fa45b29 | ||
|
|
53a3c76dc0 | ||
|
|
617e87d64f | ||
|
|
ef2fb95fab | ||
|
|
2150859847 | ||
|
|
c9f7856982
|
||
|
|
51dc0fc026 | ||
|
|
cef91caebc | ||
|
|
c5a089adaf | ||
|
|
52feb6006a | ||
|
|
dbb51ceadd | ||
|
|
487615f482 | ||
|
|
d4e936a7e2 | ||
|
|
04869656aa | ||
|
|
2f6847ce0e | ||
|
|
e5e52802de | ||
|
|
42f02f4dd2 | ||
|
|
9df3a64815 | ||
|
|
9370284c6b | ||
|
|
703d804746 | ||
|
|
327270f3fa | ||
|
|
8a66c3b064 | ||
|
|
293bf67e09 | ||
|
|
c33dbc147f | ||
|
|
5598f4f435 | ||
|
|
7e02c87b91 |
@@ -18,3 +18,5 @@ dist/
|
||||
network-dev/
|
||||
**/zshell.build/
|
||||
**/zshell.dist/
|
||||
.opencode/
|
||||
run-agent.sh
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ FunctionsSpacing=true
|
||||
IndentWidth=4
|
||||
MaxColumnWidth=-1
|
||||
NewlineType=native
|
||||
GroupAttributesTogether=true
|
||||
GroupAttributesTogether=false
|
||||
ObjectsSpacing=true
|
||||
SemicolonRule=always
|
||||
SingleLineEmptyObjects=true
|
||||
|
||||
+20
-20
@@ -26,22 +26,36 @@ if(NOT DEFINED VERSION)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED GIT_REVISION)
|
||||
execute_process(COMMAND git rev-parse HEAD
|
||||
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
|
||||
OUTPUT_VARIABLE GIT_REVISION
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
)
|
||||
|
||||
if("${GIT_REVISION}" STREQUAL "")
|
||||
message(FATAL_ERROR "GIT_REVISION is not set and failed to get from git")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
set(VERSION "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}")
|
||||
|
||||
project(ZShell VERSION ${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH})
|
||||
message(STATUS "ZShell version: ${VERSION}")
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
set(ENABLE_MODULES "plugin;shell;m3shapes" CACHE STRING "Modules to build/install")
|
||||
set(ENABLE_MODULES "plugin;shell;cli;m3shapes" CACHE STRING "Modules to build/install")
|
||||
|
||||
set(INSTALL_LIBDIR "usr/lib/ZShell" CACHE STRING "Library install dir")
|
||||
set(INSTALL_QMLDIR "usr/lib/qt6/qml" CACHE STRING "QML install dir")
|
||||
set(INSTALL_QSCONFDIR "etc/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir")
|
||||
set(INSTALL_GREETERCONFDIR "etc/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir")
|
||||
set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/ZShell" CACHE STRING "Library install dir")
|
||||
set(INSTALL_QMLDIR "${CMAKE_INSTALL_LIBDIR}/qt6/qml" CACHE STRING "QML install dir")
|
||||
set(INSTALL_QSCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir")
|
||||
set(INSTALL_GREETERCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir")
|
||||
|
||||
set(CMAKE_INSTALL_MESSAGE NEVER)
|
||||
|
||||
@@ -54,21 +68,7 @@ add_compile_options(
|
||||
)
|
||||
|
||||
|
||||
if("shell" IN_LIST ENABLE_MODULES)
|
||||
# Build settings index
|
||||
find_package(Python3 COMPONENTS Interpreter REQUIRED)
|
||||
set(SETTINGS_INDEX_JSON "${CMAKE_BINARY_DIR}/settings-index.json")
|
||||
execute_process(
|
||||
COMMAND ${Python3_EXECUTABLE}
|
||||
"${CMAKE_SOURCE_DIR}/scripts/build-settings-index.py"
|
||||
"${CMAKE_SOURCE_DIR}/Modules/Settings"
|
||||
"${SETTINGS_INDEX_JSON}"
|
||||
RESULT_VARIABLE SETTINGS_INDEX_RESULT
|
||||
)
|
||||
if(NOT SETTINGS_INDEX_RESULT EQUAL 0)
|
||||
message(FATAL_ERROR "Failed to build settings search index")
|
||||
endif()
|
||||
|
||||
if("cli" IN_LIST ENABLE_MODULES)
|
||||
# Nuitka compilation
|
||||
set(ZSHELL_CLI_BUILD_DIR "${CMAKE_BINARY_DIR}/zshell-cli")
|
||||
set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist")
|
||||
|
||||
@@ -30,7 +30,6 @@ Flickable {
|
||||
|
||||
onTriggered: {
|
||||
root.doneFakeFlick = false;
|
||||
root.returnToBounds();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ MouseArea {
|
||||
property int scrollAccumulatedY: 0
|
||||
|
||||
function onWheel(event: WheelEvent): void {
|
||||
event.accepted = false;
|
||||
}
|
||||
|
||||
onWheel: event => {
|
||||
|
||||
@@ -6,7 +6,6 @@ Rectangle {
|
||||
color: "transparent"
|
||||
|
||||
Behavior on color {
|
||||
CAnim {
|
||||
}
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ ScrollBar {
|
||||
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
|
||||
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
|
||||
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
|
||||
readonly property real axisContentPos: isHorizontal ? flickable.contentX : flickable.contentY
|
||||
readonly property real axisContentPos: isHorizontal ? (reversed ? flickable.contentX : (flickable.contentX - flickable.originX)) : (reversed ? flickable.contentY : (flickable.contentY - flickable.originY))
|
||||
readonly property real axisLength: isHorizontal ? root.width : root.height
|
||||
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
|
||||
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
|
||||
@@ -24,13 +24,8 @@ ScrollBar {
|
||||
readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0
|
||||
|
||||
enabled: !Visibilities.getForActive().isDrawing
|
||||
parent: flickable.parent
|
||||
anchors.left: isHorizontal ? flickable.left : undefined
|
||||
anchors.right: flickable.right
|
||||
anchors.top: isHorizontal ? undefined : flickable.top
|
||||
anchors.bottom: flickable.bottom
|
||||
implicitWidth: isHorizontal ? 0 : Tokens.padding.extraSmall * 2
|
||||
implicitHeight: isHorizontal ? Tokens.padding.extraSmall * 2 : 0
|
||||
implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2
|
||||
implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0
|
||||
|
||||
contentItem: Item {}
|
||||
Behavior on position {
|
||||
@@ -59,6 +54,7 @@ ScrollBar {
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: root.size < 1
|
||||
sourceComponent: root.isHorizontal ? horizontalTrack : verticalTrack
|
||||
}
|
||||
|
||||
@@ -106,9 +102,9 @@ ScrollBar {
|
||||
|
||||
var newPos = contentPosFromThumbStart(thumbStart);
|
||||
if (root.isHorizontal)
|
||||
root.flickable.contentX = newPos;
|
||||
root.flickable.contentX = newPos + (root.flickable.originX + (root.reversed ? root.flickable.contentWidth : 0));
|
||||
else
|
||||
root.flickable.contentY = newPos;
|
||||
root.flickable.contentY = newPos + (root.flickable.originY + (root.reversed ? root.flickable.contentHeight : 0));
|
||||
}
|
||||
|
||||
function visualThumbStart() {
|
||||
@@ -116,6 +112,17 @@ ScrollBar {
|
||||
return visualPos * root.travelScale;
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent): void {
|
||||
if (root.horizontal) {
|
||||
event.accepted = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var delta = event.angleDelta.y > 0 ? -0.1 : 0.1;
|
||||
var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta));
|
||||
root.position = newPos;
|
||||
}
|
||||
|
||||
anchors.fill: parent
|
||||
cursorShape: undefined
|
||||
hoverEnabled: true
|
||||
@@ -136,10 +143,5 @@ ScrollBar {
|
||||
|
||||
updateFromEvent(event);
|
||||
}
|
||||
onWheel: event => {
|
||||
var delta = (root.isHorizontal ? event.angleDelta.x : event.angleDelta.y) > 0 ? -0.1 : 0.1;
|
||||
var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta));
|
||||
root.position = newPos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ TextEdit {
|
||||
color: Colors.palette.m3onSurface
|
||||
cursorVisible: !readOnly
|
||||
font.pointSize: Tokens.font.size.small
|
||||
font.family: Config.appearance.font.family.sans
|
||||
renderType: TextField.NativeRendering
|
||||
selectedTextColor: color
|
||||
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ CustomWindow {
|
||||
property color surfaceColor: Colors.tPalette.m3surface
|
||||
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
|
||||
WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings || visibilities.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
|
||||
WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top
|
||||
color: "transparent"
|
||||
contentItem.focus: true
|
||||
|
||||
@@ -13,21 +13,17 @@ import qs.Paths
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property M3Palette current: M3Palette {
|
||||
}
|
||||
readonly property M3Palette current: M3Palette {}
|
||||
property bool currentLight
|
||||
property string flavour
|
||||
readonly property bool light: showPreview ? previewLight : currentLight
|
||||
readonly property M3Palette palette: showPreview ? preview : current
|
||||
readonly property M3Palette preview: M3Palette {
|
||||
}
|
||||
readonly property M3Palette preview: M3Palette {}
|
||||
property bool previewLight
|
||||
property string scheme
|
||||
property bool showPreview
|
||||
readonly property M3TPalette tPalette: M3TPalette {
|
||||
}
|
||||
readonly property Transparency transparency: Transparency {
|
||||
}
|
||||
readonly property M3TPalette tPalette: M3TPalette {}
|
||||
readonly property Transparency transparency: Transparency {}
|
||||
readonly property alias wallLuminance: analyser.luminance
|
||||
|
||||
function alterColor(c: color, a: real, layer: int): color {
|
||||
@@ -112,8 +108,7 @@ Singleton {
|
||||
|
||||
function setMode(mode: string): void {
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]);
|
||||
Config.general.color.mode = mode;
|
||||
Config.save();
|
||||
Config.colors.mode = mode;
|
||||
}
|
||||
|
||||
function swapRG(c: color): color {
|
||||
|
||||
@@ -9,13 +9,13 @@ Singleton {
|
||||
id: root
|
||||
|
||||
readonly property bool enabled: service.enabled
|
||||
readonly property int end: Config.general.color.scheduleHyprsunsetEnd
|
||||
readonly property int end: Config.display.nightlight.scheduleEnd
|
||||
property bool manualToggle: false
|
||||
readonly property int start: Config.general.color.scheduleHyprsunsetStart
|
||||
readonly property int temp: Config.general.color.hyprsunsetTemp
|
||||
readonly property int start: Config.display.nightlight.scheduleStart
|
||||
readonly property int temp: Config.display.nightlight.temp
|
||||
|
||||
function checkStartup(): void {
|
||||
if (!Config.general.color.scheduleHyprsunset)
|
||||
if (!Config.display.nightlight.schedule)
|
||||
return;
|
||||
|
||||
service.apply();
|
||||
@@ -26,12 +26,14 @@ Singleton {
|
||||
service.toggle();
|
||||
}
|
||||
|
||||
HyprsunsetManager {
|
||||
NightlightManager {
|
||||
id: service
|
||||
|
||||
activeAuto: Config.general.color.scheduleHyprsunset
|
||||
activeAuto: Config.display.nightlight.schedule
|
||||
endTime: root.end
|
||||
startTime: root.start
|
||||
temp: root.temp
|
||||
fadeDuration: Config.display.nightlight.fadeDuration / 1000
|
||||
useNativeNightlight: Config.display.nightlight.useNative
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,14 @@ import qs.Services
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property int darkEnd: Config.general.color.scheduleDarkEnd
|
||||
readonly property int darkStart: Config.general.color.scheduleDarkStart
|
||||
readonly property bool enabled: Config.general.color.scheduleDark && Config.general.color.schemeGeneration
|
||||
readonly property int darkEnd: Config.colors.scheduleDarkEnd
|
||||
readonly property int darkStart: Config.colors.scheduleDarkStart
|
||||
readonly property bool enabled: Config.colors.scheduleDark && Config.colors.schemeGen
|
||||
|
||||
function applyDarkMode() {
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "dark"]);
|
||||
|
||||
Config.general.color.mode = "dark";
|
||||
Config.colors.mode = "dark";
|
||||
|
||||
Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-dark'"]);
|
||||
|
||||
@@ -30,13 +30,13 @@ Singleton {
|
||||
function applyLightMode() {
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "light"]);
|
||||
|
||||
Config.general.color.mode = "light";
|
||||
Config.colors.mode = "light";
|
||||
|
||||
Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-light'"]);
|
||||
|
||||
Quickshell.execDetached(["sh", "-c", `sed -i 's/color_scheme_path=\\(.*\\)Dark.colors/color_scheme_path=\\1Light.colors/' ${Paths.home}/.config/qt6ct/qt6ct.conf`]);
|
||||
|
||||
if (Config.general.color.neovimColors)
|
||||
if (Config.colors.neovimColors)
|
||||
Quickshell.execDetached(["sed", "-i", "'s/\\(vim.cmd.colorscheme \\).*/\\1\"onelight\"/'", "~/.config/nvim/lua/config/load-colorscheme.lua"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ Searcher {
|
||||
previewPath = path;
|
||||
showPreview = true;
|
||||
|
||||
if (Config.general.color.schemeGeneration)
|
||||
if (Config.colors.schemeGen)
|
||||
previewColorsProc.running = true;
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ Searcher {
|
||||
WallpaperPath.currentWallpaperPath = path;
|
||||
Quickshell.screens.forEach(n => setCrop(n.name, Qt.rect(0, 0, 1, 1), 1.0));
|
||||
Quickshell.execDetached(["zshell-cli", "wallpaper", "lockscreen", "--input-image", `${root.actualCurrent}`, "--output-path", `${Paths.state}/lockscreen_bg.png`, "--blur-amount", `${Config.lock.blurAmount}`]);
|
||||
if (Config.general.color.schemeGeneration)
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.general.color.mode}`]);
|
||||
if (Config.colors.schemeGen)
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.colors.mode}`]);
|
||||
}
|
||||
|
||||
function stopPreview(): void {
|
||||
|
||||
@@ -40,7 +40,7 @@ Item {
|
||||
anchors.centerIn: parent
|
||||
|
||||
sourceComponent: BatteryIcon {
|
||||
devState: Battery.deviceStateString
|
||||
devState: Battery.deviceStateString.toLowerCase()
|
||||
percentage: Battery.currentPerc
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ CustomListView {
|
||||
return [0];
|
||||
case "variant":
|
||||
return SchemeVariants.query(text);
|
||||
case "files":
|
||||
return Files.query(text);
|
||||
default:
|
||||
return Apps.search(text);
|
||||
}
|
||||
@@ -33,7 +35,7 @@ CustomListView {
|
||||
function stateForText(text: string): string {
|
||||
const prefix = Config.launcher.actionPrefix;
|
||||
if (text.startsWith(prefix)) {
|
||||
for (const action of ["calc", "scheme", "variant"])
|
||||
for (const action of ["calc", "scheme", "variant", "files"])
|
||||
if (text.startsWith(`${prefix}${action} `))
|
||||
return action;
|
||||
|
||||
@@ -163,6 +165,13 @@ CustomListView {
|
||||
PropertyChanges {
|
||||
root.delegate: variantItem
|
||||
}
|
||||
},
|
||||
State {
|
||||
name: "files"
|
||||
|
||||
PropertyChanges {
|
||||
root.delegate: filesItem
|
||||
}
|
||||
}
|
||||
]
|
||||
transitions: Transition {
|
||||
@@ -264,6 +273,14 @@ CustomListView {
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: filesItem
|
||||
|
||||
FilesItem {
|
||||
list: root
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onTextChanged() {
|
||||
root.syncDisplayText();
|
||||
|
||||
@@ -85,6 +85,8 @@ Item {
|
||||
} else if (text.startsWith(Config.launcher.actionPrefix)) {
|
||||
if (text.startsWith(`${Config.launcher.actionPrefix}calc `))
|
||||
currentItem.onClicked();
|
||||
else if (text.startsWith(`${Config.launcher.actionPrefix}files `))
|
||||
currentItem.onClicked();
|
||||
else
|
||||
currentItem.modelData.onClicked(list.currentList);
|
||||
} else {
|
||||
|
||||
@@ -16,7 +16,7 @@ Item {
|
||||
implicitHeight: Config.launcher.sizes.itemHeight
|
||||
|
||||
StateLayer {
|
||||
radius: Tokens.rounding.small
|
||||
radius: Tokens.rounding.medium
|
||||
|
||||
onClicked: {
|
||||
root.modelData?.onClicked(root.list);
|
||||
|
||||
@@ -19,7 +19,7 @@ Item {
|
||||
implicitHeight: Config.launcher.sizes.itemHeight
|
||||
|
||||
StateLayer {
|
||||
radius: Tokens.rounding.small
|
||||
radius: Tokens.rounding.medium
|
||||
|
||||
onClicked: {
|
||||
Apps.launch(root.modelData);
|
||||
|
||||
@@ -24,7 +24,7 @@ Item {
|
||||
implicitHeight: Config.launcher.sizes.itemHeight
|
||||
|
||||
StateLayer {
|
||||
radius: Tokens.rounding.small
|
||||
radius: Tokens.rounding.medium
|
||||
|
||||
onClicked: {
|
||||
root.onClicked();
|
||||
@@ -97,8 +97,7 @@ Item {
|
||||
text: qsTr("Open in calculator")
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Widgets
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Helpers
|
||||
import qs.Modules.Launcher.Services
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var list
|
||||
required property var modelData
|
||||
|
||||
anchors.left: parent?.left
|
||||
anchors.right: parent?.right
|
||||
implicitHeight: Config.launcher.sizes.itemHeight
|
||||
|
||||
function onClicked(): void {
|
||||
root.list.visibilities.launcher = false;
|
||||
if (root.modelData?.path)
|
||||
Files.launch(["xdg-open", root.modelData.path]);
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
id: sLayer
|
||||
|
||||
radius: Tokens.rounding.medium
|
||||
manualHoverOverride: openLocation.hovered
|
||||
|
||||
onClicked: {
|
||||
root.onClicked();
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: openLocation
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Tokens.padding.medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
icon: "open_in_new"
|
||||
isRound: true
|
||||
|
||||
scale: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0.4
|
||||
opacity: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on scale {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
root.list.visibilities.launcher = false;
|
||||
if (root.modelData?.path)
|
||||
Files.showInFolder(root.modelData.path);
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Tokens.padding.small
|
||||
anchors.margins: Tokens.padding.small
|
||||
anchors.rightMargin: Tokens.padding.small
|
||||
|
||||
IconImage {
|
||||
id: icon
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitSize: Tokens.font.size.extraLarge
|
||||
source: Quickshell.iconPath(root.modelData?.icon) ?? ""
|
||||
visible: !root.modelData?.isImage
|
||||
}
|
||||
|
||||
Loader {
|
||||
active: opacity > 0
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
asynchronous: true
|
||||
opacity: root.modelData?.isImage && image.status === Image.Loading ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
sourceComponent: LoadingIndicator {
|
||||
color: Colors.palette.m3primaryContainer
|
||||
implicitSize: Tokens.font.size.extraLarge
|
||||
}
|
||||
}
|
||||
|
||||
FadeImage {
|
||||
id: image
|
||||
|
||||
property int implicitSize: Tokens.font.size.extraLarge
|
||||
|
||||
width: implicitSize
|
||||
fillMode: Image.PreserveAspectFit
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
source: root.modelData?.isImage ? root.modelData?.path ?? "" : ""
|
||||
visible: root.modelData?.isImage
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.left: icon.right
|
||||
anchors.leftMargin: Tokens.spacing.medium
|
||||
anchors.verticalCenter: icon.verticalCenter
|
||||
implicitHeight: name.implicitHeight + desc.implicitHeight
|
||||
implicitWidth: parent.width - icon.width
|
||||
|
||||
CustomText {
|
||||
id: name
|
||||
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
text: root.modelData?.name ?? ""
|
||||
elide: Text.ElideRight
|
||||
width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: desc
|
||||
|
||||
anchors.top: name.bottom
|
||||
color: Colors.palette.m3outline
|
||||
elide: Text.ElideRight
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: root.modelData?.path ?? ""
|
||||
width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ Item {
|
||||
implicitHeight: Config.launcher.sizes.itemHeight
|
||||
|
||||
StateLayer {
|
||||
radius: Tokens.rounding.small
|
||||
radius: Tokens.rounding.medium
|
||||
|
||||
onClicked: {
|
||||
root.modelData?.onClicked(root.list);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// qs/Services/Files.qml
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import ZShell.Config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property list<var> results: []
|
||||
|
||||
property string _pendingQuery: ""
|
||||
property int _generation: 0
|
||||
|
||||
readonly property var _imageExts: ({
|
||||
"png": true,
|
||||
"jpg": true,
|
||||
"jpeg": true,
|
||||
"gif": true,
|
||||
"bmp": true,
|
||||
"webp": true,
|
||||
"svg": true,
|
||||
"tiff": true,
|
||||
"tif": true,
|
||||
"ico": true,
|
||||
"heic": true,
|
||||
"avif": true
|
||||
})
|
||||
|
||||
// freedesktop mimetype-icon names; extend as needed
|
||||
readonly property var _extIcons: ({
|
||||
// images
|
||||
"png": "image-x-generic",
|
||||
"jpg": "image-x-generic",
|
||||
"jpeg": "image-x-generic",
|
||||
"gif": "image-x-generic",
|
||||
"bmp": "image-x-generic",
|
||||
"webp": "image-x-generic",
|
||||
"svg": "image-x-generic",
|
||||
"tiff": "image-x-generic",
|
||||
"tif": "image-x-generic",
|
||||
"ico": "image-x-generic",
|
||||
"heic": "image-x-generic",
|
||||
"avif": "image-x-generic",
|
||||
// documents
|
||||
"pdf": "application-pdf",
|
||||
"doc": "x-office-document",
|
||||
"docx": "x-office-document",
|
||||
"odt": "x-office-document",
|
||||
"txt": "text-x-generic",
|
||||
"md": "text-x-generic",
|
||||
"xls": "x-office-spreadsheet",
|
||||
"xlsx": "x-office-spreadsheet",
|
||||
"ods": "x-office-spreadsheet",
|
||||
"ppt": "x-office-presentation",
|
||||
"pptx": "x-office-presentation",
|
||||
"odp": "x-office-presentation",
|
||||
// archives
|
||||
"zip": "package-x-generic",
|
||||
"tar": "package-x-generic",
|
||||
"gz": "package-x-generic",
|
||||
"xz": "package-x-generic",
|
||||
"7z": "package-x-generic",
|
||||
"rar": "package-x-generic",
|
||||
// audio/video
|
||||
"mp3": "audio-x-generic",
|
||||
"flac": "audio-x-generic",
|
||||
"wav": "audio-x-generic",
|
||||
"ogg": "audio-x-generic",
|
||||
"mp4": "video-x-generic",
|
||||
"mkv": "video-x-generic",
|
||||
"webm": "video-x-generic",
|
||||
"avi": "video-x-generic",
|
||||
"mov": "video-x-generic",
|
||||
// code
|
||||
"js": "text-x-script",
|
||||
"ts": "text-x-script",
|
||||
"py": "text-x-script",
|
||||
"sh": "text-x-script",
|
||||
"qml": "text-x-script",
|
||||
"cpp": "text-x-c++src",
|
||||
"c": "text-x-csrc",
|
||||
"h": "text-x-chdr",
|
||||
"json": "application-json",
|
||||
"html": "text-html",
|
||||
"css": "text-css"
|
||||
})
|
||||
|
||||
function showInFolder(path: string): void {
|
||||
const explorer = Config.general.apps.explorer;
|
||||
let args = [];
|
||||
|
||||
if (explorer.includes("dolphin"))
|
||||
args = [...explorer, "--select", path];
|
||||
else
|
||||
args = [...explorer, path];
|
||||
|
||||
if (Config.launcher.uwsm)
|
||||
Quickshell.execDetached(["app2unit", "--", ...args]);
|
||||
else
|
||||
Quickshell.execDetached([...args]);
|
||||
}
|
||||
|
||||
function launch(command: list<string>): void {
|
||||
if (Config.launcher.uwsm)
|
||||
Quickshell.execDetached(["app2unit", "--"].concat(command));
|
||||
else
|
||||
Quickshell.execDetached(command);
|
||||
}
|
||||
|
||||
function transformSearch(search: string): string {
|
||||
return search.slice(Config.launcher.actionPrefix.length + "files ".length);
|
||||
}
|
||||
|
||||
function query(search: string): list<var> {
|
||||
search = transformSearch(search);
|
||||
if (search !== _pendingQuery) {
|
||||
_pendingQuery = search;
|
||||
debounceTimer.restart();
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function _extOf(name: string): string {
|
||||
const i = name.lastIndexOf(".");
|
||||
// no dot, or dotfile with no real extension (".bashrc" -> treat as no ext)
|
||||
if (i <= 0)
|
||||
return "";
|
||||
return name.slice(i + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function _iconFor(name: string, isDir: bool): string {
|
||||
if (isDir)
|
||||
return "folder";
|
||||
const ext = _extOf(name);
|
||||
return _extIcons[ext] ?? "text-x-generic";
|
||||
}
|
||||
|
||||
function _buildEntry(path: string): var {
|
||||
const name = path.split("/").pop();
|
||||
const ext = _extOf(name);
|
||||
// trailing slash from plocate's directory output (if -d/dir results ever appear)
|
||||
const isDir = path.endsWith("/");
|
||||
|
||||
return {
|
||||
path: path,
|
||||
name: name,
|
||||
icon: _iconFor(name, isDir),
|
||||
isImage: !isDir && (_imageExts[ext] ?? false)
|
||||
};
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: debounceTimer
|
||||
interval: 120
|
||||
onTriggered: root._runQuery(root._pendingQuery)
|
||||
}
|
||||
|
||||
function _runQuery(search: string): void {
|
||||
_generation++;
|
||||
|
||||
if (!search) {
|
||||
proc.running = false;
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (proc.running)
|
||||
proc.running = false;
|
||||
|
||||
proc.buffer = [];
|
||||
proc.myGeneration = _generation;
|
||||
proc.command = ["plocate", "-i", "-b", "--limit", "50", search];
|
||||
proc.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: proc
|
||||
|
||||
property var buffer: []
|
||||
property int myGeneration: 0
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: line => proc.buffer.push(root._buildEntry(line))
|
||||
}
|
||||
|
||||
onRunningChanged: {
|
||||
if (!running && myGeneration === root._generation)
|
||||
root.results = buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ ColumnLayout {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
color: Colors.palette.m3secondary
|
||||
font.bold: true
|
||||
font.family: Appearance.font.family.clock
|
||||
font.family: Config.appearance.font.family.clock
|
||||
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
|
||||
text: Time.hourStr
|
||||
}
|
||||
@@ -38,7 +38,7 @@ ColumnLayout {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
color: Colors.palette.m3primary
|
||||
font.bold: true
|
||||
font.family: Appearance.font.family.clock
|
||||
font.family: Config.appearance.font.family.clock
|
||||
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
|
||||
text: ":"
|
||||
}
|
||||
@@ -47,7 +47,7 @@ ColumnLayout {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
color: Colors.palette.m3secondary
|
||||
font.bold: true
|
||||
font.family: Appearance.font.family.clock
|
||||
font.family: Config.appearance.font.family.clock
|
||||
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
|
||||
text: Time.minuteStr
|
||||
}
|
||||
@@ -58,7 +58,7 @@ ColumnLayout {
|
||||
Layout.topMargin: -Tokens.padding.large * 2
|
||||
color: Colors.palette.m3tertiary
|
||||
font.bold: true
|
||||
font.family: Appearance.font.family.mono
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale)
|
||||
text: Time.format("dddd, d MMMM yyyy")
|
||||
}
|
||||
@@ -226,7 +226,7 @@ ColumnLayout {
|
||||
anchors.right: parent.right
|
||||
animateProp: "opacity"
|
||||
color: Colors.palette.m3onSurfaceVariant
|
||||
font.family: Appearance.font.family.mono
|
||||
font.family: Config.appearance.font.family.mono
|
||||
horizontalAlignment: Qt.AlignHCenter
|
||||
lineHeight: 1.2
|
||||
opacity: shouldBeVisible && !message.msg ? 1 : 0
|
||||
@@ -295,7 +295,7 @@ ColumnLayout {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
color: Colors.palette.m3error
|
||||
font.family: Appearance.font.family.mono
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
horizontalAlignment: Qt.AlignHCenter
|
||||
opacity: 0
|
||||
|
||||
@@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import ZShell.Internal
|
||||
import ZShell.Config
|
||||
import qs.Helpers
|
||||
@@ -35,7 +36,7 @@ Scope {
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: Config.general.idle.timeouts
|
||||
model: Config.general.idle.timeouts.values
|
||||
|
||||
IdleMonitor {
|
||||
required property var modelData
|
||||
|
||||
@@ -41,7 +41,7 @@ Item {
|
||||
anchors.centerIn: parent
|
||||
animate: true
|
||||
color: root.pam.passwd.active ? Colors.palette.m3secondary : Colors.palette.m3outline
|
||||
font.family: Appearance.font.family.mono
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
opacity: root.buffer ? 0 : 1
|
||||
text: {
|
||||
|
||||
@@ -24,7 +24,7 @@ ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
color: Colors.palette.m3outline
|
||||
elide: Text.ElideRight
|
||||
font.family: Appearance.font.family.mono
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.weight: 500
|
||||
text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications")
|
||||
}
|
||||
@@ -66,7 +66,7 @@ ColumnLayout {
|
||||
CustomText {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
color: Colors.palette.m3outlineVariant
|
||||
font.family: Appearance.font.family.mono
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.large
|
||||
font.weight: 500
|
||||
text: qsTr("No Notifications")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Components
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Modules.Notifications.Sidebar.Chat.Content
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
@@ -11,13 +12,81 @@ Item {
|
||||
|
||||
property ChatSession chatData
|
||||
property bool following: true
|
||||
// qmlformat off
|
||||
readonly property var keys: [
|
||||
Qt.Key_A,
|
||||
Qt.Key_B,
|
||||
Qt.Key_C,
|
||||
Qt.Key_D,
|
||||
Qt.Key_E,
|
||||
Qt.Key_F,
|
||||
Qt.Key_G,
|
||||
Qt.Key_H,
|
||||
Qt.Key_I,
|
||||
Qt.Key_J,
|
||||
Qt.Key_K,
|
||||
Qt.Key_L,
|
||||
Qt.Key_M,
|
||||
Qt.Key_N,
|
||||
Qt.Key_O,
|
||||
Qt.Key_P,
|
||||
Qt.Key_Q,
|
||||
Qt.Key_R,
|
||||
Qt.Key_S,
|
||||
Qt.Key_T,
|
||||
Qt.Key_U,
|
||||
Qt.Key_V,
|
||||
Qt.Key_W,
|
||||
Qt.Key_X,
|
||||
Qt.Key_Y,
|
||||
Qt.Key_Z,
|
||||
|
||||
Qt.Key_Agrave,
|
||||
Qt.Key_Aacute,
|
||||
Qt.Key_Acircumflex,
|
||||
Qt.Key_Atilde,
|
||||
Qt.Key_Adiaeresis,
|
||||
Qt.Key_Aring,
|
||||
Qt.Key_AE,
|
||||
|
||||
Qt.Key_Ccedilla,
|
||||
|
||||
Qt.Key_Egrave,
|
||||
Qt.Key_Eacute,
|
||||
Qt.Key_Ecircumflex,
|
||||
Qt.Key_Ediaeresis,
|
||||
|
||||
Qt.Key_Igrave,
|
||||
Qt.Key_Iacute,
|
||||
Qt.Key_Icircumflex,
|
||||
Qt.Key_Idiaeresis,
|
||||
|
||||
Qt.Key_ETH,
|
||||
|
||||
Qt.Key_Ntilde,
|
||||
|
||||
Qt.Key_Ograve,
|
||||
Qt.Key_Oacute,
|
||||
Qt.Key_Ocircumflex,
|
||||
Qt.Key_Otilde,
|
||||
Qt.Key_Odiaeresis,
|
||||
Qt.Key_Ooblique,
|
||||
|
||||
Qt.Key_Ugrave,
|
||||
Qt.Key_Uacute,
|
||||
Qt.Key_Ucircumflex,
|
||||
Qt.Key_Udiaeresis,
|
||||
|
||||
Qt.Key_Yacute,
|
||||
Qt.Key_ydiaeresis,
|
||||
|
||||
Qt.Key_THORN,
|
||||
Qt.Key_ssharp
|
||||
]
|
||||
// qmlformat on
|
||||
|
||||
signal requestClose
|
||||
|
||||
function scrollToBottom(): void {
|
||||
Qt.callLater(list.positionViewAtBeginning);
|
||||
}
|
||||
|
||||
function send(text: string): void {
|
||||
if (text.trim() === "")
|
||||
return;
|
||||
@@ -26,7 +95,20 @@ Item {
|
||||
input.text = "";
|
||||
}
|
||||
|
||||
Component.onCompleted: input.focus = true
|
||||
function focusInput(): void {
|
||||
Qt.callLater(() => input.forceActiveFocus());
|
||||
}
|
||||
|
||||
onChatDataChanged: {
|
||||
if (chatData)
|
||||
focusInput();
|
||||
}
|
||||
Keys.onPressed: e => {
|
||||
if (root.keys.includes(e.key)) {
|
||||
input.insert(input.length, e.text);
|
||||
focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: header
|
||||
@@ -43,21 +125,23 @@ Item {
|
||||
isRound: true
|
||||
type: IconButton.Tonal
|
||||
|
||||
enabled: !ChatState.isWindow
|
||||
visible: enabled
|
||||
|
||||
onClicked: root.requestClose()
|
||||
}
|
||||
|
||||
CustomText {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillWidth: ChatState.isWindow ? false : true
|
||||
Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
font.pointSize: Tokens.font.size.larger
|
||||
text: qsTr(root.chatData.title)
|
||||
}
|
||||
}
|
||||
|
||||
VerticalFadeListView {
|
||||
id: list
|
||||
|
||||
property bool userScrolledUp: false
|
||||
Loader {
|
||||
id: listLoader
|
||||
|
||||
anchors.bottom: input.top
|
||||
anchors.bottomMargin: Tokens.spacing.medium
|
||||
@@ -65,22 +149,58 @@ Item {
|
||||
anchors.right: parent.right
|
||||
anchors.top: header.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
cacheBuffer: height * 20
|
||||
active: root.chatData && root.chatData.loaded
|
||||
|
||||
sourceComponent: VerticalFadeListView {
|
||||
id: list
|
||||
|
||||
property bool userScrolledUp: false
|
||||
property real lastCHeight: 0.0
|
||||
|
||||
function scrollToBottom(): void {
|
||||
scrollAnim.start();
|
||||
}
|
||||
|
||||
cacheBuffer: Math.max(height * 20, 0)
|
||||
clip: true
|
||||
fadeAmount: 0.05
|
||||
fadeThreshold: Tokens.padding.medium
|
||||
model: root.chatData.messagesModel
|
||||
spacing: 0
|
||||
verticalLayoutDirection: VerticalFadeListView.BottomToTop
|
||||
|
||||
rotation: 180
|
||||
add: Transition {
|
||||
Anim {
|
||||
from: list.width
|
||||
property: "x"
|
||||
to: 0
|
||||
from: -100
|
||||
property: "y"
|
||||
}
|
||||
}
|
||||
delegate: MessageDelegate {}
|
||||
|
||||
CustomScrollBar.vertical: CustomScrollBar {
|
||||
id: scrollBar
|
||||
|
||||
flickable: list
|
||||
parent: list.parent
|
||||
anchors.top: list.top
|
||||
anchors.right: list.right
|
||||
anchors.bottom: list.bottom
|
||||
|
||||
transform: Rotation {
|
||||
origin.y: list.height / 2
|
||||
origin.x: scrollBar.width / 2
|
||||
angle: 180
|
||||
|
||||
axis {
|
||||
y: 0
|
||||
x: 1
|
||||
z: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
delegate: MessageDelegate {
|
||||
rotation: 180
|
||||
rootParent: list
|
||||
}
|
||||
|
||||
displaced: Transition {
|
||||
Anim {
|
||||
property: "y"
|
||||
@@ -92,22 +212,24 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: positionViewAtBeginning()
|
||||
onAtYEndChanged: {
|
||||
if (atYEnd)
|
||||
Component.onCompleted: {
|
||||
positionViewAtBeginning();
|
||||
}
|
||||
onAtYBeginningChanged: {
|
||||
if (atYBeginning)
|
||||
userScrolledUp = false;
|
||||
}
|
||||
onContentHeightChanged: {
|
||||
if (!userScrolledUp && atYEnd)
|
||||
root.scrollToBottom();
|
||||
if (userScrolledUp && Chat.busy) {
|
||||
const delta = contentHeight - lastCHeight;
|
||||
contentY += delta;
|
||||
}
|
||||
onCountChanged: {
|
||||
if (!userScrolledUp)
|
||||
root.scrollToBottom();
|
||||
|
||||
lastCHeight = contentHeight;
|
||||
}
|
||||
onMovingChanged: {
|
||||
if (moving)
|
||||
userScrolledUp = !atYEnd;
|
||||
userScrolledUp = !atYBeginning;
|
||||
}
|
||||
|
||||
Anim {
|
||||
@@ -116,7 +238,17 @@ Item {
|
||||
property: "contentY"
|
||||
target: list
|
||||
type: Anim.DefaultEffects
|
||||
to: list.originY
|
||||
}
|
||||
|
||||
WheelInverter {
|
||||
target: list
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger the load even before we're ready to show the list.
|
||||
Component.onCompleted: if (root.chatData)
|
||||
root.chatData.messagesModel
|
||||
}
|
||||
|
||||
EmptyBackground {
|
||||
@@ -124,7 +256,7 @@ Item {
|
||||
|
||||
anchors.fill: parent
|
||||
spacing: Tokens.spacing.small
|
||||
visible: !root.chatData
|
||||
visible: root.chatData.messageCount < 1
|
||||
}
|
||||
|
||||
Item {
|
||||
@@ -133,7 +265,7 @@ Item {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
implicitHeight: scrollToBottom.implicitHeight
|
||||
implicitWidth: scrollToBottom.implicitWidth
|
||||
scale: list.visibleArea.yPosition + list.visibleArea.heightRatio < 1 && list.contentHeight > list.height ? 1 : 0
|
||||
scale: !listLoader.item.atYBeginning && listLoader.item.contentHeight > listLoader.item.height ? 1 : 0
|
||||
visible: scale > 0
|
||||
|
||||
Behavior on scale {
|
||||
@@ -157,7 +289,7 @@ Item {
|
||||
type: IconButton.Tonal
|
||||
|
||||
onClicked: {
|
||||
list.positionViewAtBeginning();
|
||||
listLoader.item.scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,11 +304,13 @@ Item {
|
||||
bg.color: Colors.tPalette.m3surfaceContainerLowest
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
|
||||
focus: true
|
||||
placeholderText: qsTr("Send a message")
|
||||
sendIcon.font.pointSize: Tokens.font.size.large
|
||||
sendIcon.icon: "arrow_upward"
|
||||
sendIcon.padding: Tokens.padding.extraSmall
|
||||
|
||||
Component.onCompleted: root.focusInput()
|
||||
Keys.onPressed: e => {
|
||||
if (e.key == Qt.Key_Return) {
|
||||
if (!(e.modifiers & Qt.ShiftModifier)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import QtQuick
|
||||
import ZShell.Components
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Effects
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
@@ -9,15 +10,20 @@ CustomClippingRect {
|
||||
id: root
|
||||
|
||||
property bool expanded: false
|
||||
property bool highlighted: false
|
||||
property bool slim: false
|
||||
required property ChatSession modelData
|
||||
|
||||
signal clicked(content: ChatSession)
|
||||
signal remove(content: ChatSession)
|
||||
|
||||
color: Colors.tPalette.m3surfaceContainer
|
||||
color: highlighted ? Qt.alpha(Colors.palette.m3primaryContainer, 0.3) : Qt.alpha(Colors.tPalette.m3surfaceContainer, 0.2)
|
||||
implicitHeight: {
|
||||
let h = 0;
|
||||
|
||||
if (slim)
|
||||
return chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2;
|
||||
|
||||
h += infoContainer.implicitHeight;
|
||||
|
||||
if (expanded)
|
||||
@@ -33,6 +39,20 @@ CustomClippingRect {
|
||||
return h;
|
||||
}
|
||||
radius: Tokens.rounding.large
|
||||
clip: true
|
||||
|
||||
CustomRect {
|
||||
id: borderHighlight
|
||||
|
||||
anchors.fill: parent
|
||||
border.width: root.highlighted ? 2 : 0
|
||||
border.color: Colors.palette.m3secondary
|
||||
radius: root.radius - border.width + 1
|
||||
|
||||
Behavior on border.width {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on implicitHeight {
|
||||
enabled: titleText.readOnly
|
||||
@@ -49,6 +69,7 @@ CustomClippingRect {
|
||||
anchors.topMargin: Tokens.padding.large
|
||||
font.pointSize: Tokens.font.size.extraLarge
|
||||
text: root.modelData.icon || "check"
|
||||
color: Colors.palette.m3onSurface
|
||||
}
|
||||
|
||||
Item {
|
||||
@@ -68,6 +89,14 @@ CustomClippingRect {
|
||||
return h;
|
||||
}
|
||||
|
||||
opacity: root.slim ? 0 : 1
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: title
|
||||
|
||||
@@ -85,7 +114,20 @@ CustomClippingRect {
|
||||
}
|
||||
}
|
||||
|
||||
TextEditBase {
|
||||
Item {
|
||||
id: titleWrapper
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
implicitHeight: titleText.implicitHeight + Tokens.padding.small * 2
|
||||
|
||||
layer.enabled: true
|
||||
layer.effect: OpacityMask {
|
||||
maskSource: fadeMask
|
||||
}
|
||||
|
||||
TextFieldBase {
|
||||
id: titleText
|
||||
|
||||
anchors.left: parent.left
|
||||
@@ -93,8 +135,9 @@ CustomClippingRect {
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
text: root.modelData.title
|
||||
text: root.modelData.title || qsTr("New chat")
|
||||
readOnly: true
|
||||
color: Colors.palette.m3onSurface
|
||||
|
||||
Behavior on anchors.leftMargin {
|
||||
Anim {}
|
||||
@@ -113,6 +156,34 @@ CustomClippingRect {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: fadeMask
|
||||
|
||||
// anchors.top: titleText.top
|
||||
// anchors.bottom: titleText.bottom
|
||||
// anchors.left: titleText.left
|
||||
// anchors.right: parent.right
|
||||
anchors.fill: titleWrapper
|
||||
|
||||
layer.enabled: true
|
||||
visible: false
|
||||
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
|
||||
GradientStop {
|
||||
color: Qt.rgba(1, 1, 1, 1.0)
|
||||
position: 0.85
|
||||
}
|
||||
|
||||
GradientStop {
|
||||
color: Qt.rgba(1, 1, 1, 0)
|
||||
position: 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: created
|
||||
@@ -151,7 +222,7 @@ CustomClippingRect {
|
||||
id: timestamp
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.top: titleText.bottom
|
||||
anchors.top: title.bottom
|
||||
anchors.topMargin: Tokens.spacing.extraSmall
|
||||
color: Colors.palette.m3onSurfaceVariant
|
||||
font.pointSize: Tokens.font.size.small
|
||||
@@ -187,8 +258,8 @@ CustomClippingRect {
|
||||
color: Colors.palette.m3onSurfaceVariant
|
||||
font.pointSize: Tokens.font.size.small
|
||||
maximumLineCount: 1
|
||||
opacity: !root.expanded ? 1 : 0
|
||||
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? ""
|
||||
opacity: !root.expanded && !root.slim ? 1 : 0
|
||||
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet")
|
||||
|
||||
Behavior on y {
|
||||
Anim {}
|
||||
@@ -213,7 +284,7 @@ CustomClippingRect {
|
||||
color: Colors.palette.m3onSurfaceVariant
|
||||
maximumLineCount: 5
|
||||
opacity: root.expanded ? 1 : 0
|
||||
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? ""
|
||||
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content ?? qsTr("No messages yet")
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
|
||||
Behavior on y {
|
||||
@@ -286,7 +357,17 @@ CustomClippingRect {
|
||||
implicitHeight: expandIcon.implicitHeight
|
||||
implicitWidth: expandIcon.implicitHeight
|
||||
radius: Tokens.rounding.full
|
||||
color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 3)
|
||||
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
|
||||
|
||||
opacity: root.slim ? 0 : 1
|
||||
visible: opacity > 0
|
||||
enabled: visible
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
onClicked: root.expanded = !root.expanded
|
||||
@@ -298,6 +379,7 @@ CustomClippingRect {
|
||||
anchors.centerIn: parent
|
||||
anchors.verticalCenterOffset: root.expanded ? -1 : 1
|
||||
rotation: root.expanded ? 180 : 0
|
||||
color: Colors.palette.m3onSurface
|
||||
text: "expand_more"
|
||||
|
||||
Behavior on anchors.verticalCenterOffset {
|
||||
|
||||
@@ -31,7 +31,7 @@ TextAreaBase {
|
||||
enabled: !root.activeFocus
|
||||
manualPressOverride: tapHandler.pressed
|
||||
|
||||
onClicked: root.focus = true
|
||||
onClicked: root.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,12 @@ Item {
|
||||
|
||||
property alias list: list
|
||||
property alias model: list.model
|
||||
property bool highlight: false
|
||||
property bool slim: false
|
||||
|
||||
signal requestExpand
|
||||
signal deleteChatRequest(content: ChatSession)
|
||||
signal loadChatRequest(content: ChatSession)
|
||||
signal loadChatRequest(content: ChatSession, index: int)
|
||||
signal newChatRequest
|
||||
|
||||
CustomText {
|
||||
@@ -35,45 +38,404 @@ Item {
|
||||
CustomListView {
|
||||
id: list
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottom: modelsContainer.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: header.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
cacheBuffer: height * 2
|
||||
clip: true
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
add: Transition {
|
||||
Anim {
|
||||
from: list.width
|
||||
property: "x"
|
||||
}
|
||||
}
|
||||
remove: Transition {
|
||||
Anim {
|
||||
to: list.width
|
||||
property: "x"
|
||||
}
|
||||
|
||||
Anim {
|
||||
to: 0
|
||||
property: "opacity"
|
||||
}
|
||||
}
|
||||
displaced: Transition {
|
||||
Anim {
|
||||
property: "y"
|
||||
}
|
||||
}
|
||||
move: Transition {
|
||||
Anim {
|
||||
property: "y"
|
||||
}
|
||||
}
|
||||
|
||||
delegate: ChatDelegate {
|
||||
id: chat
|
||||
|
||||
implicitWidth: ListView.view.width
|
||||
required property int index
|
||||
|
||||
onClicked: content => root.loadChatRequest(content)
|
||||
implicitWidth: ListView.view.width
|
||||
highlighted: root.highlight && ChatState.chatSession === modelData
|
||||
slim: root.slim
|
||||
|
||||
onHighlightedChanged: if (highlighted)
|
||||
list.currentIndex = index
|
||||
onClicked: content => root.loadChatRequest(content, index)
|
||||
onRemove: content => root.deleteChatRequest(content)
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: {
|
||||
root.focus = true;
|
||||
ChatState.fabExpanded = false;
|
||||
modelsContainer.expanded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
id: modelsContainer
|
||||
|
||||
property bool expanded: false
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: fabRoot.left
|
||||
anchors.margins: Tokens.padding.small
|
||||
anchors.rightMargin: Tokens.spacing.small
|
||||
|
||||
implicitHeight: expanded ? 400 : fabRoot.implicitHeight
|
||||
radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2
|
||||
|
||||
function prettyModelName(): string {
|
||||
if (modelsList.count < 1)
|
||||
return qsTr("No models found");
|
||||
|
||||
const pathList = Chat.model.split("\/");
|
||||
const name = pathList[pathList.length - 1];
|
||||
|
||||
if (!name)
|
||||
return qsTr("No model selected");
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
Behavior on radius {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: modelName
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
implicitHeight: fabRoot.implicitHeight
|
||||
|
||||
CustomText {
|
||||
id: label
|
||||
text: modelsContainer.prettyModelName()
|
||||
color: Colors.palette.m3onSurfaceVariant
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Tokens.padding.large
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
color: Colors.palette.m3onSurfaceVariant
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Tokens.padding.large
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelsContainer.expanded ? "unfold_less" : "unfold_more"
|
||||
animate: true
|
||||
font.pointSize: Tokens.font.size.large
|
||||
visible: modelsList.count > 0
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
id: modelLayer
|
||||
enabled: modelsList.count > 0
|
||||
|
||||
onClicked: {
|
||||
ChatState.fabExpanded = false;
|
||||
modelsContainer.expanded = !modelsContainer.expanded;
|
||||
}
|
||||
radius: modelsContainer.radius
|
||||
}
|
||||
}
|
||||
|
||||
VerticalFadeListView {
|
||||
id: modelsList
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.top: modelName.bottom
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
model: Chat.availableModels
|
||||
opacity: modelsContainer.expanded ? 1 : 0
|
||||
currentIndex: Chat.availableModels.indexOf(Chat.model)
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
CustomScrollBar.vertical: CustomScrollBar {
|
||||
flickable: modelsList
|
||||
}
|
||||
|
||||
delegate: CustomRect {
|
||||
id: model
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
readonly property bool selected: model.ListView.view.currentIndex === model.index
|
||||
|
||||
implicitWidth: ListView.view.width
|
||||
implicitHeight: layout.implicitHeight + Tokens.padding.small * 2
|
||||
radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium
|
||||
color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0)
|
||||
|
||||
Behavior on radius {
|
||||
Anim {
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: layout
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.small
|
||||
anchors.leftMargin: Tokens.padding.large
|
||||
anchors.rightMargin: Tokens.padding.large
|
||||
|
||||
CustomText {
|
||||
text: model.modelData
|
||||
color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
Layout.alignment: Qt.AlignRight
|
||||
visible: model.selected
|
||||
color: Colors.palette.m3onTertiaryContainer
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
opacity: model.selected ? 1 : 0
|
||||
text: "check"
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
id: stateLayer
|
||||
|
||||
onClicked: {
|
||||
Chat.selectModel(model.modelData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: newChatBtn
|
||||
id: fabRoot
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: Tokens.padding.small
|
||||
anchors.right: parent.right
|
||||
font.pointSize: Math.round(18 * 1.2)
|
||||
icon: "add"
|
||||
anchors.margins: Tokens.padding.small
|
||||
padding: 8
|
||||
radius: Tokens.rounding.full
|
||||
font.pointSize: Math.round(18 * 1.2)
|
||||
icon: root.slim ? "left_panel_open" : "add"
|
||||
isRound: ChatState.fabExpanded
|
||||
// opacity: root.slim ? 0 : 1
|
||||
// visible: opacity > 0
|
||||
//
|
||||
// Behavior on opacity {
|
||||
// Anim {
|
||||
// type: Anim.DefaultEffects
|
||||
// }
|
||||
// }
|
||||
|
||||
label.transform: Rotation {
|
||||
origin.y: fabRoot.label.height / 2
|
||||
origin.x: fabRoot.label.width / 2
|
||||
angle: ChatState.fabExpanded ? 135 : 0
|
||||
|
||||
Behavior on angle {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
root.newChatRequest();
|
||||
if (root.slim && !ChatState.narrowSidebarExpanded) {
|
||||
root.requestExpand();
|
||||
return;
|
||||
}
|
||||
|
||||
modelsContainer.expanded = false;
|
||||
ChatState.fabExpanded = !ChatState.fabExpanded;
|
||||
}
|
||||
|
||||
Elevation {
|
||||
anchors.fill: parent
|
||||
level: newChatBtn.stateLayer.containsMouse ? 4 : 3
|
||||
level: fabRoot.stateLayer.containsMouse ? 4 : 3
|
||||
radius: parent.radius
|
||||
z: -1
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: fabMenu
|
||||
|
||||
anchors.bottom: fabRoot.top
|
||||
anchors.right: fabRoot.right
|
||||
anchors.bottomMargin: Tokens.padding.medium
|
||||
|
||||
Repeater {
|
||||
id: fabRep
|
||||
|
||||
model: ListModel {
|
||||
ListElement {
|
||||
name: "new chat"
|
||||
icon: "add"
|
||||
}
|
||||
ListElement {
|
||||
name: "pop out in window"
|
||||
icon: "open_in_new"
|
||||
}
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
id: fabMenuItem
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
Layout.alignment: Qt.AlignRight
|
||||
implicitHeight: fabMenuItemInner.implicitHeight + Tokens.padding.medium * 2
|
||||
|
||||
radius: sLayer.pressed ? Tokens.rounding.small : implicitHeight / 2
|
||||
color: Colors.palette.m3primaryContainer
|
||||
visible: !(modelData.name === "pop out in window" && ChatState.isWindow)
|
||||
|
||||
Behavior on radius {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
opacity: 0
|
||||
|
||||
states: State {
|
||||
name: "visible"
|
||||
when: ChatState.fabExpanded
|
||||
|
||||
PropertyChanges {
|
||||
fabMenuItem.implicitWidth: fabMenuItemInner.implicitWidth + Tokens.padding.large * 2
|
||||
fabMenuItem.opacity: 1
|
||||
fabMenuItemInner.opacity: 1
|
||||
}
|
||||
}
|
||||
|
||||
transitions: [
|
||||
Transition {
|
||||
to: "visible"
|
||||
|
||||
SequentialAnimation {
|
||||
PauseAnimation {
|
||||
duration: (fabRep.count - 1 - fabMenuItem.index) * 50
|
||||
}
|
||||
ParallelAnimation {
|
||||
Anim {
|
||||
property: "implicitWidth"
|
||||
type: Anim.FastSpatial
|
||||
}
|
||||
Anim {
|
||||
property: "opacity"
|
||||
duration: Tokens.anim.durations.small
|
||||
easing: Tokens.anim.standard
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Transition {
|
||||
from: "visible"
|
||||
|
||||
SequentialAnimation {
|
||||
PauseAnimation {
|
||||
duration: fabMenuItem.index * Tokens.anim.durations.small / 8
|
||||
}
|
||||
ParallelAnimation {
|
||||
Anim {
|
||||
property: "implicitWidth"
|
||||
type: Anim.FastSpatial
|
||||
}
|
||||
Anim {
|
||||
property: "opacity"
|
||||
duration: Tokens.anim.durations.small
|
||||
easing: Tokens.anim.standard
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
RowLayout {
|
||||
id: fabMenuItemInner
|
||||
|
||||
anchors.centerIn: parent
|
||||
spacing: Tokens.spacing.medium
|
||||
opacity: 0
|
||||
|
||||
MaterialIcon {
|
||||
text: fabMenuItem.modelData.icon
|
||||
color: Colors.palette.m3onPrimaryContainer
|
||||
font.pointSize: Tokens.font.size.large
|
||||
fill: 1
|
||||
}
|
||||
|
||||
CustomText {
|
||||
animate: true
|
||||
text: fabMenuItem.modelData.name
|
||||
font.capitalization: Font.Capitalize
|
||||
color: Colors.palette.m3onPrimaryContainer
|
||||
Layout.preferredWidth: implicitWidth
|
||||
|
||||
Behavior on Layout.preferredWidth {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
id: sLayer
|
||||
onClicked: {
|
||||
const name = fabMenuItem.modelData.name;
|
||||
|
||||
if (name === "new chat")
|
||||
root.newChatRequest();
|
||||
else if (name === "pop out in window")
|
||||
Detach.create();
|
||||
|
||||
ChatState.fabExpanded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,45 +18,19 @@ Item {
|
||||
|
||||
Component.onCompleted: {
|
||||
if (ChatState.inChat) {
|
||||
stackLoader.item?.push(chatList);
|
||||
stackLoader.item?.push(chatContent, {
|
||||
stack.push(chatList);
|
||||
stack.push(chatContent, {
|
||||
"chatData": ChatState.chatSession
|
||||
});
|
||||
} else
|
||||
stackLoader.item?.push(chatList);
|
||||
stack.push(chatList);
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: stackLoader
|
||||
|
||||
active: !ChatState.isWindow || root.width <= (ChatState.screen.width / 4)
|
||||
anchors.fill: parent
|
||||
|
||||
sourceComponent: StackView {
|
||||
StackView {
|
||||
id: stack
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: sidebarViewLoader
|
||||
|
||||
active: ChatState.isWindow && root.width > (ChatState.screen.width / 4)
|
||||
anchors.fill: parent
|
||||
|
||||
sourceComponent: SidebarView {}
|
||||
}
|
||||
|
||||
// IconButton {
|
||||
// anchors.right: parent.right
|
||||
// icon: "close"
|
||||
// visible: !ChatState.isWindow
|
||||
//
|
||||
// onClicked: {
|
||||
// Detach.create();
|
||||
// Visibilities.getForActive().sidebar = false;
|
||||
// }
|
||||
// }
|
||||
|
||||
Component {
|
||||
id: chatList
|
||||
|
||||
@@ -68,8 +42,8 @@ Item {
|
||||
onDeleteChatRequest: chat => {
|
||||
Chat.chats.remove(chat);
|
||||
}
|
||||
onLoadChatRequest: chat => {
|
||||
stackLoader.item?.push(chatContent, {
|
||||
onLoadChatRequest: (chat, index) => {
|
||||
stack.push(chatContent, {
|
||||
"chatData": chat
|
||||
});
|
||||
ChatState.inChat = true;
|
||||
@@ -77,7 +51,7 @@ Item {
|
||||
}
|
||||
onNewChatRequest: {
|
||||
const data = Chat.chats.insert();
|
||||
stackLoader.item?.push(chatContent, {
|
||||
stack.push(chatContent, {
|
||||
"chatData": data
|
||||
});
|
||||
ChatState.inChat = true;
|
||||
@@ -91,7 +65,7 @@ Item {
|
||||
|
||||
ChatContent {
|
||||
onRequestClose: {
|
||||
stackLoader.item?.pop();
|
||||
stack.pop();
|
||||
ChatState.inChat = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,25 @@ pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import ZShell.Llm
|
||||
import qs.Helpers
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property ChatSession chatSession
|
||||
readonly property string title: chatSession ? chatSession.title : "Chat"
|
||||
property int currentIdx: -1
|
||||
property bool inChat: false
|
||||
property bool fabExpanded: false
|
||||
property bool isWindow: false
|
||||
property bool isWide: false
|
||||
property bool settledSlim: false
|
||||
property bool settledWide: false
|
||||
property bool narrowSidebarExpanded: false
|
||||
property bool windowIsWide: false
|
||||
property ShellScreen screen
|
||||
property bool sidebarVisible: Visibilities.getForActive().sidebar
|
||||
|
||||
onSidebarVisibleChanged: if (!sidebarVisible)
|
||||
fabExpanded = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
TextEditBase {
|
||||
id: root
|
||||
|
||||
color: Colors.palette.m3onSurface
|
||||
readOnly: true
|
||||
textFormat: Text.MarkdownText
|
||||
font.pointSize: Tokens.font.size.smaller
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
|
||||
onLinkActivated: link => {
|
||||
Qt.openUrlExternally(link);
|
||||
}
|
||||
|
||||
CustomMouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.NoButton
|
||||
cursorShape: root.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor
|
||||
preventStealing: false
|
||||
hoverEnabled: true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import QtQuick
|
||||
import ZShell.Llm
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
import qs.Modules.Notifications.Sidebar.Chat
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property int animOff
|
||||
property Item currentItem
|
||||
property bool noAnim: false
|
||||
property bool loading: false
|
||||
property int lastIdx: -1
|
||||
|
||||
readonly property Component conversationComp: ChatContent {}
|
||||
|
||||
signal requestClose
|
||||
|
||||
function loadConversation(chat): void {
|
||||
if (currentItem) {
|
||||
currentItem.destroy();
|
||||
currentItem = null;
|
||||
}
|
||||
|
||||
if (!chat)
|
||||
return;
|
||||
|
||||
root.loading = true;
|
||||
|
||||
const incubator = root.conversationComp.incubateObject(container, {
|
||||
chatData: chat
|
||||
});
|
||||
|
||||
const attach = () => {
|
||||
incubator.object.anchors.fill = container;
|
||||
incubator.object.requestClose.connect(root.requestClose);
|
||||
currentItem = incubator.object;
|
||||
root.loading = false;
|
||||
if (!noAnim)
|
||||
enterAnim.start();
|
||||
};
|
||||
|
||||
if (incubator.status === Component.Ready)
|
||||
attach();
|
||||
else
|
||||
incubator.onStatusChanged = status => {
|
||||
if (status === Component.Ready)
|
||||
attach();
|
||||
};
|
||||
}
|
||||
|
||||
Item {
|
||||
id: container
|
||||
|
||||
anchors.fill: parent
|
||||
layer.enabled: opacity < 1
|
||||
objectName: "ConversationContainer"
|
||||
|
||||
Component.onCompleted: {
|
||||
if (ChatState.chatSession)
|
||||
root.loadConversation(ChatState.chatSession);
|
||||
}
|
||||
}
|
||||
|
||||
LoadingIndicator {
|
||||
anchors.centerIn: parent
|
||||
implicitSize: Tokens.font.size.extraLarge * 4
|
||||
opacity: root.loading ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onChatSessionChanged(): void {
|
||||
exitAnim.complete();
|
||||
enterAnim.complete();
|
||||
|
||||
if (root.noAnim) {
|
||||
root.loadConversation(ChatState.chatSession);
|
||||
return;
|
||||
}
|
||||
|
||||
root.animOff = Tokens.padding.small * (ChatState.currentIdx > root.lastIdx ? 1 : -1);
|
||||
root.lastIdx = ChatState.currentIdx;
|
||||
exitAnim.start();
|
||||
}
|
||||
|
||||
target: ChatState
|
||||
}
|
||||
|
||||
SequentialAnimation {
|
||||
id: exitAnim
|
||||
|
||||
Anim {
|
||||
property: "opacity"
|
||||
target: container
|
||||
to: 0
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
|
||||
ScriptAction {
|
||||
script: root.loadConversation(ChatState.chatSession)
|
||||
}
|
||||
}
|
||||
|
||||
SequentialAnimation {
|
||||
id: enterAnim
|
||||
|
||||
PropertyAction {
|
||||
property: "topMargin"
|
||||
target: container.anchors
|
||||
value: root.animOff
|
||||
}
|
||||
|
||||
PropertyAction {
|
||||
property: "bottomMargin"
|
||||
target: container.anchors
|
||||
value: -root.animOff
|
||||
}
|
||||
|
||||
ParallelAnimation {
|
||||
Anim {
|
||||
from: 0
|
||||
property: "opacity"
|
||||
target: container
|
||||
to: 1
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
|
||||
Anim {
|
||||
properties: "topMargin,bottomMargin"
|
||||
target: container.anchors
|
||||
to: 0
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import QtQuick.Shapes
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
@@ -7,7 +8,7 @@ import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
CustomClippingRect {
|
||||
CustomRect {
|
||||
id: root
|
||||
|
||||
required property string language
|
||||
@@ -15,16 +16,11 @@ CustomClippingRect {
|
||||
property bool copied: false
|
||||
property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh
|
||||
property color codeHeaderColor: Colors.palette.m3outline
|
||||
|
||||
// Highlighter spans for the current code; refreshed when the code or
|
||||
// its language changes. Highlighting runs off the GUI thread; the
|
||||
// token drops results that arrive after the code already changed.
|
||||
property var codeSpans: []
|
||||
property int highlightToken: 0
|
||||
|
||||
function refresh() {
|
||||
const token = ++root.highlightToken;
|
||||
codeSpans = [];
|
||||
CodeHighlighter.highlight(root.code, root.language, root, token);
|
||||
}
|
||||
|
||||
@@ -84,10 +80,14 @@ CustomClippingRect {
|
||||
let pos = 0;
|
||||
for (let i = 0; i < spans.length; i++) {
|
||||
const span = spans[i];
|
||||
if (span.start > pos)
|
||||
out += escapeHtml(code.slice(pos, span.start));
|
||||
out += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(span.start, span.start + span.length)) + "</font>";
|
||||
pos = span.start + span.length;
|
||||
const start = Math.min(span.start, code.length);
|
||||
const end = Math.min(span.start + span.length, code.length);
|
||||
if (end <= pos)
|
||||
continue;
|
||||
if (start > pos)
|
||||
out += escapeHtml(code.slice(pos, start));
|
||||
out += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(start, end)) + "</font>";
|
||||
pos = end;
|
||||
}
|
||||
if (pos < code.length)
|
||||
out += escapeHtml(code.slice(pos));
|
||||
@@ -100,7 +100,10 @@ CustomClippingRect {
|
||||
color: root.codeBackgroundColor
|
||||
radius: Tokens.rounding.medium
|
||||
|
||||
onLanguageChanged: refresh()
|
||||
onLanguageChanged: {
|
||||
codeSpans = [];
|
||||
refresh();
|
||||
}
|
||||
onCodeChanged: refresh()
|
||||
|
||||
RowLayout {
|
||||
@@ -212,7 +215,7 @@ CustomClippingRect {
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
CustomClippingRect {
|
||||
id: codeRect
|
||||
|
||||
anchors.left: parent.left
|
||||
@@ -226,6 +229,21 @@ CustomClippingRect {
|
||||
implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2
|
||||
color: CodeColors.active.bg
|
||||
|
||||
CustomText {
|
||||
id: code
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
x: implicitWidth * (0 - codeFlick.visibleArea.xPosition) + Tokens.padding.small
|
||||
anchors.margins: Tokens.padding.small
|
||||
text: root.highlightedHtml(root.code, root.codeSpans)
|
||||
textFormat: Text.RichText
|
||||
color: CodeColors.active.normal
|
||||
layer.enabled: true
|
||||
clip: false
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: codeFlick
|
||||
|
||||
@@ -234,15 +252,23 @@ CustomClippingRect {
|
||||
|
||||
CustomScrollBar.horizontal: CustomScrollBar {
|
||||
flickable: codeFlick
|
||||
parent: codeFlick.parent
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
}
|
||||
|
||||
TextAreaBase.flickable: TextAreaBase {
|
||||
id: codeText
|
||||
|
||||
color: CodeColors.active.normal
|
||||
layer.enabled: true
|
||||
leftInset: Tokens.padding.small
|
||||
clip: false
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
textFormat: Text.RichText
|
||||
text: root.highlightedHtml(root.code, root.codeSpans)
|
||||
text: code.text
|
||||
readOnly: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Llm
|
||||
import ZShell.Config
|
||||
import qs.Modules.Notifications.Sidebar.Chat
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
@@ -16,27 +17,66 @@ Item {
|
||||
required property ChatMessage message
|
||||
required property ChatGeneration current
|
||||
|
||||
// Widest the bubble may grow to; assistant bubbles always use it so
|
||||
// the markdown blocks have a deterministic width to wrap against.
|
||||
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
|
||||
|
||||
signal edit(text: string)
|
||||
|
||||
implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
|
||||
implicitHeight: bubble.implicitHeight + (actionsRow.shouldBeActive ? actionsRow.implicitHeight + actionsRow.anchors.topMargin : 0)
|
||||
|
||||
CustomRect {
|
||||
CustomClippingRect {
|
||||
id: bubble
|
||||
|
||||
property real liveTargetWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
|
||||
property real frozenWidth: 0.0
|
||||
property bool layoutFrozen: false
|
||||
property real layoutWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
|
||||
|
||||
radius: Tokens.rounding.medium
|
||||
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
|
||||
implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
|
||||
implicitWidth: layoutFrozen ? frozenWidth : layoutWidth
|
||||
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2
|
||||
anchors.right: root.isUser ? parent.right : undefined
|
||||
layer.enabled: false
|
||||
layer.smooth: true
|
||||
|
||||
transform: Scale {
|
||||
id: stretchScale
|
||||
origin.x: root.isUser ? bubble.width : 0
|
||||
xScale: bubble.layoutFrozen && bubble.frozenWidth > 0 ? bubble.liveTargetWidth / bubble.frozenWidth : 1.0
|
||||
}
|
||||
|
||||
states: [
|
||||
State {
|
||||
name: "toSlim"
|
||||
when: ChatState.isWindow && !ChatState.isWide && !ChatState.settledSlim
|
||||
|
||||
PropertyChanges {
|
||||
bubble.layer.enabled: true
|
||||
bubble.layoutFrozen: true
|
||||
}
|
||||
},
|
||||
State {
|
||||
name: "toWide"
|
||||
when: ChatState.isWindow && ChatState.isWide && !ChatState.settledWide
|
||||
|
||||
PropertyChanges {
|
||||
bubble.layer.enabled: true
|
||||
bubble.layoutFrozen: true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
onLayoutFrozenChanged: {
|
||||
if (layoutFrozen)
|
||||
frozenWidth = width;
|
||||
}
|
||||
|
||||
// User messages stay a plain editable text field.
|
||||
TextEditBase {
|
||||
id: msgText
|
||||
|
||||
property string cachedText: root.segment.text
|
||||
property bool cancelled: false
|
||||
|
||||
visible: root.isUser
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
@@ -59,27 +99,29 @@ Item {
|
||||
event.accepted = true;
|
||||
}
|
||||
} else if (event.key == Qt.Key_Escape) {
|
||||
text = root.segment.text;
|
||||
cancelled = true;
|
||||
readOnly = true;
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
onEditingFinished: {
|
||||
const old = root.segment.text;
|
||||
if (old !== text)
|
||||
root.edit(text);
|
||||
|
||||
readOnly = true;
|
||||
}
|
||||
onReadOnlyChanged: {
|
||||
if (readOnly) {
|
||||
animateCursor = false;
|
||||
root.forceActiveFocus();
|
||||
textFormat = CustomText.MarkdownText;
|
||||
|
||||
if (cancelled) {
|
||||
text = cachedText;
|
||||
cancelled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
root.edit(text);
|
||||
} else {
|
||||
var raw = root.segment.text;
|
||||
textFormat = CustomText.PlainText;
|
||||
text = raw;
|
||||
text = cachedText;
|
||||
forceActiveFocus();
|
||||
cursorPosition = text.length;
|
||||
animateCursor = true;
|
||||
@@ -103,12 +145,13 @@ Item {
|
||||
Actions {
|
||||
id: actionsRow
|
||||
|
||||
readonly property bool shouldBeActive: (root.segment.type === LlmSegment.Type.Content) && !root.segment.running && root.repeater.count === (root.index + 1)
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
|
||||
anchors.top: bubble.bottom
|
||||
visible: {
|
||||
return (root.segment.type === LlmSegment.Type.Content) && root.segment.status !== LlmSegment.Status.Running && root.repeater.count === (root.index + 1);
|
||||
}
|
||||
opacity: shouldBeActive ? 1 : 0
|
||||
visible: opacity > 0
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
edit: msgText
|
||||
hovered: root.hovered
|
||||
@@ -116,5 +159,9 @@ Item {
|
||||
segment: root.segment
|
||||
current: root.current
|
||||
message: root.message
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ ColumnLayout {
|
||||
CustomText {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
color: Colors.tPalette.m3onSurfaceVariant
|
||||
text: qsTr("No messages yet")
|
||||
text: qsTr("Send a message to get started")
|
||||
}
|
||||
|
||||
Item {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
@@ -7,7 +8,6 @@ import qs.Services
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// Top-level markdown blocks (see MarkdownParser / LlmSegment.markdown).
|
||||
required property var blocks
|
||||
|
||||
spacing: Tokens.spacing.small
|
||||
@@ -15,8 +15,10 @@ Column {
|
||||
Repeater {
|
||||
id: blockRep
|
||||
|
||||
model: root.blocks
|
||||
|
||||
model: ScriptModel {
|
||||
values: root.blocks
|
||||
objectProp: "id"
|
||||
}
|
||||
delegate: DelegateChooser {
|
||||
role: "type"
|
||||
|
||||
@@ -50,7 +52,7 @@ Column {
|
||||
DelegateChoice {
|
||||
roleValue: LlmMarkdown.Type.Heading
|
||||
|
||||
delegate: CustomText {
|
||||
delegate: TextEditBase {
|
||||
required property var modelData
|
||||
|
||||
color: Colors.palette.m3onSurface
|
||||
@@ -59,10 +61,11 @@ Column {
|
||||
anchors.margins: Tokens.padding.medium
|
||||
text: modelData.text
|
||||
textFormat: Text.MarkdownText
|
||||
readOnly: true
|
||||
font.bold: true
|
||||
font.pointSize: {
|
||||
if (modelData.level <= 1)
|
||||
return Tokens.font.size.larger;
|
||||
return Tokens.font.size.large;
|
||||
if (modelData.level === 2)
|
||||
return Tokens.font.size.normal;
|
||||
return Tokens.font.size.smaller;
|
||||
@@ -74,17 +77,13 @@ Column {
|
||||
DelegateChoice {
|
||||
roleValue: LlmMarkdown.Type.Text
|
||||
|
||||
delegate: CustomText {
|
||||
delegate: BubbleEdit {
|
||||
required property var modelData
|
||||
|
||||
color: Colors.palette.m3onSurface
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
text: modelData.text
|
||||
textFormat: Text.MarkdownText
|
||||
font.pointSize: Tokens.font.size.smaller
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ Item {
|
||||
property color mathColor: Colors.palette.m3onSurface
|
||||
property real fontSize: Tokens.font.size.large
|
||||
|
||||
implicitWidth: Math.max(fallbackText.implicitWidth, root.width)
|
||||
implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2
|
||||
|
||||
LlmMathText {
|
||||
|
||||
@@ -11,13 +11,15 @@ import qs.Services
|
||||
MouseArea {
|
||||
id: root
|
||||
|
||||
readonly property ChatGeneration current: modelData.activeGeneration
|
||||
property ChatGeneration current: modelData.activeGeneration
|
||||
required property int index
|
||||
readonly property bool isUser: modelData.role === ChatMessage.Role.User
|
||||
required property Item rootParent
|
||||
required property ChatMessage modelData
|
||||
property bool reasoningExpanded: false
|
||||
readonly property var blocks: blockify(current.segments)
|
||||
property real savedContentY: -1
|
||||
property real savedOffset: -1
|
||||
property bool stickActive: false
|
||||
|
||||
function handleReasoningToggle(expanded: bool): void {
|
||||
const view = root.ListView.view;
|
||||
@@ -25,14 +27,16 @@ MouseArea {
|
||||
return;
|
||||
|
||||
if (expanded) {
|
||||
root.savedContentY = view.contentY;
|
||||
root.savedOffset = view.contentY - root.y;
|
||||
root.reasoningExpanded = true;
|
||||
} else {
|
||||
root.reasoningExpanded = false;
|
||||
if (root.savedContentY !== -1) {
|
||||
restoreAnim.to = root.savedContentY;
|
||||
root.stickActive = false;
|
||||
settleTimer.stop();
|
||||
if (!isNaN(root.savedOffset)) {
|
||||
restoreAnim.to = root.y + root.savedOffset;
|
||||
restoreAnim.restart();
|
||||
root.savedContentY = -1;
|
||||
root.savedOffset = NaN;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,20 +64,87 @@ MouseArea {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
implicitWidth: ListView.view.width
|
||||
hoverEnabled: true
|
||||
preventStealing: true
|
||||
preventStealing: false
|
||||
implicitHeight: layout.implicitHeight + Tokens.padding.medium
|
||||
|
||||
Binding {
|
||||
property: "contentY"
|
||||
restoreMode: Binding.RestoreNone
|
||||
target: root.ListView.view
|
||||
value: root.y - Tokens.padding.large * 2
|
||||
when: root.reasoningExpanded && root.ListView.view && ((root.y - Tokens.padding.large * 2) < root.ListView.view.contentY)
|
||||
Timer {
|
||||
id: settleTimer
|
||||
interval: 16
|
||||
|
||||
onTriggered: {
|
||||
root.stickActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
onImplicitHeightChanged: {
|
||||
if (root.reasoningExpanded) {
|
||||
root.stickActive = true;
|
||||
settleTimer.restart();
|
||||
|
||||
const view = root.ListView.view;
|
||||
if (!view)
|
||||
return;
|
||||
const bottomEdge = root.y + root.height + Tokens.padding.large * 2;
|
||||
const viewportBottom = view.contentY + view.height;
|
||||
if (bottomEdge > viewportBottom)
|
||||
view.contentY = bottomEdge - view.height;
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on current {
|
||||
id: changeAnim
|
||||
|
||||
property var object: targetProperty
|
||||
readonly property bool next: {
|
||||
const cond = root.modelData.generations.indexOf(current) < root.modelData.generations.indexOf(changeAnim.targetValue);
|
||||
return cond;
|
||||
}
|
||||
|
||||
animation: SequentialAnimation {
|
||||
ParallelAnimation {
|
||||
Anim {
|
||||
target: root
|
||||
property: "x"
|
||||
to: changeAnim.next ? root.width / 4 : -root.width / 4
|
||||
from: 0
|
||||
type: Anim.FastEffects
|
||||
}
|
||||
|
||||
Anim {
|
||||
target: root
|
||||
property: "opacity"
|
||||
from: 1
|
||||
to: 0
|
||||
type: Anim.FastEffects
|
||||
}
|
||||
}
|
||||
|
||||
PropertyAction {}
|
||||
|
||||
ParallelAnimation {
|
||||
Anim {
|
||||
target: root
|
||||
property: "x"
|
||||
from: changeAnim.next ? -root.width / 4 : root.width / 4
|
||||
type: Anim.FastEffects
|
||||
to: 0
|
||||
}
|
||||
|
||||
Anim {
|
||||
target: root
|
||||
property: "opacity"
|
||||
from: 0
|
||||
type: Anim.FastEffects
|
||||
to: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Anim {
|
||||
@@ -90,6 +161,7 @@ MouseArea {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
Repeater {
|
||||
id: segmentRep
|
||||
@@ -105,12 +177,10 @@ MouseArea {
|
||||
roleValue: "process"
|
||||
|
||||
delegate: ProcessBlock {
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
segments: modelData.segments
|
||||
isActive: index === root.blocks.length - 1
|
||||
width: root.width
|
||||
blocks: root.blocks
|
||||
current: root.current
|
||||
rootParent: root.rootParent
|
||||
|
||||
onExpandedChanged: root.handleReasoningToggle(expanded)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Blobs
|
||||
import ZShell.Config
|
||||
import ZShell.Components
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
@@ -8,19 +10,54 @@ import qs.Services
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var segments
|
||||
required property bool isActive
|
||||
required property int index
|
||||
required property var modelData
|
||||
required property var blocks
|
||||
readonly property var segments: modelData.segments
|
||||
required property Item rootParent
|
||||
required property ChatGeneration current
|
||||
readonly property bool isActive: index === blocks.length - 1
|
||||
property bool expanded: false
|
||||
readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1]
|
||||
readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
|
||||
readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).some(s => current.pendingToolCalls.includes(s))
|
||||
|
||||
implicitHeight: expandedRect.implicitHeight + collapsedText.implicitHeight + expandedRect.anchors.topMargin
|
||||
clip: true
|
||||
implicitHeight: {
|
||||
const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin;
|
||||
const collapsed = layout.implicitHeight;
|
||||
const approval = layout.implicitHeight + approvalPrompt.implicitHeight + approvalPrompt.anchors.topMargin;
|
||||
|
||||
if (expanded)
|
||||
return expand;
|
||||
else if (pendingApproval)
|
||||
return approval;
|
||||
else
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: layout
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
|
||||
Item {
|
||||
implicitHeight: expandBtn.implicitHeight
|
||||
implicitWidth: expandBtn.implicitWidth
|
||||
|
||||
LoadingIndicator {
|
||||
id: spinnerReasoning
|
||||
|
||||
anchors.centerIn: expandBtn
|
||||
implicitSize: collapsedText.implicitHeight
|
||||
anchors.centerIn: parent
|
||||
opacity: root.isActive ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
@@ -32,13 +69,12 @@ Item {
|
||||
IconButton {
|
||||
id: expandBtn
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: collapsedText.verticalCenter
|
||||
font.pointSize: Tokens.font.size.large
|
||||
anchors.topMargin: 0
|
||||
icon: "keyboard_arrow_down"
|
||||
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
|
||||
anchors.centerIn: parent
|
||||
opacity: root.isActive ? 0 : 1
|
||||
enabled: opacity > 0
|
||||
rotation: root.expanded ? 180 : 0
|
||||
type: IconButton.Text
|
||||
|
||||
@@ -48,19 +84,20 @@ Item {
|
||||
|
||||
onClicked: root.expanded = !root.expanded
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: collapsedText
|
||||
|
||||
anchors.left: expandBtn.right
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 0
|
||||
color: Colors.palette.m3outline
|
||||
font.pointSize: Tokens.font.size.small
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: Tokens.spacing.medium
|
||||
text: {
|
||||
if (root.isActive) {
|
||||
if (root.pendingApproval)
|
||||
return qsTr("Waiting for approval...");
|
||||
|
||||
if (root.lastSegment.type === LlmSegment.Type.ToolCall)
|
||||
return qsTr("Using %1...").arg(root.lastSegment.name);
|
||||
return qsTr("Thinking...");
|
||||
@@ -72,25 +109,277 @@ Item {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
Item {
|
||||
id: approvalPrompt
|
||||
anchors.top: layout.bottom
|
||||
anchors.topMargin: Tokens.spacing.small
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2
|
||||
visible: root.pendingApproval
|
||||
|
||||
CustomRect {
|
||||
id: approvalBg
|
||||
|
||||
anchors.fill: parent
|
||||
radius: Tokens.rounding.medium
|
||||
color: Colors.palette.m3surface
|
||||
border.width: 1
|
||||
border.color: Colors.palette.m3outlineVariant
|
||||
|
||||
ColumnLayout {
|
||||
id: approvalLayout
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.medium
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
CustomText {
|
||||
id: requestHeader
|
||||
text: qsTr("Tool call request")
|
||||
font.pointSize: Tokens.font.size.large
|
||||
color: Colors.palette.m3onSurface
|
||||
}
|
||||
|
||||
Item {
|
||||
id: toolsBg
|
||||
|
||||
property bool open: false
|
||||
property Item rootParent: root.rootParent
|
||||
property int openHeight: toolsBg.rootParent.height - Tokens.padding.medium * 2
|
||||
property int openWidth: toolsBg.rootParent.width - Tokens.padding.medium * 2
|
||||
|
||||
implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2
|
||||
Layout.fillWidth: true
|
||||
|
||||
function reparentWrapper(): void {
|
||||
const newParent = open ? toolsBg.rootParent : toolsBg;
|
||||
const pos = toolsWrapper.mapToItem(newParent, 0, 0);
|
||||
toolsWrapper.parent = newParent;
|
||||
toolsWrapper.x = pos.x;
|
||||
toolsWrapper.y = pos.y;
|
||||
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
|
||||
}
|
||||
|
||||
BlobGroup {
|
||||
id: blobGroup
|
||||
|
||||
color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer
|
||||
|
||||
Behavior on color {
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: backdrop
|
||||
|
||||
anchors.fill: parent
|
||||
enabled: false
|
||||
hoverEnabled: enabled
|
||||
preventStealing: true
|
||||
parent: toolsBg.open ? toolsBg.rootParent : toolsBg
|
||||
|
||||
onClicked: toolsBg.open = false
|
||||
}
|
||||
|
||||
Item {
|
||||
id: toolsWrapper
|
||||
|
||||
width: toolsBg.width
|
||||
height: toolBox.implicitHeight
|
||||
|
||||
states: State {
|
||||
name: "open"
|
||||
when: toolsBg.open
|
||||
|
||||
PropertyChanges {
|
||||
backdrop.enabled: true
|
||||
toolsBackground.bottomLeftRadius: Tokens.rounding.largeIncreased
|
||||
toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased
|
||||
toolsBackground.topRightRadius: Tokens.rounding.largeIncreased
|
||||
toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased
|
||||
// toolsContent.opacity: 1
|
||||
toolsWrapper.height: toolsBg.openHeight
|
||||
toolsWrapper.width: toolsBg.openWidth
|
||||
toolsWrapper.x: (toolsBg.rootParent.width - toolsBg.openWidth) / 2
|
||||
toolsWrapper.y: (toolsBg.rootParent.height - toolsBg.openHeight) / 2
|
||||
elevation.opacity: 1
|
||||
toolBox.opacity: 0
|
||||
}
|
||||
}
|
||||
transitions: Transition {
|
||||
id: dialogTransition
|
||||
|
||||
SequentialAnimation {
|
||||
ScriptAction {
|
||||
script: toolsBg.reparentWrapper()
|
||||
}
|
||||
|
||||
Anim {
|
||||
properties: "x,y"
|
||||
}
|
||||
}
|
||||
|
||||
PropertyAction {
|
||||
property: "enabled"
|
||||
}
|
||||
|
||||
Anim {
|
||||
properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
|
||||
Anim {
|
||||
properties: "width,height"
|
||||
}
|
||||
}
|
||||
|
||||
Elevation {
|
||||
id: elevation
|
||||
|
||||
anchors.fill: parent
|
||||
bottomLeftRadius: toolsBackground.bottomLeftRadius
|
||||
bottomRightRadius: toolsBackground.bottomRightRadius
|
||||
level: 4
|
||||
opacity: 0
|
||||
radius: toolsBackground.radius
|
||||
|
||||
transform: Matrix4x4 {
|
||||
matrix: toolsBackground.deformMatrix
|
||||
}
|
||||
}
|
||||
|
||||
BlobRect {
|
||||
id: toolsBackground
|
||||
|
||||
anchors.fill: parent
|
||||
bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
|
||||
bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
|
||||
topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
|
||||
topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
|
||||
deformScale: 0
|
||||
group: blobGroup
|
||||
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: toolBox
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: Math.min(implicitHeight, parent.height)
|
||||
implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
StateLayer {
|
||||
onClicked: {
|
||||
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
|
||||
toolsBg.open = true;
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: toolList
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.margins: Tokens.padding.medium
|
||||
|
||||
Repeater {
|
||||
model: root.current.pendingToolCalls ?? []
|
||||
delegate: RowLayout {
|
||||
id: toolText
|
||||
|
||||
required property LlmSegment modelData
|
||||
required property int index
|
||||
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
function toolTarget(seg: LlmSegment): string {
|
||||
var a = null;
|
||||
try {
|
||||
a = JSON.parse(seg.arguments);
|
||||
} catch (e) {}
|
||||
if (a && typeof a === "object") {
|
||||
const v = a.url ?? a.path;
|
||||
if (v !== undefined && v !== null)
|
||||
return String(v);
|
||||
}
|
||||
return String(seg.arguments);
|
||||
}
|
||||
|
||||
CustomText {
|
||||
color: Colors.palette.m3onPrimaryContainer
|
||||
text: String(toolText.index + 1)
|
||||
font.pointSize: Tokens.font.size.large
|
||||
}
|
||||
|
||||
CustomText {
|
||||
color: Colors.palette.m3onPrimaryContainer
|
||||
text: qsTr("%1").arg(toolText.modelData.name)
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
color: Colors.palette.m3onPrimaryContainer
|
||||
text: "arrow_right_alt"
|
||||
}
|
||||
|
||||
CustomText {
|
||||
color: Colors.palette.m3onPrimaryContainer
|
||||
text: qsTr("%1").arg(toolText.toolTarget(toolText.modelData))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ButtonRow {
|
||||
Layout.alignment: Qt.AlignRight
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
IconTextButton {
|
||||
icon: "check_circle"
|
||||
text: qsTr("Allow")
|
||||
horizontalPadding: Tokens.padding.large
|
||||
verticalPadding: Tokens.padding.small
|
||||
isRound: true
|
||||
shapeMorph: true
|
||||
onClicked: root.current.approveTools()
|
||||
}
|
||||
|
||||
IconTextButton {
|
||||
icon: "cancel"
|
||||
text: qsTr("Deny")
|
||||
isRound: true
|
||||
shapeMorph: true
|
||||
inactiveColor: Colors.palette.m3error
|
||||
inactiveOnColor: Colors.palette.m3onError
|
||||
horizontalPadding: Tokens.padding.large
|
||||
verticalPadding: Tokens.padding.small
|
||||
onClicked: root.current.denyTools()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: expandedRect
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: collapsedText.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
anchors.top: layout.bottom
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.topMargin: Tokens.spacing.small
|
||||
color: Colors.palette.m3surfaceContainerLow
|
||||
implicitHeight: root.expanded ? expandedContent.contentHeight + expandedContent.anchors.margins * 2 : 0
|
||||
implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2
|
||||
opacity: root.expanded ? 1 : 0
|
||||
radius: Tokens.rounding.medium
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
@@ -133,6 +422,49 @@ Item {
|
||||
CustomText {
|
||||
id: content
|
||||
|
||||
function toolTarget(seg: LlmSegment): string {
|
||||
var a = null;
|
||||
try {
|
||||
a = JSON.parse(seg.arguments);
|
||||
} catch (e) {}
|
||||
if (a && typeof a === "object") {
|
||||
const v = a.url ?? a.path;
|
||||
if (v !== undefined && v !== null)
|
||||
return String(v);
|
||||
}
|
||||
return String(seg.arguments);
|
||||
}
|
||||
|
||||
function prettyTool(seg: LlmSegment): string {
|
||||
if (seg.name === "webfetch") {
|
||||
switch (seg.status) {
|
||||
case LlmSegment.Status.Running:
|
||||
return qsTr("Fetching");
|
||||
case LlmSegment.Status.Pending:
|
||||
return qsTr("Pending fetch");
|
||||
case LlmSegment.Status.Success:
|
||||
return qsTr("Fetched");
|
||||
case LlmSegment.Status.Error:
|
||||
return qsTr("Failed to fetch");
|
||||
default:
|
||||
return qsTr("Fetched website");
|
||||
}
|
||||
} else if (seg.name === "readfile") {
|
||||
switch (seg.status) {
|
||||
case LlmSegment.Status.Running:
|
||||
return qsTr("Reading");
|
||||
case LlmSegment.Status.Pending:
|
||||
return qsTr("Pending read");
|
||||
case LlmSegment.Status.Success:
|
||||
return qsTr("Read");
|
||||
case LlmSegment.Status.Error:
|
||||
return qsTr("Failed to read");
|
||||
default:
|
||||
return qsTr("Read file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Tokens.padding.small
|
||||
anchors.top: header.bottom
|
||||
@@ -141,7 +473,7 @@ Item {
|
||||
color: Colors.palette.m3outline
|
||||
font.pointSize: Tokens.font.size.small
|
||||
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
|
||||
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("Fetched %1").arg(JSON.parse(segment.modelData.arguments)?.url ?? "website")
|
||||
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("%1 %2").arg(content.prettyTool(segment.modelData)).arg(content.toolTarget(segment.modelData))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ pragma Singleton
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import qs.Helpers
|
||||
import qs.Services
|
||||
|
||||
Singleton {
|
||||
@@ -10,6 +11,7 @@ Singleton {
|
||||
|
||||
function create(parent: Item, props: var): void {
|
||||
chatComp.createObject(parent ?? dummy, props);
|
||||
Visibilities.getForActive().sidebar = false;
|
||||
ChatState.isWindow = true;
|
||||
}
|
||||
|
||||
@@ -31,7 +33,7 @@ Singleton {
|
||||
minimumSize.height: Config.sidebar.sizes.width
|
||||
minimumSize.width: Config.sidebar.sizes.width
|
||||
surfaceFormat.opaque: false
|
||||
title: qsTr("ZShell - Chat")
|
||||
title: qsTr("ZShell - %1").arg(ChatState.title)
|
||||
|
||||
Component.onCompleted: ChatState.screen = screen
|
||||
onVisibleChanged: {
|
||||
@@ -41,7 +43,7 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
ChatPanel {
|
||||
SidebarView {
|
||||
id: chat
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
@@ -2,90 +2,238 @@ pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import ZShell.Blobs
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Modules.Notifications.Sidebar.Chat.Content
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property int breakpoint: 700
|
||||
property int breakpoint: Math.round(Config.sidebar.sizes.width * 2) + Tokens.spacing.medium * 2 + Tokens.padding.medium * 3
|
||||
property color blobColor: Colors.tPalette.m3surfaceContainerLow
|
||||
readonly property int iconSize: Math.round(Tokens.font.size.extraLarge * (96 / 72)) + Tokens.padding.large * 2
|
||||
property alias conversationModel: sidebar.model
|
||||
property var currentConversation: null // whatever model item is "open"
|
||||
property bool chatOpen: ChatState.chatSession !== null
|
||||
|
||||
readonly property bool isWide: root.width >= root.breakpoint
|
||||
property bool narrowShowsSidebar: true
|
||||
|
||||
function closeConversation() {
|
||||
narrowShowsSidebar = true;
|
||||
readonly property bool showList: isWide || !chatOpen
|
||||
readonly property bool showContent: !isWide && chatOpen
|
||||
|
||||
function openConversation(conv: ChatSession, index: int): void {
|
||||
}
|
||||
|
||||
function openConversation(conv) {
|
||||
currentConversation = conv;
|
||||
narrowShowsSidebar = false; // in narrow mode, jump to the chat
|
||||
BlobGroup {
|
||||
id: blobGroup
|
||||
|
||||
color: root.blobColor
|
||||
smoothing: Tokens.rounding.medium
|
||||
}
|
||||
|
||||
BlobInvertedRect {
|
||||
id: invertedRect
|
||||
anchors.fill: parent
|
||||
borderBottom: Tokens.padding.small
|
||||
borderLeft: sidebar.implicitWidth + sidebar.anchors.margins + Tokens.spacing.medium
|
||||
borderRight: Tokens.padding.small
|
||||
borderTop: Tokens.padding.small
|
||||
group: blobGroup
|
||||
opacity: root.blobColor.a
|
||||
radius: Tokens.rounding.largeIncreased
|
||||
}
|
||||
|
||||
ChatList {
|
||||
id: sidebar
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: root.isWide ? undefined : parent.right
|
||||
anchors.top: parent.top
|
||||
implicitWidth: 20
|
||||
list.currentIndex: -1
|
||||
list.highlightFollowsCurrentItem: false
|
||||
anchors.left: parent.left
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: Tokens.padding.medium
|
||||
implicitWidth: root.isWide || ChatState.narrowSidebarExpanded ? Config.sidebar.sizes.width : root.iconSize
|
||||
highlight: true
|
||||
slim: !root.isWide && !ChatState.narrowSidebarExpanded
|
||||
z: 1
|
||||
|
||||
Behavior on anchors.right {
|
||||
AnchorAnim {}
|
||||
}
|
||||
Behavior on implicitWidth {
|
||||
Anim {}
|
||||
}
|
||||
list.highlight: CustomRect {
|
||||
color: Colors.palette.m3primary
|
||||
implicitHeight: sidebar.list.currentItem?.implicitHeight ?? 0
|
||||
implicitWidth: sidebar.list.width
|
||||
radius: Tokens.rounding.medium
|
||||
x: sidebar.list.currentItem?.chat.x ?? 0
|
||||
y: sidebar.list.currentItem?.y ?? 0
|
||||
|
||||
Behavior on y {
|
||||
Anim {
|
||||
duration: Tokens.anim.durations.small
|
||||
easing: Tokens.anim.expressiveDefaultEffects
|
||||
}
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
model: ScriptModel {
|
||||
values: Chat.chats.values
|
||||
}
|
||||
|
||||
anchors.onRightChanged: {
|
||||
if (anchors.right === undefined)
|
||||
implicitWidth = 20;
|
||||
}
|
||||
onLoadChatRequest: (chat, index) => {
|
||||
sidebar.list.currentIndex = index;
|
||||
root.openConversation(chat);
|
||||
ChatState.currentIdx = index;
|
||||
ChatState.chatSession = chat;
|
||||
}
|
||||
|
||||
onDeleteChatRequest: chat => {
|
||||
if (chat === ChatState.chatSession) {
|
||||
ChatState.chatSession = null;
|
||||
ChatState.currentIdx = -1;
|
||||
}
|
||||
Chat.chats.remove(chat);
|
||||
}
|
||||
onNewChatRequest: {
|
||||
const data = Chat.chats.insert();
|
||||
ChatState.currentIdx = 0;
|
||||
ChatState.chatSession = data;
|
||||
}
|
||||
|
||||
onRequestExpand: {
|
||||
ChatState.narrowSidebarExpanded = true;
|
||||
}
|
||||
}
|
||||
|
||||
CustomClippingWrapperRect {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: sidebar.right
|
||||
anchors.right: parent.right
|
||||
CustomClippingRect {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: invertedRect.borderLeft
|
||||
anchors.topMargin: invertedRect.borderTop
|
||||
anchors.bottomMargin: invertedRect.borderBottom
|
||||
anchors.rightMargin: invertedRect.borderRight
|
||||
radius: invertedRect.radius
|
||||
|
||||
children: [contentArea, dim]
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: dim
|
||||
anchors.fill: parent
|
||||
|
||||
color: Colors.palette.m3shadow
|
||||
opacity: ChatState.narrowSidebarExpanded && !root.isWide ? 0.3 : 0
|
||||
z: 1
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: contentArea
|
||||
|
||||
property int leftMargin: sidebar.implicitWidth
|
||||
|
||||
z: 0
|
||||
anchors.top: parent.top
|
||||
child: conversationView
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.right: parent.right
|
||||
implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 - anchors.margins * 2
|
||||
|
||||
states: State {
|
||||
name: "wide"
|
||||
when: !root.isWide
|
||||
|
||||
PropertyChanges {
|
||||
contentArea.leftMargin: root.iconSize
|
||||
}
|
||||
}
|
||||
|
||||
ChatContent {
|
||||
transitions: Transition {
|
||||
to: "wide"
|
||||
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
property: "leftMargin"
|
||||
}
|
||||
}
|
||||
|
||||
ChatHost {
|
||||
id: convHost
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
implicitWidth: Math.max(Math.min(parent.width, 800), Config.sidebar.sizes.width)
|
||||
clip: true
|
||||
|
||||
onLoadingChanged: if (!loading)
|
||||
root.chatOpen = true
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: noChatLayout
|
||||
anchors.centerIn: parent
|
||||
opacity: ChatState.chatSession ? 0 : 1
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
text: "chat_bubble_off"
|
||||
font.pointSize: Tokens.font.size.extraLarge
|
||||
color: Colors.tPalette.m3onSurfaceVariant
|
||||
}
|
||||
|
||||
CustomText {
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
text: qsTr("Start or create a chat")
|
||||
color: Colors.tPalette.m3onSurfaceVariant
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: dimArea
|
||||
anchors.fill: parent
|
||||
preventStealing: true
|
||||
enabled: !root.isWide && ChatState.narrowSidebarExpanded
|
||||
cursorShape: undefined
|
||||
|
||||
onClicked: {
|
||||
const x = mouseX;
|
||||
const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2;
|
||||
|
||||
if (x > invertedRect.borderLeft - margin)
|
||||
ChatState.narrowSidebarExpanded = false;
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
cursorShape: dimArea.enabled ? Qt.ArrowCursor : undefined
|
||||
enabled: dimArea.enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: ChatState
|
||||
property: "windowIsWide"
|
||||
value: root.showList
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: ChatState
|
||||
property: "isWide"
|
||||
value: root.isWide
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: ChatState
|
||||
value: contentArea.leftMargin === root.iconSize
|
||||
property: "settledSlim"
|
||||
}
|
||||
|
||||
Binding {
|
||||
target: ChatState
|
||||
value: contentArea.leftMargin === sidebar.implicitWidth
|
||||
property: "settledWide"
|
||||
}
|
||||
|
||||
Component {
|
||||
id: conversationView
|
||||
|
||||
anchors.fill: root
|
||||
anchors.leftMargin: root.isWide ? Config.sidebar.sizes.width / 2 : 0
|
||||
chatData: root.currentConversation
|
||||
ChatContent {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Components
|
||||
@@ -31,7 +30,7 @@ Item {
|
||||
|
||||
Tabs {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: ChatState.isWindow ? 0 : implicitHeight
|
||||
Layout.preferredHeight: ChatState.isWindow || !Config.llm.enabled ? 0 : implicitHeight
|
||||
dashState: root.props
|
||||
nonAnimWidth: layout.width
|
||||
visible: height > 0
|
||||
@@ -53,12 +52,10 @@ Item {
|
||||
x: root.props.currentTab === 0 ? 0 : -root.width
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
Behavior on x {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
@@ -73,24 +70,29 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: chatPage
|
||||
Loader {
|
||||
id: chatLoader
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.top: parent.top
|
||||
implicitWidth: parent.width
|
||||
active: Config.llm.enabled
|
||||
width: parent.width
|
||||
property bool tre: true
|
||||
|
||||
sourceComponent: Item {
|
||||
id: chatPage
|
||||
|
||||
opacity: root.props.currentTab === 1 ? 1 : 0
|
||||
visible: opacity > 0
|
||||
x: root.props.currentTab === 0 ? root.width : 0
|
||||
z: 1
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
|
||||
Behavior on x {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
@@ -104,6 +106,7 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
Layout.fillWidth: true
|
||||
|
||||
@@ -110,7 +110,10 @@ ColumnLayout {
|
||||
|
||||
onClicked: {
|
||||
root.visibilities.sidebar = false;
|
||||
if (Config.launcher.uwsm)
|
||||
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]);
|
||||
else
|
||||
Quickshell.execDetached([...Config.general.apps.playback, recording.modelData.path]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +123,10 @@ ColumnLayout {
|
||||
|
||||
onClicked: {
|
||||
root.visibilities.sidebar = false;
|
||||
if (Config.launcher.uwsm)
|
||||
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]);
|
||||
else
|
||||
Quickshell.execDetached([...Config.general.apps.explorer, recording.modelData.path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,8 +141,7 @@ ColumnLayout {
|
||||
}
|
||||
}
|
||||
Behavior on implicitHeight {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
model: FileSystemModel {
|
||||
nameFilters: ["recording_*.mp4"]
|
||||
@@ -162,8 +167,7 @@ ColumnLayout {
|
||||
opacity: list.count === 0 ? 1 : 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
sourceComponent: ColumnLayout {
|
||||
spacing: Tokens.spacing.small
|
||||
@@ -178,16 +182,13 @@ ColumnLayout {
|
||||
text: "scan_delete"
|
||||
|
||||
Behavior on Layout.preferredHeight {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
Behavior on scale {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,16 +204,13 @@ ColumnLayout {
|
||||
text: "scan_delete"
|
||||
|
||||
Behavior on Layout.preferredWidth {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
Behavior on scale {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-20
@@ -73,20 +73,21 @@ Scope {
|
||||
|
||||
// mask: Region { item: inputPanel }
|
||||
|
||||
Rectangle {
|
||||
CustomRect {
|
||||
id: inputPanel
|
||||
|
||||
anchors.centerIn: parent
|
||||
color: Colors.tPalette.m3surface
|
||||
implicitHeight: layout.childrenRect.height + 28
|
||||
implicitWidth: layout.childrenRect.width + 32
|
||||
implicitHeight: layout.implicitHeight + layout.anchors.margins * 2
|
||||
implicitWidth: Math.max(layout.implicitWidth + layout.anchors.margins * 2, 450)
|
||||
opacity: 0
|
||||
radius: Tokens.rounding.small * 2
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
|
||||
anchors.centerIn: parent
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.medium
|
||||
|
||||
RowLayout {
|
||||
id: contentRow
|
||||
@@ -130,7 +131,7 @@ Scope {
|
||||
Layout.preferredWidth: Math.min(600, contentWidth)
|
||||
font.bold: true
|
||||
font.pointSize: 16
|
||||
text: polkitAgent.flow?.message
|
||||
text: polkitAgent.flow?.message ?? ""
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
@@ -147,8 +148,8 @@ Scope {
|
||||
TextField {
|
||||
id: passInput
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 40
|
||||
Layout.preferredWidth: contentColumn.implicitWidth
|
||||
color: Colors.palette.m3onSurfaceVariant
|
||||
echoMode: polkitAgent.flow?.responseVisible ? TextInput.Normal : TextInput.Password
|
||||
placeholderText: polkitAgent.flow?.failed ? " Incorrect Password" : " Input Password"
|
||||
@@ -168,7 +169,7 @@ Scope {
|
||||
id: showPassCheckbox
|
||||
|
||||
Layout.alignment: Qt.AlignLeft
|
||||
checked: polkitAgent.flow?.responseVisible
|
||||
checked: polkitAgent.flow?.responseVisible ?? false
|
||||
text: "Show Password"
|
||||
|
||||
onCheckedChanged: {
|
||||
@@ -189,7 +190,8 @@ Scope {
|
||||
clip: true
|
||||
color: Colors.tPalette.m3surfaceContainerLow
|
||||
implicitHeight: 0
|
||||
radius: 16
|
||||
implicitWidth: textDetailsColumn.implicitWidth + textDetailsColumn.anchors.margins * 2
|
||||
radius: Tokens.rounding.medium
|
||||
visible: true
|
||||
|
||||
Behavior on open {
|
||||
@@ -197,7 +199,8 @@ Scope {
|
||||
Anim {
|
||||
property: "implicitHeight"
|
||||
target: detailsPanel
|
||||
to: !detailsPanel.open ? textDetailsColumn.childrenRect.height + 16 : 0
|
||||
to: !detailsPanel.open ? textDetailsColumn.implicitHeight + Tokens.padding.small * 2 : 0
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
|
||||
Anim {
|
||||
@@ -205,12 +208,6 @@ Scope {
|
||||
target: textDetailsColumn
|
||||
to: !detailsPanel.open ? 1 : 0
|
||||
}
|
||||
|
||||
Anim {
|
||||
property: "scale"
|
||||
target: textDetailsColumn
|
||||
to: !detailsPanel.open ? 1 : 0.9
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,10 +215,9 @@ Scope {
|
||||
id: textDetailsColumn
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
anchors.margins: Tokens.padding.small
|
||||
opacity: 0
|
||||
scale: 0.9
|
||||
spacing: 8
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
CustomText {
|
||||
text: `actionId: ${polkitAgent.flow?.actionId}`
|
||||
@@ -239,16 +235,17 @@ Scope {
|
||||
Layout.preferredWidth: contentRow.implicitWidth
|
||||
spacing: 8
|
||||
|
||||
IconTextButton {
|
||||
IconButton {
|
||||
id: detailsButton
|
||||
|
||||
Layout.alignment: Qt.AlignLeft
|
||||
horizontalPadding: Tokens.padding.medium
|
||||
icon: "info"
|
||||
inactiveColor: Colors.palette.m3surfaceContainer
|
||||
inactiveOnColor: Colors.palette.m3onSurface
|
||||
isRound: true
|
||||
shapeMorph: true
|
||||
text: "Details"
|
||||
verticalPadding: Tokens.padding.medium
|
||||
|
||||
onClicked: {
|
||||
panelWindow.detailsOpen = !panelWindow.detailsOpen;
|
||||
|
||||
@@ -15,14 +15,17 @@ Item {
|
||||
required property Component content
|
||||
required property string header
|
||||
property int horizontalContentMargin
|
||||
required property string icon
|
||||
property string icon
|
||||
required property string label
|
||||
property alias iconLabel: openButton.iconLabel
|
||||
property alias rowButton: openButton
|
||||
property string subtext: ""
|
||||
property bool open
|
||||
property real openHeight: Math.min(rootParent.height * 0.8, 600)
|
||||
property real openWidth: Math.min(rootParent.width * 0.8, 400)
|
||||
required property Item rootParent
|
||||
property bool separateContent
|
||||
required property string settingAnchor
|
||||
property bool first: false
|
||||
property bool last: false
|
||||
|
||||
@@ -57,6 +60,7 @@ Item {
|
||||
anchors.fill: parent
|
||||
enabled: false
|
||||
hoverEnabled: enabled
|
||||
preventStealing: true
|
||||
parent: root.open ? root.rootParent : root
|
||||
|
||||
onClicked: root.open = false
|
||||
@@ -77,7 +81,8 @@ Item {
|
||||
backdrop.enabled: true
|
||||
dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased
|
||||
dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased
|
||||
dialogBg.radius: Tokens.rounding.largeIncreased
|
||||
dialogBg.topRightRadius: Tokens.rounding.largeIncreased
|
||||
dialogBg.topLeftRadius: Tokens.rounding.largeIncreased
|
||||
dialogContent.opacity: 1
|
||||
dialogWrapper.height: root.openHeight
|
||||
dialogWrapper.width: root.openWidth
|
||||
@@ -105,7 +110,7 @@ Item {
|
||||
}
|
||||
|
||||
Anim {
|
||||
properties: "opacity,radius,bottomLeftRadius,bottomRightRadius"
|
||||
properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
|
||||
@@ -172,6 +177,7 @@ Item {
|
||||
|
||||
sourceComponent: MouseArea {
|
||||
onWheel: event => event.accepted = true
|
||||
preventStealing: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.bottomMargin: Tokens.padding.largeIncreased
|
||||
@@ -224,6 +230,7 @@ Item {
|
||||
text: qsTr("Cancel")
|
||||
type: TextButton.Text
|
||||
verticalPadding: Tokens.padding.small
|
||||
stateLayer.preventStealing: true
|
||||
|
||||
onClicked: {
|
||||
root.cancelled();
|
||||
@@ -237,6 +244,7 @@ Item {
|
||||
isRound: true
|
||||
text: root.acceptLabel
|
||||
type: TextButton.Text
|
||||
stateLayer.preventStealing: true
|
||||
verticalPadding: Tokens.padding.small
|
||||
|
||||
onClicked: {
|
||||
|
||||
@@ -10,6 +10,7 @@ DialogRowButton {
|
||||
|
||||
required property var model
|
||||
property var selectedItem
|
||||
property var initialSelect
|
||||
|
||||
function keyFor(item: var): string {
|
||||
return item.id;
|
||||
@@ -91,7 +92,14 @@ DialogRowButton {
|
||||
}
|
||||
|
||||
onOpenChanged: {
|
||||
if (open)
|
||||
if (open) {
|
||||
root.rootParent.interactive = false;
|
||||
if (!initialSelect)
|
||||
selectedItem = null;
|
||||
else
|
||||
selectedItem = initialSelect;
|
||||
} else {
|
||||
root.rootParent.interactive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ ColumnLayout {
|
||||
function findAnchor(item: Item, anchor: string): Item {
|
||||
if (!item)
|
||||
return null;
|
||||
if (item.settingAnchor !== undefined && item.settingAnchor === anchor)
|
||||
if (item.settingAnchor !== undefined && item.settingAnchor === anchor) // qmllint disable missing-property
|
||||
return item;
|
||||
const kids = item.children;
|
||||
for (let i = 0; i < kids.length; i++) {
|
||||
@@ -43,8 +43,8 @@ ColumnLayout {
|
||||
|
||||
function highlightAnchor(anchor: string): void {
|
||||
const row = findAnchor(contentChild, anchor);
|
||||
if (row && row.flashHighlight !== undefined)
|
||||
row.flashHighlight();
|
||||
if (row && row.flashHighlight !== undefined) // qmllint disable missing-property
|
||||
row.flashHighlight(); // qmllint disable missing-property
|
||||
}
|
||||
|
||||
function scrollToAnchor(anchor: string): bool {
|
||||
@@ -61,8 +61,8 @@ ColumnLayout {
|
||||
root.animateScroll = true;
|
||||
flickable.contentY = target;
|
||||
Qt.callLater(() => root.animateScroll = false);
|
||||
if (row.flashHighlight !== undefined)
|
||||
row.flashHighlight();
|
||||
if (row.flashHighlight !== undefined) // qmllint disable missing-property
|
||||
row.flashHighlight(); // qmllint disable missing-property
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -154,11 +154,20 @@ ColumnLayout {
|
||||
Layout.fillHeight: true
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: -topMargin
|
||||
|
||||
bottomMargin: Tokens.padding.extraLarge
|
||||
topMargin: Tokens.padding.large
|
||||
fadeAmount: 0.1
|
||||
|
||||
contentHeight: root.contentChild?.implicitHeight ?? 0
|
||||
contentItem.children: [root.contentChild]
|
||||
fadeAmount: 0.1
|
||||
topMargin: Tokens.padding.large
|
||||
|
||||
rebound: Transition {
|
||||
Anim {
|
||||
properties: "x,y"
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on contentY {
|
||||
enabled: root.animateScroll
|
||||
|
||||
@@ -17,6 +17,7 @@ ConnectedRect {
|
||||
property alias subtext: subLabel.text
|
||||
property alias text: label.text
|
||||
property string trailingIcon
|
||||
property string activeItem
|
||||
|
||||
signal clicked(event: MouseEvent)
|
||||
|
||||
@@ -40,15 +41,16 @@ ConnectedRect {
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
id: iconLabel
|
||||
|
||||
color: Colors.palette.m3onSurfaceVariant
|
||||
visible: root.icon !== ""
|
||||
fill: 1
|
||||
text: root.icon
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
}
|
||||
|
||||
@@ -79,6 +81,26 @@ ConnectedRect {
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
active: root.activeItem && root.subtext
|
||||
asynchronous: true
|
||||
visible: active
|
||||
Layout.fillHeight: true
|
||||
|
||||
sourceComponent: CustomRect {
|
||||
color: Colors.palette.m3secondaryContainer
|
||||
radius: Tokens.rounding.full
|
||||
implicitWidth: text.implicitWidth + Tokens.padding.medium * 2
|
||||
|
||||
CustomText {
|
||||
id: text
|
||||
anchors.centerIn: parent
|
||||
color: Colors.palette.m3onSecondaryContainer
|
||||
text: root.activeItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
active: root.trailingIcon
|
||||
asynchronous: true
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
DialogRowButton {
|
||||
id: root
|
||||
|
||||
required property var model
|
||||
property var selectedItem
|
||||
property var initialSelect
|
||||
|
||||
acceptAllowed: !!selectedItem
|
||||
horizontalContentMargin: -Tokens.padding.extraSmall
|
||||
separateContent: true
|
||||
|
||||
content: Component {
|
||||
ColumnLayout {
|
||||
VerticalFadeListView {
|
||||
bottomMargin: Tokens.padding.large
|
||||
Layout.fillHeight: true
|
||||
Layout.fillWidth: true
|
||||
model: ScriptModel {
|
||||
values: {
|
||||
const s = searchField.text;
|
||||
var regex = new RegExp(s, "i");
|
||||
|
||||
return root.model.filter(n => regex.test(n));
|
||||
}
|
||||
}
|
||||
spacing: 0
|
||||
topMargin: Tokens.padding.large
|
||||
|
||||
delegate: CustomRect {
|
||||
id: item
|
||||
|
||||
required property var modelData
|
||||
readonly property bool selected: root.selectedItem === modelData
|
||||
|
||||
anchors.left: ListView.view.contentItem.left
|
||||
anchors.margins: 1 // Gets cut off for some reason without this
|
||||
anchors.right: ListView.view.contentItem.right
|
||||
color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0)
|
||||
implicitHeight: label.implicitHeight + Tokens.padding.small * 2
|
||||
radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium
|
||||
|
||||
Behavior on radius {
|
||||
Anim {
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
id: stateLayer
|
||||
|
||||
onClicked: root.selectedItem = item.modelData
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: label
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.large
|
||||
anchors.right: item.selected ? checkIcon.left : parent.right
|
||||
anchors.rightMargin: item.selected ? Tokens.spacing.medium : anchors.margins
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: item.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
|
||||
elide: Text.ElideRight
|
||||
font.pointSize: Tokens.font.size.smaller
|
||||
text: item.modelData
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
id: checkIcon
|
||||
|
||||
anchors.margins: Tokens.padding.large
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: Colors.palette.m3onTertiaryContainer
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
opacity: item.selected ? 1 : 0
|
||||
text: "check"
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SearchBar {
|
||||
id: searchField
|
||||
|
||||
Layout.fillWidth: true
|
||||
bg.border.color: Colors.palette.m3outlineVariant
|
||||
bg.color: Colors.tPalette.m3surfaceContainerLowest
|
||||
clearIcon.font.pointSize: Tokens.font.size.large
|
||||
clearIcon.padding: Tokens.padding.extraSmall
|
||||
font.pointSize: Tokens.font.size.small
|
||||
placeholderText: qsTr("Search...")
|
||||
searchIcon.anchors.leftMargin: Tokens.padding.large
|
||||
searchIcon.font.pointSize: Tokens.font.size.large
|
||||
|
||||
Behavior on bg.border.color {
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onOpenChanged: {
|
||||
if (open) {
|
||||
root.rootParent.interactive = false;
|
||||
if (!initialSelect)
|
||||
selectedItem = null;
|
||||
else
|
||||
selectedItem = initialSelect;
|
||||
} else {
|
||||
root.rootParent.interactive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Blobs
|
||||
import qs.Components
|
||||
import ZShell.Config
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool acceptAllowed: true
|
||||
required property string acceptLabel
|
||||
required property Component content
|
||||
required property string header
|
||||
property int horizontalContentMargin
|
||||
property string icon
|
||||
required property string label
|
||||
property alias iconLabel: openButton.iconText
|
||||
property alias rowButton: openButton
|
||||
property alias contentItem: dialogContent.item
|
||||
property string subtext: ""
|
||||
property bool open
|
||||
property real openHeight: Math.min(rootParent.height * 0.8, 400)
|
||||
property real openWidth: Math.min(rootParent.width * 0.8, 600)
|
||||
required property Item rootParent
|
||||
property bool first: false
|
||||
property bool last: false
|
||||
required property string settingAnchor
|
||||
property color openColor: Colors.palette.m3surfaceContainerHighest
|
||||
|
||||
signal accepted
|
||||
signal cancelled
|
||||
|
||||
function reparentWrapper(): void {
|
||||
const newParent = open ? rootParent : root;
|
||||
const pos = dialogWrapper.mapToItem(newParent, 0, 0);
|
||||
dialogWrapper.parent = newParent;
|
||||
dialogWrapper.x = pos.x;
|
||||
dialogWrapper.y = pos.y;
|
||||
}
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: openButton.implicitHeight
|
||||
z: open || dialogTransition.running ? 2 : 0
|
||||
|
||||
BlobGroup {
|
||||
id: blobGroup
|
||||
|
||||
color: root.open ? root.openColor : Colors.tPalette.m3surfaceContainer
|
||||
|
||||
Behavior on color {
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: backdrop
|
||||
|
||||
anchors.fill: parent
|
||||
enabled: false
|
||||
hoverEnabled: enabled
|
||||
preventStealing: true
|
||||
parent: root.open ? root.rootParent : root
|
||||
|
||||
onClicked: root.open = false
|
||||
}
|
||||
|
||||
Item {
|
||||
id: dialogWrapper
|
||||
|
||||
height: openButton.implicitHeight
|
||||
width: root.width
|
||||
z: 1
|
||||
|
||||
states: State {
|
||||
name: "open"
|
||||
when: root.open
|
||||
|
||||
PropertyChanges {
|
||||
backdrop.enabled: true
|
||||
dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased
|
||||
dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased
|
||||
dialogBg.topRightRadius: Tokens.rounding.largeIncreased
|
||||
dialogBg.topLeftRadius: Tokens.rounding.largeIncreased
|
||||
dialogContent.opacity: 1
|
||||
dialogWrapper.height: root.openHeight
|
||||
dialogWrapper.width: root.openWidth
|
||||
dialogWrapper.x: (root.rootParent.width - root.openWidth) / 2
|
||||
dialogWrapper.y: (root.rootParent.height - root.openHeight) / 2
|
||||
elevation.opacity: 1
|
||||
openButton.opacity: 0
|
||||
}
|
||||
}
|
||||
transitions: Transition {
|
||||
id: dialogTransition
|
||||
|
||||
SequentialAnimation {
|
||||
ScriptAction {
|
||||
script: root.reparentWrapper()
|
||||
}
|
||||
|
||||
Anim {
|
||||
properties: "x,y"
|
||||
}
|
||||
}
|
||||
|
||||
PropertyAction {
|
||||
property: "enabled"
|
||||
}
|
||||
|
||||
Anim {
|
||||
properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
|
||||
Anim {
|
||||
properties: "width,height"
|
||||
}
|
||||
}
|
||||
|
||||
Elevation {
|
||||
id: elevation
|
||||
|
||||
anchors.fill: parent
|
||||
bottomLeftRadius: dialogBg.bottomLeftRadius
|
||||
bottomRightRadius: dialogBg.bottomRightRadius
|
||||
level: 4
|
||||
opacity: 0
|
||||
radius: dialogBg.radius
|
||||
|
||||
transform: Matrix4x4 {
|
||||
matrix: dialogBg.deformMatrix
|
||||
}
|
||||
}
|
||||
|
||||
BlobRect {
|
||||
id: dialogBg
|
||||
|
||||
anchors.fill: parent
|
||||
bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
bottomRightRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
deformScale: 0
|
||||
group: blobGroup
|
||||
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
|
||||
}
|
||||
|
||||
TimeDialogToggle {
|
||||
id: openButton
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
color: "transparent"
|
||||
height: Math.min(implicitHeight, parent.height)
|
||||
subtext: root.subtext
|
||||
last: root.last
|
||||
first: root.first
|
||||
text: root.label
|
||||
|
||||
transform: Matrix4x4 {
|
||||
matrix: dialogBg.deformMatrix
|
||||
}
|
||||
|
||||
onClicked: root.open = true
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: dialogContent
|
||||
|
||||
active: opacity > 0
|
||||
anchors.fill: parent
|
||||
asynchronous: false
|
||||
opacity: 0
|
||||
|
||||
sourceComponent: MouseArea {
|
||||
onWheel: event => event.accepted = true
|
||||
preventStealing: true
|
||||
clip: true
|
||||
implicitWidth: innerLayout.implicitWidth + innerLayout.anchors.margins * 2
|
||||
implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins + innerLayout.anchors.bottomMargin
|
||||
|
||||
ColumnLayout {
|
||||
id: innerLayout
|
||||
anchors.bottomMargin: Tokens.padding.largeIncreased
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.extraLarge
|
||||
spacing: 0
|
||||
|
||||
CustomText {
|
||||
font.pointSize: Tokens.font.size.large
|
||||
text: root.header
|
||||
}
|
||||
|
||||
Loader {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: root.horizontalContentMargin
|
||||
Layout.topMargin: Tokens.spacing.medium
|
||||
Layout.bottomMargin: Tokens.spacing.medium
|
||||
Layout.rightMargin: root.horizontalContentMargin
|
||||
sourceComponent: root.content
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.alignment: Qt.AlignRight
|
||||
spacing: Tokens.spacing.extraSmall
|
||||
|
||||
TextButton {
|
||||
horizontalPadding: Tokens.padding.largeIncreased
|
||||
isRound: true
|
||||
text: qsTr("Cancel")
|
||||
type: TextButton.Text
|
||||
verticalPadding: Tokens.padding.small
|
||||
stateLayer.preventStealing: true
|
||||
|
||||
onClicked: {
|
||||
root.cancelled();
|
||||
root.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
TextButton {
|
||||
enabled: root.acceptAllowed
|
||||
horizontalPadding: Tokens.padding.largeIncreased
|
||||
isRound: true
|
||||
text: root.acceptLabel
|
||||
type: TextButton.Text
|
||||
stateLayer.preventStealing: true
|
||||
verticalPadding: Tokens.padding.small
|
||||
|
||||
onClicked: {
|
||||
root.accepted();
|
||||
root.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
transform: Matrix4x4 {
|
||||
matrix: dialogBg.deformMatrix
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import qs.Helpers
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
TimeDialogRow {
|
||||
id: root
|
||||
|
||||
required property int start
|
||||
required property int end
|
||||
property int endCurrent
|
||||
property int startCurrent
|
||||
openColor: Colors.palette.m3surfaceContainer
|
||||
openHeight: Math.min(rootParent.height * 0.8, contentItem?.implicitHeight ?? 0)
|
||||
openWidth: Math.min(rootParent.width * 0.8, contentItem?.implicitWidth ?? 0)
|
||||
|
||||
function keyFor(item: var): string {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function labelFor(item: var): string {
|
||||
return item.label;
|
||||
}
|
||||
|
||||
horizontalContentMargin: -Tokens.padding.extraSmall
|
||||
|
||||
content: Component {
|
||||
TimeInput {
|
||||
id: timeContent
|
||||
start: root.start
|
||||
end: root.end
|
||||
|
||||
onEndEdit: value => root.endCurrent = value
|
||||
onStartEdit: value => root.startCurrent = value
|
||||
}
|
||||
}
|
||||
|
||||
onOpenChanged: {
|
||||
if (open) {
|
||||
root.rootParent.interactive = false;
|
||||
root.z = 5;
|
||||
} else {
|
||||
root.rootParent.interactive = true;
|
||||
root.z = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -10,12 +10,14 @@ ConnectedRect {
|
||||
|
||||
property alias checked: switchButton.checked
|
||||
property int horizontalPadding: Tokens.padding.largeIncreased
|
||||
required property Component popup
|
||||
property alias subtext: subtext.text
|
||||
property alias text: text.text
|
||||
property alias icon: icon
|
||||
property string iconText: "open_in_new"
|
||||
property int verticalPadding: Tokens.padding.small
|
||||
|
||||
signal clicked(checked: bool)
|
||||
signal clicked(event: MouseEvent)
|
||||
signal toggled(checked: bool)
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: layout.implicitHeight + verticalPadding * 2
|
||||
@@ -50,11 +52,11 @@ ConnectedRect {
|
||||
anchors.right: switchButton.left
|
||||
anchors.rightMargin: Tokens.spacing.medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "open_in_new"
|
||||
text: root.iconText
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
onClicked: PopupManager.requestOpen(root.popup)
|
||||
onClicked: e => root.clicked(e)
|
||||
}
|
||||
|
||||
CustomSwitch {
|
||||
@@ -64,6 +66,6 @@ ConnectedRect {
|
||||
anchors.rightMargin: root.horizontalPadding
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
onClicked: root.clicked(checked)
|
||||
onClicked: root.toggled(checked)
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,13 @@ import qs.Components
|
||||
import qs.Helpers
|
||||
import qs.Services
|
||||
|
||||
CustomClippingRect {
|
||||
ColumnLayout {
|
||||
id: root
|
||||
|
||||
required property var object
|
||||
required property list<string> settings
|
||||
property bool shouldBeActive: true
|
||||
|
||||
signal applySettings(startTime: int, endTime: int)
|
||||
signal close
|
||||
required property int start
|
||||
required property int end
|
||||
signal endEdit(time: int)
|
||||
signal startEdit(time: int)
|
||||
|
||||
function convertHour(timeValue: int): int {
|
||||
return Math.floor(timeValue / 60);
|
||||
@@ -28,24 +26,8 @@ CustomClippingRect {
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
color: Colors.palette.m3surfaceContainer
|
||||
implicitHeight: column.implicitHeight + column.anchors.margins * 2 + buttonRow.implicitHeight + buttonRow.anchors.topMargin
|
||||
implicitWidth: column.implicitWidth + column.anchors.margins * 2
|
||||
radius: Tokens.rounding.largeIncreased
|
||||
|
||||
ColumnLayout {
|
||||
id: column
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.largeIncreased
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
CustomText {
|
||||
text: qsTr("Select time")
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
CustomRect {
|
||||
id: startHourRect
|
||||
@@ -64,16 +46,15 @@ CustomClippingRect {
|
||||
radius: parent.radius - border.width
|
||||
|
||||
Behavior on border.width {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldBase {
|
||||
id: startHourField
|
||||
|
||||
function setConfigText(setting: string): string {
|
||||
var val = root.convertHour(root.object[setting]);
|
||||
function setConfigText(): string {
|
||||
var val = root.convertHour(root.start);
|
||||
if (val === 0) {
|
||||
return "00";
|
||||
}
|
||||
@@ -90,7 +71,7 @@ CustomClippingRect {
|
||||
font.pixelSize: 56
|
||||
font.weight: 400
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
text: setConfigText(root.settings[1])
|
||||
text: setConfigText()
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
Keys.onPressed: event => {
|
||||
@@ -104,7 +85,7 @@ CustomClippingRect {
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
event.accepted = true;
|
||||
startHourField.text = setConfigText(root.settings[1]);
|
||||
startHourField.text = setConfigText();
|
||||
startHourField.focus = false;
|
||||
} else if (event.key === Qt.Key_Return) {
|
||||
startHourField.focus = false;
|
||||
@@ -145,16 +126,10 @@ CustomClippingRect {
|
||||
event.accepted = true;
|
||||
}
|
||||
onCursorPositionChanged: cursorPosition = 2
|
||||
onTextEdited: {
|
||||
if (startHourField.text === "")
|
||||
return;
|
||||
var val = parseInt(startHourField.text);
|
||||
if (isNaN(val))
|
||||
return;
|
||||
val = Math.max(0, Math.min(23, val));
|
||||
var newText = val.toString();
|
||||
if (newText !== startHourField.text)
|
||||
startHourField.text = newText;
|
||||
onTextChanged: {
|
||||
var mins = parseInt(startMinuteField.text);
|
||||
const hours = root.convertToMinutes(parseInt(startHourField.text));
|
||||
root.startEdit(hours + mins);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,16 +158,15 @@ CustomClippingRect {
|
||||
radius: parent.radius - border.width
|
||||
|
||||
Behavior on border.width {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldBase {
|
||||
id: startMinuteField
|
||||
|
||||
function setConfigText(setting: string): string {
|
||||
var val = root.convertMinute(root.object[setting]);
|
||||
function setConfigText(): string {
|
||||
var val = root.convertMinute(root.start);
|
||||
if (val === 0) {
|
||||
return "00";
|
||||
}
|
||||
@@ -209,7 +183,7 @@ CustomClippingRect {
|
||||
font.pixelSize: 56
|
||||
font.weight: 400
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
text: setConfigText(root.settings[1])
|
||||
text: setConfigText()
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
Keys.onPressed: event => {
|
||||
@@ -223,7 +197,7 @@ CustomClippingRect {
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
event.accepted = true;
|
||||
startMinuteField.text = setConfigText(root.settings[1]);
|
||||
startMinuteField.text = setConfigText();
|
||||
startMinuteField.focus = false;
|
||||
} else if (event.key === Qt.Key_Return) {
|
||||
startMinuteField.focus = false;
|
||||
@@ -264,16 +238,10 @@ CustomClippingRect {
|
||||
event.accepted = true;
|
||||
}
|
||||
onCursorPositionChanged: cursorPosition = 2
|
||||
onTextEdited: {
|
||||
if (startMinuteField.text === "")
|
||||
return;
|
||||
var val = parseInt(startMinuteField.text);
|
||||
if (isNaN(val))
|
||||
return;
|
||||
val = Math.max(0, Math.min(23, val));
|
||||
var newText = val.toString();
|
||||
if (newText !== startMinuteField.text)
|
||||
startMinuteField.text = newText;
|
||||
onTextChanged: {
|
||||
var mins = parseInt(startMinuteField.text);
|
||||
const hours = root.convertToMinutes(parseInt(startHourField.text));
|
||||
root.startEdit(hours + mins);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,16 +269,15 @@ CustomClippingRect {
|
||||
radius: parent.radius - border.width
|
||||
|
||||
Behavior on border.width {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldBase {
|
||||
id: endHourField
|
||||
|
||||
function setConfigText(setting: string): string {
|
||||
var val = root.convertHour(root.object[setting]);
|
||||
function setConfigText(): string {
|
||||
var val = root.convertHour(root.end);
|
||||
if (val === 0) {
|
||||
return "00";
|
||||
}
|
||||
@@ -327,7 +294,7 @@ CustomClippingRect {
|
||||
font.pixelSize: 56
|
||||
font.weight: 400
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
text: setConfigText(root.settings[2])
|
||||
text: setConfigText()
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
Keys.onPressed: event => {
|
||||
@@ -341,7 +308,7 @@ CustomClippingRect {
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
event.accepted = true;
|
||||
endHourField.text = setConfigText(root.settings[2]);
|
||||
endHourField.text = setConfigText();
|
||||
endHourField.focus = false;
|
||||
} else if (event.key === Qt.Key_Return) {
|
||||
endHourField.focus = false;
|
||||
@@ -382,16 +349,10 @@ CustomClippingRect {
|
||||
event.accepted = true;
|
||||
}
|
||||
onCursorPositionChanged: cursorPosition = 2
|
||||
onTextEdited: {
|
||||
if (endHourField.text === "")
|
||||
return;
|
||||
var val = parseInt(endHourField.text);
|
||||
if (isNaN(val))
|
||||
return;
|
||||
val = Math.max(0, Math.min(23, val));
|
||||
var newText = val.toString();
|
||||
if (newText !== endHourField.text)
|
||||
endHourField.text = newText;
|
||||
onTextChanged: {
|
||||
var mins = parseInt(endMinuteField.text);
|
||||
const hours = root.convertToMinutes(parseInt(endHourField.text));
|
||||
root.endEdit(hours + mins);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -420,16 +381,15 @@ CustomClippingRect {
|
||||
radius: parent.radius - border.width
|
||||
|
||||
Behavior on border.width {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldBase {
|
||||
id: endMinuteField
|
||||
|
||||
function setConfigText(setting: string): string {
|
||||
var val = root.convertMinute(root.object[setting]);
|
||||
function setConfigText(): string {
|
||||
var val = root.convertMinute(root.end);
|
||||
if (val === 0) {
|
||||
return "00";
|
||||
}
|
||||
@@ -446,7 +406,7 @@ CustomClippingRect {
|
||||
font.pixelSize: 56
|
||||
font.weight: 400
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
text: setConfigText(root.settings[2])
|
||||
text: setConfigText()
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
Keys.onPressed: event => {
|
||||
@@ -460,7 +420,7 @@ CustomClippingRect {
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
event.accepted = true;
|
||||
endMinuteField.text = setConfigText(root.settings[2]);
|
||||
endMinuteField.text = setConfigText();
|
||||
endMinuteField.focus = false;
|
||||
} else if (event.key === Qt.Key_Return) {
|
||||
endMinuteField.focus = false;
|
||||
@@ -501,16 +461,10 @@ CustomClippingRect {
|
||||
event.accepted = true;
|
||||
}
|
||||
onCursorPositionChanged: cursorPosition = 2
|
||||
onTextEdited: {
|
||||
if (endMinuteField.text === "")
|
||||
return;
|
||||
var val = parseInt(endMinuteField.text);
|
||||
if (isNaN(val))
|
||||
return;
|
||||
val = Math.max(0, Math.min(23, val));
|
||||
var newText = val.toString();
|
||||
if (newText !== endMinuteField.text)
|
||||
endMinuteField.text = newText;
|
||||
onTextChanged: {
|
||||
var mins = parseInt(endMinuteField.text);
|
||||
const hours = root.convertToMinutes(parseInt(endHourField.text));
|
||||
root.endEdit(hours + mins);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -528,55 +482,3 @@ CustomClippingRect {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: buttonRow
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.largeIncreased
|
||||
anchors.right: parent.right
|
||||
anchors.top: column.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
|
||||
Item {
|
||||
id: buttonSpacer
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
ButtonRow {
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
IconTextButton {
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
icon: "close"
|
||||
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2)
|
||||
inactiveOnColor: Colors.palette.m3onSurfaceVariant
|
||||
isRound: true
|
||||
isToggle: false
|
||||
shapeMorph: true
|
||||
shapeMorphExpansion: pressed ? 12 : 0
|
||||
text: "Cancel"
|
||||
|
||||
onClicked: root.close()
|
||||
}
|
||||
|
||||
IconTextButton {
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
icon: "check"
|
||||
isRound: true
|
||||
isToggle: false
|
||||
shapeMorph: true
|
||||
shapeMorphExpansion: pressed ? 12 : 0
|
||||
text: "Apply"
|
||||
|
||||
onClicked: {
|
||||
const start = root.convertToMinutes(parseInt(startHourField.text)) + parseInt(startMinuteField.text);
|
||||
const end = root.convertToMinutes(parseInt(endHourField.text)) + parseInt(endMinuteField.text);
|
||||
root.applySettings(start, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ CustomSwitch {
|
||||
property alias last: bg.last
|
||||
property string settingAnchor
|
||||
property string subtext
|
||||
property string iconText
|
||||
|
||||
function flashHighlight(): void {
|
||||
bg.flashHighlight();
|
||||
@@ -63,8 +64,7 @@ CustomSwitch {
|
||||
text: root.text
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,9 +81,15 @@ CustomSwitch {
|
||||
visible: root.subtext
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
id: icon
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.iconText ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ CustomClippingRect {
|
||||
radius: Tokens.rounding.largeIncreased + Tokens.padding.small
|
||||
|
||||
Behavior on blobColor {
|
||||
CAnim {
|
||||
}
|
||||
CAnim {}
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
@@ -70,8 +69,4 @@ CustomClippingRect {
|
||||
anchors.top: parent.top
|
||||
sState: root.sState
|
||||
}
|
||||
|
||||
PopupOverlay {
|
||||
anchors.fill: parent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,14 +81,10 @@ VerticalFadeFlickable {
|
||||
topLeftRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
topRightRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
|
||||
RadiusBehavior on bottomLeftRadius {
|
||||
}
|
||||
RadiusBehavior on bottomRightRadius {
|
||||
}
|
||||
RadiusBehavior on topLeftRadius {
|
||||
}
|
||||
RadiusBehavior on topRightRadius {
|
||||
}
|
||||
RadiusBehavior on bottomLeftRadius {}
|
||||
RadiusBehavior on bottomRightRadius {}
|
||||
RadiusBehavior on topLeftRadius {}
|
||||
RadiusBehavior on topRightRadius {}
|
||||
|
||||
StateLayer {
|
||||
id: stateLayer
|
||||
@@ -262,14 +258,10 @@ VerticalFadeFlickable {
|
||||
topRightRadius: layer.pressed ? Tokens.rounding.largeIncreased : isFirst ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
width: cardList.width
|
||||
|
||||
RadiusBehavior on bottomLeftRadius {
|
||||
}
|
||||
RadiusBehavior on bottomRightRadius {
|
||||
}
|
||||
RadiusBehavior on topLeftRadius {
|
||||
}
|
||||
RadiusBehavior on topRightRadius {
|
||||
}
|
||||
RadiusBehavior on bottomLeftRadius {}
|
||||
RadiusBehavior on bottomRightRadius {}
|
||||
RadiusBehavior on topLeftRadius {}
|
||||
RadiusBehavior on topRightRadius {}
|
||||
|
||||
ColumnLayout {
|
||||
id: resultLayout
|
||||
@@ -319,7 +311,8 @@ VerticalFadeFlickable {
|
||||
z: 1
|
||||
|
||||
onClicked: {
|
||||
root.sState.jumpToSetting(result.modelData.pageIdx, result.modelData.subPath, result.modelData.anchor);
|
||||
const target = result.modelData.targetSubPath;
|
||||
root.sState.jumpToSetting(result.modelData.pageIdx, target.length > 0 ? target : result.modelData.subPath, target.length > 0 ? "" : result.modelData.anchor);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ QtObject {
|
||||
Component {
|
||||
WallpaperSelect {}
|
||||
}
|
||||
|
||||
Component {
|
||||
ColorsFonts {}
|
||||
}
|
||||
}
|
||||
},
|
||||
Component {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import QtQuick
|
||||
import qs.Components
|
||||
import ZShell.Config
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -41,6 +42,18 @@ Item {
|
||||
objectName: "PageContainer"
|
||||
|
||||
Component.onCompleted: root.loadPage(root.sState.currentPageIdx)
|
||||
|
||||
// CustomRect {
|
||||
// anchors.fill: parent
|
||||
// color: Qt.alpha(Colors.palette.m3shadow, 0.3)
|
||||
// opacity: root.sState.dimmed ? 1 : 0
|
||||
// visible: opacity > 0
|
||||
// z: 0
|
||||
//
|
||||
// Behavior on opacity {
|
||||
// Anim {}
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
Connections {
|
||||
|
||||
@@ -55,6 +55,10 @@ PageBase {
|
||||
enabled: Object.keys(model).length > 0
|
||||
header: qsTr("Add new entry")
|
||||
icon: "add"
|
||||
settingAnchor: "panels-bar-status-icons-add-item"
|
||||
onOpenChanged: {
|
||||
root.sState.dimmed = open;
|
||||
}
|
||||
last: true
|
||||
label: qsTr("Add entry")
|
||||
model: {
|
||||
|
||||
@@ -67,6 +67,20 @@ PageBase {
|
||||
|
||||
SectionHeader {
|
||||
first: true
|
||||
text: qsTr("General")
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
settingAnchor: "panels-sidebar-llm-enabled"
|
||||
checked: Config.llm.enabled
|
||||
text: qsTr("Enabled")
|
||||
first: true
|
||||
last: true
|
||||
|
||||
onToggled: Config.llm.enabled = checked
|
||||
}
|
||||
|
||||
SectionHeader {
|
||||
text: qsTr("Appearance")
|
||||
}
|
||||
|
||||
@@ -75,15 +89,25 @@ PageBase {
|
||||
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: Object.keys(model).length > 0
|
||||
settingAnchor: "panels-sidebar-select-scheme"
|
||||
separateContent: false
|
||||
header: qsTr("Select scheme")
|
||||
first: true
|
||||
last: true
|
||||
icon: "add"
|
||||
icon: "format_paint"
|
||||
iconLabel.font.pointSize: Tokens.font.size.large
|
||||
rowButton.trailingIcon: "open_in_new"
|
||||
onOpenChanged: {
|
||||
root.sState.dimmed = open;
|
||||
}
|
||||
rowButton.activeItem: root.schemes.find(s => s.id === Config.llm.appearance.scheme).label
|
||||
label: qsTr("Color scheme")
|
||||
subtext: qsTr("Select the color scheme used for code blocks")
|
||||
model: root.schemes
|
||||
rootParent: root.flickable
|
||||
|
||||
initialSelect: Config.llm.appearance.scheme
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
@@ -88,6 +88,10 @@ PageBase {
|
||||
enabled: Object.keys(model).length > 0
|
||||
header: qsTr("Add new entry")
|
||||
icon: "add"
|
||||
onOpenChanged: {
|
||||
root.sState.dimmed = open;
|
||||
}
|
||||
settingAnchor: "panels-sidebar-quick-toggles-add-item"
|
||||
label: qsTr("Add entry")
|
||||
last: true
|
||||
model: {
|
||||
@@ -116,6 +120,7 @@ PageBase {
|
||||
|
||||
NavRow {
|
||||
text: qsTr("AI chat")
|
||||
settingAnchor: "panels-sidebar-llm"
|
||||
icon: "robot_2"
|
||||
first: true
|
||||
last: true
|
||||
|
||||
@@ -202,12 +202,12 @@ PageBase {
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
checked: Config.general.color.smart
|
||||
checked: Config.colors.smart
|
||||
settingAnchor: "services-smart-color-scheme"
|
||||
subtext: qsTr("Derive theme mode from the wallpaper")
|
||||
text: qsTr("Smart color scheme")
|
||||
|
||||
onToggled: Config.general.color.smart = checked
|
||||
onToggled: Config.colors.smart = checked
|
||||
}
|
||||
|
||||
SelectRow {
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Config
|
||||
import qs.Paths
|
||||
import qs.Services
|
||||
import qs.Modules.Settings.Common
|
||||
|
||||
PageBase {
|
||||
id: root
|
||||
|
||||
readonly property list<string> fonts: Qt.fontFamilies()
|
||||
readonly property var schemes: [
|
||||
{
|
||||
id: "onedark",
|
||||
label: qsTr("One Dark")
|
||||
},
|
||||
{
|
||||
id: "nord",
|
||||
label: qsTr("Nord")
|
||||
},
|
||||
{
|
||||
id: "dracula",
|
||||
label: qsTr("Dracula")
|
||||
},
|
||||
{
|
||||
id: "darkgreen",
|
||||
label: qsTr("Darkgreen")
|
||||
},
|
||||
{
|
||||
id: "solarized",
|
||||
label: qsTr("Solarized")
|
||||
},
|
||||
{
|
||||
id: "rosepine",
|
||||
label: qsTr("Rosepine")
|
||||
},
|
||||
{
|
||||
id: "gruvbox",
|
||||
label: qsTr("Gruvbox")
|
||||
},
|
||||
{
|
||||
id: "catppuccin",
|
||||
label: qsTr("Catppuccin")
|
||||
},
|
||||
{
|
||||
id: "tokyonight",
|
||||
label: qsTr("Tokyo Night")
|
||||
},
|
||||
{
|
||||
id: "shadotheme",
|
||||
label: qsTr("Shadotheme")
|
||||
}
|
||||
]
|
||||
|
||||
isSubPage: true
|
||||
title: qsTr("Colors & font")
|
||||
|
||||
ColumnLayout {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
spacing: Tokens.spacing.extraSmall / 2
|
||||
width: root.cappedWidth
|
||||
|
||||
SectionHeader {
|
||||
first: true
|
||||
text: qsTr("Colors")
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
text: qsTr("Dynamic colors")
|
||||
first: true
|
||||
subtext: qsTr("Automatic color scheme based on wallpaper is %1").arg(Config.colors.schemeGen ? "enabled" : "disabled")
|
||||
checked: Config.colors.schemeGen
|
||||
settingAnchor: "colors-fonts-dynamic-colors"
|
||||
|
||||
onToggled: {
|
||||
Config.colors.schemeGen = checked;
|
||||
|
||||
if (checked)
|
||||
Colors.setMode(Config.colors.mode);
|
||||
}
|
||||
}
|
||||
|
||||
DialogSelectButton {
|
||||
settingAnchor: "colors-fonts-preset-scheme"
|
||||
model: root.schemes
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: !Config.colors.schemeGen
|
||||
header: qsTr("Select scheme")
|
||||
rowButton.activeItem: root.schemes.find(s => {
|
||||
var regex = new RegExp(Config.colors.presets.name, "i");
|
||||
return regex.test(s.id);
|
||||
}).label ?? ""
|
||||
label: qsTr("Preset scheme")
|
||||
subtext: qsTr("Select preset color scheme")
|
||||
rootParent: root.flickable
|
||||
|
||||
FileView {
|
||||
id: view
|
||||
path: `${Paths.state}/presets.json`
|
||||
|
||||
watchChanges: true
|
||||
onFileChanged: reload()
|
||||
|
||||
JsonAdapter {
|
||||
id: adapter
|
||||
|
||||
property var presets
|
||||
}
|
||||
}
|
||||
|
||||
initialSelect: Config.colors.presets.name.toLowerCase()
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
const s = selectedItem.toLowerCase();
|
||||
let v = "";
|
||||
let a = "";
|
||||
const existingVariant = Config.colors.presets.variant;
|
||||
const existingAccent = Config.colors.presets.accent;
|
||||
const variantKeys = Object.keys(adapter.presets[s].variants);
|
||||
|
||||
if (existingVariant !== "" && variantKeys.some(v => v === existingVariant))
|
||||
v = existingVariant;
|
||||
|
||||
if (v !== "" && existingAccent !== "" && adapter.presets[s].variants[v]?.accents.some(a => a === existingAccent))
|
||||
a = existingAccent;
|
||||
|
||||
if (a !== "")
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]);
|
||||
else if (v !== "")
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`]);
|
||||
else
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${variantKeys[0]}`]);
|
||||
}
|
||||
}
|
||||
|
||||
DialogSelectButton {
|
||||
settingAnchor: "colors-fonts-preset-variant"
|
||||
// qmlformat off
|
||||
model: {
|
||||
const v = adapter.presets[Config.colors.presets.name].variants;
|
||||
const map = Object.keys(v).map(v => ({
|
||||
id: v,
|
||||
label: qsTr("%1%2")
|
||||
.arg(v.charAt(0).toUpperCase())
|
||||
.arg(v.slice(1).toLowerCase())
|
||||
}));
|
||||
|
||||
return map;
|
||||
}
|
||||
// qmlformat on
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: !Config.colors.schemeGen && model.length > 1
|
||||
header: qsTr("Select variant")
|
||||
rowButton.activeItem: {
|
||||
const v = Config.colors.presets.variant ?? "";
|
||||
if (v === "")
|
||||
return "";
|
||||
return qsTr("%1%2").arg(v.charAt(0).toUpperCase()).arg(v.slice(1).toLowerCase());
|
||||
}
|
||||
label: qsTr("Variant")
|
||||
subtext: qsTr("Select preset variant")
|
||||
rootParent: root.flickable
|
||||
|
||||
initialSelect: Config.colors.presets.variant
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
const v = selectedItem.toLowerCase();
|
||||
const s = Config.colors.presets.name;
|
||||
const existingAccent = Config.colors.presets.accent;
|
||||
let a = "";
|
||||
|
||||
if (existingAccent !== "" && adapter.presets[s].variants[v]?.accents.some(a => a === existingAccent))
|
||||
a = existingAccent;
|
||||
|
||||
if (a !== "")
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]);
|
||||
else
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`]);
|
||||
}
|
||||
}
|
||||
|
||||
DialogSelectButton {
|
||||
settingAnchor: "colors-fonts-preset-accent"
|
||||
// qmlformat off
|
||||
model: {
|
||||
const a = adapter.presets[Config.colors.presets.name].variants[Config.colors.presets.variant]?.accents ?? [];
|
||||
|
||||
if (a.length === 0)
|
||||
return a;
|
||||
|
||||
const map = a.map(a => ({
|
||||
id: a,
|
||||
label: qsTr("%1%2")
|
||||
.arg(a.charAt(0).toUpperCase())
|
||||
.arg(a.slice(1).toLowerCase())
|
||||
}));
|
||||
|
||||
return map;
|
||||
}
|
||||
// qmlformat on
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: !Config.colors.schemeGen && model.length > 1
|
||||
header: qsTr("Select accent")
|
||||
rowButton.activeItem: {
|
||||
const a = Config.colors.presets.accent ?? "";
|
||||
if (a === "")
|
||||
return "";
|
||||
return qsTr("%1%2").arg(a.charAt(0).toUpperCase()).arg(a.slice(1).toLowerCase());
|
||||
}
|
||||
label: qsTr("Accent")
|
||||
last: true
|
||||
subtext: qsTr("Select preset accent")
|
||||
rootParent: root.flickable
|
||||
|
||||
initialSelect: Config.colors.presets.accent
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
const a = selectedItem.toLowerCase();
|
||||
const v = Config.colors.presets.variant;
|
||||
const s = Config.colors.presets.name;
|
||||
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]);
|
||||
}
|
||||
}
|
||||
|
||||
SectionHeader {
|
||||
text: qsTr("Fonts")
|
||||
}
|
||||
|
||||
SearchPopup {
|
||||
id: selectSansFont
|
||||
|
||||
settingAnchor: "colors-fonts-sans-font"
|
||||
model: root.fonts
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: Object.keys(model).length > 0
|
||||
separateContent: false
|
||||
header: qsTr("Select font")
|
||||
first: true
|
||||
rowButton.activeItem: root.fonts.find(s => {
|
||||
var regex = new RegExp(Config.appearance.font.family.sans, "i");
|
||||
return regex.test(s);
|
||||
}) ?? ""
|
||||
label: qsTr("Sans font")
|
||||
subtext: qsTr("Select desired sans font")
|
||||
rootParent: root.flickable
|
||||
|
||||
initialSelect: Config.appearance.font.family.sans
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
Config.appearance.font.family.sans = selectedItem;
|
||||
}
|
||||
}
|
||||
|
||||
SearchPopup {
|
||||
id: selectMonoFont
|
||||
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: Object.keys(model).length > 0
|
||||
settingAnchor: "colors-fonts-mono-font"
|
||||
separateContent: false
|
||||
header: qsTr("Select font")
|
||||
rowButton.activeItem: root.fonts.find(s => {
|
||||
var regex = new RegExp(Config.appearance.font.family.mono, "i");
|
||||
return regex.test(s);
|
||||
}) ?? ""
|
||||
label: qsTr("Mono font")
|
||||
subtext: qsTr("Select desired mono font")
|
||||
model: root.fonts
|
||||
rootParent: root.flickable
|
||||
|
||||
initialSelect: Config.appearance.font.family.mono
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
Config.appearance.font.family.mono = selectedItem;
|
||||
}
|
||||
}
|
||||
|
||||
SearchPopup {
|
||||
id: selectClockFont
|
||||
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: Object.keys(model).length > 0
|
||||
separateContent: false
|
||||
settingAnchor: "colors-fonts-clock-font"
|
||||
header: qsTr("Select font")
|
||||
last: true
|
||||
rowButton.activeItem: root.fonts.find(s => {
|
||||
var regex = new RegExp(Config.appearance.font.family.clock, "i");
|
||||
return regex.test(s);
|
||||
}) ?? ""
|
||||
label: qsTr("Clock font")
|
||||
subtext: qsTr("Select desired clock font")
|
||||
model: root.fonts
|
||||
rootParent: root.flickable
|
||||
|
||||
initialSelect: Config.appearance.font.family.clock
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
Config.appearance.font.family.clock = selectedItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,14 @@ PageBase {
|
||||
}
|
||||
}
|
||||
|
||||
IconTextButton {
|
||||
ButtonRow {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
IconTextButton {
|
||||
|
||||
property string settingAnchor: "style-wallpapers"
|
||||
|
||||
enabled: Config.background.enabled
|
||||
horizontalPadding: Tokens.padding.extraLarge
|
||||
icon: "wallpaper"
|
||||
@@ -47,6 +53,23 @@ PageBase {
|
||||
onClicked: root.sState.openSubPage(1) // Wallpaper page
|
||||
}
|
||||
|
||||
IconTextButton {
|
||||
|
||||
property string settingAnchor: "style-colors-fonts"
|
||||
|
||||
enabled: Config.background.enabled
|
||||
horizontalPadding: Tokens.padding.extraLarge
|
||||
icon: "palette"
|
||||
isRound: true
|
||||
shapeMorph: true
|
||||
text: qsTr("Colors & font")
|
||||
type: IconTextButton.Tonal
|
||||
verticalPadding: Tokens.padding.small
|
||||
|
||||
onClicked: root.sState.openSubPage(2) // Colors & font page
|
||||
}
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
checked: Config.background.enabled
|
||||
first: true
|
||||
@@ -76,85 +99,116 @@ PageBase {
|
||||
onToggled: Colors.setMode(checked ? "dark" : "light")
|
||||
}
|
||||
|
||||
OverlayRow {
|
||||
id: darkMode
|
||||
|
||||
TimeDialogSelect {
|
||||
readonly property string endTime: {
|
||||
var d = new Date(0, 0, 0, 0, 0, 0, 0);
|
||||
d.setMinutes(Config.general.color.scheduleDarkEnd);
|
||||
d.setMinutes(Config.colors.scheduleDarkEnd);
|
||||
return Qt.formatTime(d, "hh:mm AP");
|
||||
}
|
||||
readonly property string startTime: {
|
||||
var d = new Date(0, 0, 0, 0, 0, 0, 0);
|
||||
d.setMinutes(Config.general.color.scheduleDarkStart);
|
||||
d.setMinutes(Config.colors.scheduleDarkStart);
|
||||
return Qt.formatTime(d, "hh:mm AP");
|
||||
}
|
||||
|
||||
checked: Config.general.color.scheduleDark
|
||||
rowButton.checked: Config.colors.scheduleDark
|
||||
rowButton.onToggled: Config.colors.scheduleDark = rowButton.checked
|
||||
start: Config.colors.scheduleDarkStart
|
||||
end: Config.colors.scheduleDarkEnd
|
||||
first: true
|
||||
last: true
|
||||
settingAnchor: "style-schedule-dark-mode"
|
||||
acceptLabel: qsTr("Apply")
|
||||
rootParent: root.flickable
|
||||
header: qsTr("Select time")
|
||||
subtext: qsTr("Dark mode will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime)
|
||||
text: qsTr("Schedule dark mode")
|
||||
label: qsTr("Schedule dark mode")
|
||||
|
||||
popup: Component {
|
||||
TimeInput {
|
||||
object: Config.general.color
|
||||
settings: ["scheduleDark", "scheduleDarkStart", "scheduleDarkEnd"]
|
||||
onOpenChanged: {
|
||||
root.sState.dimmed = open;
|
||||
}
|
||||
|
||||
onApplySettings: (start, end) => {
|
||||
Config.general.color.scheduleDarkStart = start;
|
||||
Config.general.color.scheduleDarkEnd = end;
|
||||
onAccepted: {
|
||||
Config.colors.scheduleDarkStart = startCurrent;
|
||||
Config.colors.scheduleDarkEnd = endCurrent;
|
||||
ModeScheduler.checkStartup();
|
||||
PopupManager.requestClose();
|
||||
}
|
||||
onClose: PopupManager.requestClose()
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: value => {
|
||||
Config.general.color.scheduleDark = value;
|
||||
}
|
||||
}
|
||||
|
||||
OverlayRow {
|
||||
id: hyprsunset
|
||||
|
||||
TimeDialogSelect {
|
||||
readonly property string endTime: {
|
||||
var d = new Date(0, 0, 0, 0, 0, 0, 0);
|
||||
d.setMinutes(Config.general.color.scheduleHyprsunsetEnd);
|
||||
d.setMinutes(Config.display.nightlight.scheduleEnd);
|
||||
return Qt.formatTime(d, "hh:mm AP");
|
||||
}
|
||||
readonly property string startTime: {
|
||||
var d = new Date(0, 0, 0, 0, 0, 0, 0);
|
||||
d.setMinutes(Config.general.color.scheduleHyprsunsetStart);
|
||||
d.setMinutes(Config.display.nightlight.scheduleStart);
|
||||
return Qt.formatTime(d, "hh:mm AP");
|
||||
}
|
||||
|
||||
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
|
||||
checked: Config.general.color.scheduleHyprsunset
|
||||
last: true
|
||||
settingAnchor: "style-schedule-hyprsunset"
|
||||
subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime)
|
||||
text: qsTr("Schedule hyprsunset")
|
||||
rowButton.checked: Config.display.nightlight.schedule
|
||||
rowButton.onToggled: Config.display.nightlight.schedule = rowButton.checked
|
||||
start: Config.display.nightlight.scheduleStart
|
||||
end: Config.display.nightlight.scheduleEnd
|
||||
first: true
|
||||
settingAnchor: "style-schedule-nightlight"
|
||||
acceptLabel: qsTr("Apply")
|
||||
rootParent: root.flickable
|
||||
header: qsTr("Select time")
|
||||
subtext: qsTr("Nightlight will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime)
|
||||
label: qsTr("Schedule nightlight")
|
||||
|
||||
popup: Component {
|
||||
TimeInput {
|
||||
object: Config.general.color
|
||||
settings: ["scheduleHyprsunset", "scheduleHyprsunsetStart", "scheduleHyprsunsetEnd"]
|
||||
onOpenChanged: {
|
||||
root.sState.dimmed = open;
|
||||
}
|
||||
|
||||
onApplySettings: (start, end) => {
|
||||
Config.general.color.scheduleHyprsunsetStart = start;
|
||||
Config.general.color.scheduleHyprsunsetEnd = end;
|
||||
onAccepted: {
|
||||
Config.display.nightlight.scheduleStart = startCurrent;
|
||||
Config.display.nightlight.scheduleEnd = endCurrent;
|
||||
Hyprsunset.checkStartup();
|
||||
PopupManager.requestClose();
|
||||
}
|
||||
onClose: PopupManager.requestClose()
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: value => {
|
||||
Config.general.color.scheduleHyprsunset = value;
|
||||
}
|
||||
ToggleRow {
|
||||
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
|
||||
|
||||
checked: Config.display.nightlight.useNative
|
||||
settingAnchor: "display-nightlight-useNative"
|
||||
text: qsTr("Use native")
|
||||
subtext: qsTr("Use native shader nightlight for more features")
|
||||
|
||||
onToggled: Config.display.nightlight.useNative = checked
|
||||
}
|
||||
|
||||
SpinRow {
|
||||
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
|
||||
|
||||
from: 2000
|
||||
settingAnchor: "display-nightlight-temp"
|
||||
stepSize: 100
|
||||
subtext: qsTr("How warm/orange the screen will be. Lower is warmer (Kelvin)")
|
||||
text: qsTr("Temperature")
|
||||
to: 6500
|
||||
value: Config.display.nightlight.temp
|
||||
|
||||
onMoved: v => Config.display.nightlight.temp = v
|
||||
}
|
||||
|
||||
SpinRow {
|
||||
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
|
||||
|
||||
from: 0
|
||||
settingAnchor: "display-nightlight-fade-duration"
|
||||
enabled: Config.display.nightlight.useNative
|
||||
last: true
|
||||
stepSize: 1
|
||||
subtext: qsTr("How long it takes for the gradual nightlight to finish (s)")
|
||||
text: qsTr("Fade duration")
|
||||
to: 60 * 5
|
||||
value: Config.display.nightlight.fadeDuration / 1000
|
||||
|
||||
onMoved: v => Config.display.nightlight.fadeDuration = v * 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool closed: true
|
||||
property Component currentPopup: null
|
||||
|
||||
function requestClose(): void {
|
||||
closed = true;
|
||||
}
|
||||
|
||||
function requestOpen(component: Component): void {
|
||||
currentPopup = component;
|
||||
closed = false;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
CustomRect {
|
||||
id: root
|
||||
|
||||
readonly property bool closing: PopupManager.closed
|
||||
readonly property var currentPopup: PopupManager.currentPopup
|
||||
property bool shouldBeVisible: loader.status === Loader.Ready
|
||||
|
||||
color: Qt.alpha(Colors.palette.m3shadow, 0.3)
|
||||
opacity: shouldBeVisible && !closing ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
onVisibleChanged: if (!visible)
|
||||
PopupManager.currentPopup = null
|
||||
|
||||
CustomMouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
preventStealing: true
|
||||
propagateComposedEvents: false
|
||||
|
||||
onClicked: {
|
||||
const insideItemWidth = mouseX < loader.x + loader.item.width && mouseX > loader.x;
|
||||
const insideItemHeight = mouseY < loader.y + loader.item.height && mouseY > loader.y;
|
||||
if (insideItemHeight && insideItemWidth)
|
||||
return;
|
||||
|
||||
PopupManager.requestClose();
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: loader
|
||||
|
||||
anchors.centerIn: parent
|
||||
sourceComponent: root.currentPopup
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,24 @@
|
||||
pragma Singleton
|
||||
|
||||
import "../../scripts/fzf.js" as Fzf
|
||||
import "../../scripts/settings-indexer.js" as SettingsIndexer
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import ZShell
|
||||
import ZShell.Config
|
||||
import qs.Paths
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var fzfFinder: null
|
||||
property var inverted: ({})
|
||||
property var ranking: ({})
|
||||
readonly property var highlightCache: ({
|
||||
"search": "",
|
||||
"pattern": null
|
||||
})
|
||||
property var inverted: ({})
|
||||
property var ranking: ({})
|
||||
|
||||
function highlight(text: string, search: string, colour: color): string {
|
||||
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
if (search.length === 0)
|
||||
return escaped;
|
||||
|
||||
const cache = root.highlightCache;
|
||||
if (search !== cache.search) {
|
||||
const tokens = tokenize(search);
|
||||
cache.search = search;
|
||||
if (tokens.length === 0)
|
||||
cache.pattern = null;
|
||||
else {
|
||||
const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
cache.pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi");
|
||||
}
|
||||
}
|
||||
|
||||
const pattern = cache.pattern;
|
||||
if (!pattern)
|
||||
return escaped;
|
||||
|
||||
pattern.lastIndex = 0;
|
||||
if (!pattern.test(escaped))
|
||||
return escaped;
|
||||
|
||||
pattern.lastIndex = 0;
|
||||
return escaped.replace(pattern, `<font color="${colour}">$1</font>`);
|
||||
}
|
||||
|
||||
function lookup(token: string): var {
|
||||
const result = ({});
|
||||
const exact = root.inverted[token] !== undefined;
|
||||
const keys = exact ? [token] : Object.keys(root.inverted).filter(k => k.startsWith(token));
|
||||
for (const key of keys) {
|
||||
const rank = root.ranking[key] ?? ({});
|
||||
for (const id of root.inverted[key]) {
|
||||
const w = rank[id] ?? 0.1;
|
||||
if (result[id] === undefined || w > result[id])
|
||||
result[id] = w;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
property var fzfFinder: null
|
||||
readonly property string cachePath: Paths.cache + "/settings-index.json"
|
||||
|
||||
function query(search: string): list<QtObject> {
|
||||
const tokens = root.tokenize(search);
|
||||
@@ -70,7 +28,7 @@ Singleton {
|
||||
const scores = ({});
|
||||
const hitCounts = ({});
|
||||
for (const token of tokens) {
|
||||
const matches = root.lookup(token);
|
||||
const matches = root.lookup(token); // { id: weight }
|
||||
for (const id in matches) {
|
||||
scores[id] = (scores[id] ?? 0) + matches[id];
|
||||
hitCounts[id] = (hitCounts[id] ?? 0) + 1;
|
||||
@@ -103,13 +61,76 @@ Singleton {
|
||||
return out;
|
||||
}
|
||||
|
||||
function lookup(token: string): var {
|
||||
const result = ({});
|
||||
const exact = root.inverted[token] !== undefined;
|
||||
const keys = exact ? [token] : Object.keys(root.inverted).filter(k => k.startsWith(token));
|
||||
for (const key of keys) {
|
||||
const rank = root.ranking[key] ?? ({});
|
||||
for (const id of root.inverted[key]) {
|
||||
const w = rank[id] ?? 0.1;
|
||||
if (result[id] === undefined || w > result[id])
|
||||
result[id] = w;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function tokenize(text: string): var {
|
||||
return text.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length > 0);
|
||||
}
|
||||
|
||||
function highlight(text: string, search: string, colour: color): string {
|
||||
const escaped = text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
if (search.length === 0)
|
||||
return escaped;
|
||||
|
||||
const cache = root.highlightCache;
|
||||
if (search !== cache.search) {
|
||||
const tokens = root.tokenize(search);
|
||||
cache.search = search;
|
||||
if (tokens.length === 0) {
|
||||
cache.pattern = null;
|
||||
} else {
|
||||
const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
||||
cache.pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi");
|
||||
}
|
||||
}
|
||||
|
||||
const pattern = cache.pattern;
|
||||
if (!pattern)
|
||||
return escaped;
|
||||
|
||||
pattern.lastIndex = 0;
|
||||
if (!pattern.test(escaped))
|
||||
return escaped;
|
||||
|
||||
pattern.lastIndex = 0;
|
||||
return escaped.replace(pattern, `<font color="${colour}">$1</font>`);
|
||||
}
|
||||
|
||||
function loadIndex(): var {
|
||||
const revision = ZUtils.gitRevision();
|
||||
const cached = ZUtils.readTextFile(cachePath);
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (parsed.version === 3 && revision && parsed.revision === revision && parsed.locale === Qt.locale().name)
|
||||
return parsed;
|
||||
} catch (e) {}
|
||||
}
|
||||
const data = SettingsIndexer.buildIndex(`${Quickshell.shellDir}/Modules/Settings`, p => ZUtils.readTextFile(p), (d, s) => ZUtils.listFiles(d, s), (ctx, text) => qsTranslate(ctx, text));
|
||||
data.revision = revision;
|
||||
data.locale = Qt.locale().name;
|
||||
ZUtils.writeTextFile(cachePath, JSON.stringify(data));
|
||||
console.log(`SettingsSearcher: indexed ${data.entries.length} settings (revision ${revision || "unknown"})`);
|
||||
return data;
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
try {
|
||||
const data = JSON.parse(ZUtils.settingsIndex());
|
||||
const data = root.loadIndex();
|
||||
entries.model = data.entries;
|
||||
root.inverted = data.inverted ?? {};
|
||||
root.ranking = data.ranking ?? {};
|
||||
@@ -122,6 +143,7 @@ Singleton {
|
||||
limit: 25
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("SettingsSearcher: failed to build settings index:", e);
|
||||
entries.model = [];
|
||||
root.inverted = {};
|
||||
root.ranking = {};
|
||||
@@ -132,22 +154,25 @@ Singleton {
|
||||
Variants {
|
||||
id: entries
|
||||
|
||||
SettingEntry {
|
||||
}
|
||||
SettingEntry {}
|
||||
}
|
||||
|
||||
component SettingEntry: QtObject {
|
||||
readonly property string anchor: modelData.anchor ?? ""
|
||||
required property var modelData
|
||||
|
||||
readonly property int pageIdx: modelData.pageIdx
|
||||
readonly property var subPath: modelData.subPath
|
||||
readonly property var targetSubPath: modelData.targetSubPath ?? []
|
||||
readonly property var crumbIcons: modelData.crumbIcons
|
||||
readonly property var crumbLabels: modelData.crumbLabels
|
||||
readonly property bool isToggle: togglePath.length > 0
|
||||
required property var modelData
|
||||
readonly property int pageIdx: modelData.pageIdx
|
||||
readonly property string section: modelData.section ?? ""
|
||||
readonly property var subPath: modelData.subPath
|
||||
readonly property string subtext: modelData.subtext ?? ""
|
||||
readonly property string title: modelData.title
|
||||
readonly property string section: modelData.section ?? ""
|
||||
readonly property string subtext: modelData.subtext ?? ""
|
||||
readonly property string anchor: modelData.anchor ?? ""
|
||||
readonly property string icon: modelData.icon ?? ""
|
||||
|
||||
readonly property string togglePath: modelData.togglePath ?? ""
|
||||
readonly property bool isToggle: togglePath.length > 0
|
||||
readonly property bool toggleValue: {
|
||||
if (!isToggle)
|
||||
return false;
|
||||
|
||||
@@ -16,6 +16,7 @@ QtObject {
|
||||
property string searchText
|
||||
property DesktopEntry selectedApp
|
||||
property BluetoothDevice selectedBtDevice
|
||||
property bool dimmed
|
||||
property string selectedWallpaperCategory
|
||||
property list<int> subPageIdxStack
|
||||
|
||||
|
||||
@@ -1 +1,24 @@
|
||||
find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus CanvasPainter)
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
|
||||
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
|
||||
pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED)
|
||||
pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET)
|
||||
pkg_check_modules(GLIB REQUIRED glib-2.0 gobject-2.0 gio-2.0)
|
||||
|
||||
if(NOT Cava_FOUND)
|
||||
pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED)
|
||||
endif()
|
||||
|
||||
include(cmake/sensorslib.cmake)
|
||||
|
||||
set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
|
||||
qt_standard_project_setup(REQUIRES 6.9)
|
||||
|
||||
include(cmake/pch.cmake)
|
||||
include(cmake/qml-module.cmake)
|
||||
|
||||
add_library(zshell-util INTERFACE)
|
||||
target_include_directories(zshell-util INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include")
|
||||
|
||||
add_subdirectory(ZShell)
|
||||
|
||||
@@ -1,55 +1,3 @@
|
||||
find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus CanvasPainter)
|
||||
find_package(PkgConfig REQUIRED)
|
||||
find_library(SENSORS_LIBRARY NAMES sensors REQUIRED)
|
||||
find_path(SENSORS_INCLUDE_DIR NAMES sensors/sensors.h REQUIRED)
|
||||
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
|
||||
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
|
||||
pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED)
|
||||
pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET)
|
||||
pkg_check_modules(GLIB REQUIRED glib-2.0 gobject-2.0 gio-2.0)
|
||||
|
||||
if(NOT Cava_FOUND)
|
||||
pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED)
|
||||
endif()
|
||||
|
||||
if(NOT TARGET Sensors::Sensors)
|
||||
add_library(Sensors::Sensors UNKNOWN IMPORTED)
|
||||
set_target_properties(Sensors::Sensors PROPERTIES
|
||||
IMPORTED_LOCATION "${SENSORS_LIBRARY}"
|
||||
INTERFACE_INCLUDE_DIRECTORIES "${SENSORS_INCLUDE_DIR}"
|
||||
)
|
||||
endif()
|
||||
|
||||
set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
|
||||
qt_standard_project_setup(REQUIRES 6.9)
|
||||
|
||||
function(qml_module arg_TARGET)
|
||||
cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;LIBRARIES;RESOURCES")
|
||||
qt_add_qml_module(${arg_TARGET}
|
||||
URI ${arg_URI}
|
||||
VERSION 1.0
|
||||
SOURCES ${arg_SOURCES}
|
||||
RESOURCES ${arg_RESOURCES}
|
||||
)
|
||||
|
||||
qt_query_qml_module(${arg_TARGET}
|
||||
URI module_uri
|
||||
VERSION module_version
|
||||
PLUGIN_TARGET module_plugin_target
|
||||
TARGET_PATH module_target_path
|
||||
QMLDIR module_qmldir
|
||||
TYPEINFO module_typeinfo
|
||||
)
|
||||
set(module_dir "${INSTALL_QMLDIR}/${module_target_path}")
|
||||
install(TARGETS ${arg_TARGET} LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}")
|
||||
install(TARGETS "${module_plugin_target}" LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}")
|
||||
install(FILES "${module_qmldir}" DESTINATION "${module_dir}")
|
||||
install(FILES "${module_typeinfo}" DESTINATION "${module_dir}")
|
||||
target_link_libraries(${arg_TARGET} PRIVATE Qt::Core Qt::Qml ${arg_LIBRARIES})
|
||||
endfunction()
|
||||
|
||||
set_source_files_properties("${SETTINGS_INDEX_JSON}" PROPERTIES QT_RESOURCE_ALIAS "settings-index.json")
|
||||
|
||||
qml_module(ZShell
|
||||
URI ZShell
|
||||
SOURCES
|
||||
@@ -60,8 +8,6 @@ qml_module(ZShell
|
||||
toaster.hpp toaster.cpp
|
||||
qalculator.hpp qalculator.cpp
|
||||
zutils.hpp zutils.cpp
|
||||
RESOURCES
|
||||
"${SETTINGS_INDEX_JSON}"
|
||||
LIBRARIES
|
||||
Qt::Gui
|
||||
Qt::Quick
|
||||
@@ -69,9 +15,13 @@ qml_module(ZShell
|
||||
Qt::Sql
|
||||
Qt::DBus
|
||||
PkgConfig::Qalculate
|
||||
zshell-util
|
||||
)
|
||||
|
||||
target_compile_definitions(ZShell PRIVATE ZSHELL_VERSION="${VERSION}")
|
||||
target_compile_definitions(ZShell PRIVATE
|
||||
ZSHELL_VERSION="${VERSION}"
|
||||
GIT_REVISION="${GIT_REVISION}"
|
||||
)
|
||||
|
||||
add_subdirectory(Models)
|
||||
add_subdirectory(Internal)
|
||||
@@ -80,3 +30,11 @@ add_subdirectory(Components)
|
||||
add_subdirectory(Blobs)
|
||||
add_subdirectory(Config)
|
||||
add_subdirectory(Llm)
|
||||
|
||||
pkg_check_modules(HYPRLAND_PROBE hyprland)
|
||||
if(HYPRLAND_PROBE_FOUND)
|
||||
add_subdirectory(HyprPlugins)
|
||||
else()
|
||||
message(STATUS
|
||||
"hyprland development headers not found; skipping zshell-nightlight plugin")
|
||||
endif()
|
||||
|
||||
@@ -5,6 +5,7 @@ qml_module(ZShell-components
|
||||
wavyline.hpp wavyline.cpp
|
||||
buttonrow.hpp buttonrow.cpp
|
||||
carouselview.hpp carouselview.cpp
|
||||
wheelinverter.hpp
|
||||
LIBRARIES
|
||||
Qt::Quick
|
||||
)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include <QQuickItem>
|
||||
#include <QQuickWindow>
|
||||
#include <QWheelEvent>
|
||||
#include <QCoreApplication>
|
||||
#include <QPointer>
|
||||
#include <QDebug>
|
||||
|
||||
namespace ZShell::components {
|
||||
|
||||
class WheelInverter : public QQuickItem {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
|
||||
Q_PROPERTY(
|
||||
QQuickItem* target READ target WRITE setTarget NOTIFY targetChanged)
|
||||
|
||||
public:
|
||||
using QQuickItem::QQuickItem;
|
||||
|
||||
QQuickItem* target() const { return m_target; }
|
||||
|
||||
void setTarget(QQuickItem* target) {
|
||||
if (m_target == target) return;
|
||||
|
||||
if (m_target) m_target->removeEventFilter(this);
|
||||
|
||||
m_target = target;
|
||||
|
||||
if (m_target) m_target->installEventFilter(this);
|
||||
|
||||
emit targetChanged();
|
||||
}
|
||||
|
||||
protected:
|
||||
class InvertedWheelEvent : public QWheelEvent {
|
||||
public:
|
||||
using QWheelEvent::QWheelEvent;
|
||||
|
||||
bool invertedByWheelInverter = true;
|
||||
};
|
||||
|
||||
bool eventFilter(QObject* watched, QEvent* event) override {
|
||||
if (watched != m_target || event->type() != QEvent::Wheel)
|
||||
return QQuickItem::eventFilter(watched, event);
|
||||
|
||||
auto* wheel = static_cast<QWheelEvent*>(event);
|
||||
|
||||
const bool inverted = dynamic_cast<InvertedWheelEvent*>(wheel) !=
|
||||
nullptr;
|
||||
|
||||
if (inverted) return false;
|
||||
|
||||
auto* window = m_target ? m_target->window() : nullptr;
|
||||
if (!window) {
|
||||
qInfo() << "[WheelInverter] no window";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QPointF windowPos =
|
||||
window->mapFromGlobal(wheel->globalPosition());
|
||||
|
||||
auto* invertedEvent = new InvertedWheelEvent(
|
||||
windowPos,
|
||||
wheel->globalPosition(),
|
||||
-wheel->pixelDelta(),
|
||||
-wheel->angleDelta(),
|
||||
wheel->buttons(),
|
||||
wheel->modifiers(),
|
||||
wheel->phase(),
|
||||
wheel->inverted(),
|
||||
wheel->source(),
|
||||
wheel->pointingDevice());
|
||||
|
||||
invertedEvent->setTimestamp(wheel->timestamp());
|
||||
|
||||
QCoreApplication::postEvent(window, invertedEvent);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
signals:
|
||||
void targetChanged();
|
||||
|
||||
private:
|
||||
QPointer<QQuickItem> m_target;
|
||||
};
|
||||
|
||||
} // namespace ZShell::components
|
||||
@@ -5,6 +5,7 @@ qml_module(ZShell-config
|
||||
configobject.hpp configobject.cpp
|
||||
configlist.hpp configlist.cpp
|
||||
config.hpp config.cpp
|
||||
migration.hpp migration.cpp
|
||||
anim.hpp anim.cpp
|
||||
tokens.hpp
|
||||
appearance.hpp
|
||||
@@ -13,6 +14,7 @@ qml_module(ZShell-config
|
||||
clipboard.hpp
|
||||
colors.hpp
|
||||
dashboard.hpp
|
||||
display.hpp
|
||||
dock.hpp
|
||||
general.hpp
|
||||
launcher.hpp
|
||||
|
||||
@@ -22,6 +22,13 @@ class Colors : public ConfigObject {
|
||||
|
||||
CONFIG_SUBOBJECT(Presets, presets)
|
||||
CFG_PROPERTY(QString, schemeType, QStringLiteral("fidelity"))
|
||||
CFG_PROPERTY(bool, scheduleDark, false)
|
||||
CFG_PROPERTY(int, scheduleDarkEnd, 600)
|
||||
CFG_PROPERTY(int, scheduleDarkStart, 1140)
|
||||
CFG_PROPERTY(bool, schemeGen, true)
|
||||
CFG_PROPERTY(bool, neovimColors, false)
|
||||
CFG_PROPERTY(QString, mode, QStringLiteral("dark"))
|
||||
CFG_PROPERTY(bool, smart, false)
|
||||
|
||||
public:
|
||||
explicit Colors(QObject* parent = nullptr)
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
#include "clipboard.hpp"
|
||||
#include "colors.hpp"
|
||||
#include "dashboard.hpp"
|
||||
#include "display.hpp"
|
||||
#include "dock.hpp"
|
||||
#include "general.hpp"
|
||||
#include "launcher.hpp"
|
||||
#include "llm.hpp"
|
||||
#include "lock.hpp"
|
||||
#include "migration.hpp"
|
||||
#include "notifs.hpp"
|
||||
#include "osd.hpp"
|
||||
#include "screenshot.hpp"
|
||||
@@ -44,6 +46,7 @@ Config::Config(QObject* parent)
|
||||
, m_clipboard(new Clipboard(this))
|
||||
, m_colors(new Colors(this))
|
||||
, m_dashboard(new Dashboard(this))
|
||||
, m_display(new Display(this))
|
||||
, m_dock(new Dock(this))
|
||||
, m_general(new General(this))
|
||||
, m_launcher(new Launcher(this))
|
||||
@@ -91,8 +94,7 @@ Config* Config::instance() {
|
||||
}
|
||||
|
||||
Config* Config::create(QQmlEngine*, QJSEngine*) {
|
||||
if (!s_instance)
|
||||
s_instance = new Config();
|
||||
if (!s_instance) s_instance = new Config();
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
@@ -111,11 +113,14 @@ void Config::loadSync() {
|
||||
QJsonObject before;
|
||||
|
||||
bool existed = false;
|
||||
bool migrated = false;
|
||||
|
||||
if (f.open(QIODevice::ReadOnly)) {
|
||||
const auto doc = QJsonDocument::fromJson(f.readAll());
|
||||
if (doc.isObject()) {
|
||||
before = doc.object();
|
||||
const QJsonObject raw = doc.object();
|
||||
before = ConfigMigrations::apply(raw);
|
||||
migrated = before != raw;
|
||||
existed = true;
|
||||
} else {
|
||||
qInfo() << "Config: existing config at" << filePath()
|
||||
@@ -130,7 +135,7 @@ void Config::loadSync() {
|
||||
m_loading = false;
|
||||
|
||||
const auto after = toJson().toObject();
|
||||
if (!existed || after != before) saveNow();
|
||||
if (!existed || migrated || after != before) saveNow();
|
||||
}
|
||||
|
||||
void Config::updateWatch() {
|
||||
@@ -176,16 +181,18 @@ void Config::loadAsync() {
|
||||
if (f.open(QIODevice::ReadOnly)) {
|
||||
const auto doc = QJsonDocument::fromJson(f.readAll());
|
||||
const bool valid = doc.isObject();
|
||||
const QJsonObject before = valid ? doc.object() : QJsonObject();
|
||||
const QJsonObject raw = valid ? doc.object() : QJsonObject();
|
||||
const QJsonObject before = ConfigMigrations::apply(raw);
|
||||
const bool migrated = valid && before != raw;
|
||||
|
||||
QMetaObject::invokeMethod(
|
||||
this,
|
||||
[this, valid, before]() {
|
||||
[this, valid, migrated, before]() {
|
||||
loadFromJson(QJsonValue(before));
|
||||
m_loading = false;
|
||||
|
||||
const auto after = toJson().toObject();
|
||||
if (!valid || after != before) saveNow();
|
||||
if (!valid || migrated || after != before) saveNow();
|
||||
|
||||
if (m_reloadPending) m_reloadTimer.start();
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ class Bar;
|
||||
class Clipboard;
|
||||
class Colors;
|
||||
class Dashboard;
|
||||
class Display;
|
||||
class Dock;
|
||||
class General;
|
||||
class Launcher;
|
||||
@@ -44,6 +45,7 @@ class Config : public ConfigObject {
|
||||
Q_MOC_INCLUDE("clipboard.hpp")
|
||||
Q_MOC_INCLUDE("colors.hpp")
|
||||
Q_MOC_INCLUDE("dashboard.hpp")
|
||||
Q_MOC_INCLUDE("display.hpp")
|
||||
Q_MOC_INCLUDE("dock.hpp")
|
||||
Q_MOC_INCLUDE("general.hpp")
|
||||
Q_MOC_INCLUDE("launcher.hpp")
|
||||
@@ -62,6 +64,7 @@ class Config : public ConfigObject {
|
||||
CONFIG_SUBOBJECT(Clipboard, clipboard)
|
||||
CONFIG_SUBOBJECT(Colors, colors)
|
||||
CONFIG_SUBOBJECT(Dashboard, dashboard)
|
||||
CONFIG_SUBOBJECT(Display, display)
|
||||
CONFIG_SUBOBJECT(Dock, dock)
|
||||
CONFIG_SUBOBJECT(General, general)
|
||||
CONFIG_SUBOBJECT(Launcher, launcher)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
#include "configobject.hpp"
|
||||
#include <qqmlintegration.h>
|
||||
|
||||
namespace ZShell::config {
|
||||
|
||||
class Nightlight : public ConfigObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
|
||||
CFG_PROPERTY(bool, schedule, true)
|
||||
CFG_PROPERTY(int, scheduleStart, 1200)
|
||||
CFG_PROPERTY(int, scheduleEnd, 570)
|
||||
CFG_PROPERTY(bool, useNative, true)
|
||||
CFG_PROPERTY(int, fadeDuration, 2000)
|
||||
CFG_PROPERTY(int, temp, 2600)
|
||||
|
||||
public:
|
||||
explicit Nightlight(QObject* parent = nullptr) : ConfigObject(parent) {}
|
||||
};
|
||||
|
||||
class Display : public ConfigObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
|
||||
CONFIG_SUBOBJECT(Nightlight, nightlight)
|
||||
|
||||
public:
|
||||
explicit Display(QObject* parent = nullptr)
|
||||
: ConfigObject(parent), m_nightlight(new Nightlight(this)) {}
|
||||
};
|
||||
|
||||
} // namespace ZShell::config
|
||||
@@ -91,26 +91,6 @@ class Battery : public ConfigObject {
|
||||
explicit Battery(QObject* parent = nullptr) : ConfigObject(parent) {}
|
||||
};
|
||||
|
||||
class ColorSettings : public ConfigObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
|
||||
CFG_PROPERTY(int, hyprsunsetTemp, 2600)
|
||||
CFG_PROPERTY(QString, mode, QStringLiteral("dark"))
|
||||
CFG_PROPERTY(bool, neovimColors, false)
|
||||
CFG_PROPERTY(bool, scheduleDark, false)
|
||||
CFG_PROPERTY(int, scheduleDarkEnd, 600)
|
||||
CFG_PROPERTY(int, scheduleDarkStart, 1140)
|
||||
CFG_PROPERTY(bool, scheduleHyprsunset, true)
|
||||
CFG_PROPERTY(int, scheduleHyprsunsetEnd, 570)
|
||||
CFG_PROPERTY(int, scheduleHyprsunsetStart, 1200)
|
||||
CFG_PROPERTY(bool, schemeGeneration, true)
|
||||
CFG_PROPERTY(bool, smart, false)
|
||||
|
||||
public:
|
||||
explicit ColorSettings(QObject* parent = nullptr) : ConfigObject(parent) {}
|
||||
};
|
||||
|
||||
class Idle : public ConfigObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
@@ -135,7 +115,6 @@ class General : public ConfigObject {
|
||||
|
||||
CONFIG_SUBOBJECT(Apps, apps)
|
||||
CONFIG_SUBOBJECT(Battery, battery)
|
||||
CONFIG_SUBOBJECT(ColorSettings, color)
|
||||
CFG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss"))
|
||||
CFG_PROPERTY(bool, desktopIcons, true)
|
||||
CONFIG_SUBOBJECT(Idle, idle)
|
||||
@@ -151,7 +130,6 @@ class General : public ConfigObject {
|
||||
: ConfigObject(parent)
|
||||
, m_apps(new Apps(this))
|
||||
, m_battery(new Battery(this))
|
||||
, m_color(new ColorSettings(this))
|
||||
, m_idle(new Idle(this)) {}
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class Llm : public ConfigObject {
|
||||
CFG_PROPERTY(QString, model, "")
|
||||
CFG_PROPERTY(double, temperature, 0.7)
|
||||
CFG_PROPERTY(bool, tools, true)
|
||||
CFG_PROPERTY(bool, enabled, true)
|
||||
CONFIG_SUBOBJECT(LlmAppearance, appearance)
|
||||
|
||||
public:
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
#include "migration.hpp"
|
||||
|
||||
#include <QDebug>
|
||||
|
||||
namespace ZShell::config {
|
||||
|
||||
namespace {
|
||||
|
||||
// A stored value is "missing" if the path does not resolve to an entry.
|
||||
// Note: default-constructed QJsonValue is Null, not Undefined.
|
||||
bool isMissing(const QJsonValue& v) {
|
||||
return v.isUndefined() || v.isNull();
|
||||
}
|
||||
|
||||
QJsonValue valueAt(const QJsonObject& obj, const QStringList& path) {
|
||||
QJsonObject cur = obj;
|
||||
|
||||
for (int i = 0; i < path.size() - 1; ++i) {
|
||||
if (!cur.value(path.at(i)).isObject()) return QJsonValue::Undefined;
|
||||
cur = cur.value(path.at(i)).toObject();
|
||||
}
|
||||
|
||||
const QString key = path.last();
|
||||
if (!cur.contains(key)) return QJsonValue::Undefined;
|
||||
return cur.value(key);
|
||||
}
|
||||
|
||||
QJsonObject placeAt(QJsonObject obj, QStringList path, const QJsonValue& value) {
|
||||
const QString head = path.takeFirst();
|
||||
|
||||
if (path.isEmpty()) {
|
||||
obj[head] = value;
|
||||
return obj;
|
||||
}
|
||||
|
||||
QJsonObject child = obj.value(head).isObject() ? obj.value(head).toObject()
|
||||
: QJsonObject();
|
||||
obj[head] = placeAt(child, path, value);
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Removes `path` from `obj`. Returns true if a value was removed.
|
||||
// Parent objects left empty are removed as well.
|
||||
bool removeAt(QJsonObject& obj, const QStringList& path) {
|
||||
const QString head = path.first();
|
||||
|
||||
if (path.size() == 1) {
|
||||
if (!obj.contains(head)) return false;
|
||||
obj.remove(head);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!obj.value(head).isObject()) return false;
|
||||
|
||||
QJsonObject child = obj.value(head).toObject();
|
||||
const bool removed = removeAt(child, path.mid(1));
|
||||
|
||||
if (child.isEmpty())
|
||||
obj.remove(head);
|
||||
else
|
||||
obj[head] = child;
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const QList<ConfigMigrationRule>& ConfigMigrations::rules() {
|
||||
static const QList<ConfigMigrationRule> s_rules = {
|
||||
migrate(
|
||||
"general.color.scheduleHyprsunset", "display.nightlight.schedule"),
|
||||
migrate(
|
||||
"general.color.scheduleHyprsunsetStart",
|
||||
"display.nightlight.scheduleStart"),
|
||||
migrate(
|
||||
"general.color.scheduleHyprsunsetEnd",
|
||||
"display.nightlight.scheduleEnd"),
|
||||
migrate("general.color.scheduleDark", "colors.scheduleDark"),
|
||||
migrate("general.color.scheduleDarkStart", "colors.scheduleDarkStart"),
|
||||
migrate("general.color.scheduleDarkEnd", "colors.scheduleDarkEnd"),
|
||||
migrate("general.color.schemeGeneration", "colors.schemeGen"),
|
||||
migrate(
|
||||
"general.color.useNativeNightlight",
|
||||
"display.nightlight.useNative"),
|
||||
migrate(
|
||||
"general.color.nativeFadeDuration",
|
||||
"display.nightlight.fadeDuration"),
|
||||
migrate("general.color.hyprsunsetTemp", "display.nightlight.temp"),
|
||||
migrate("general.color.neovimColors", "colors.neovimColors"),
|
||||
migrate("general.color.mode", "colors.mode"),
|
||||
migrate("general.color.smart", "colors.smart"),
|
||||
};
|
||||
return s_rules;
|
||||
}
|
||||
|
||||
QJsonObject ConfigMigrations::apply(const QJsonObject& json) {
|
||||
QJsonObject obj = json;
|
||||
|
||||
for (const auto& rule : rules()) {
|
||||
const QStringList from =
|
||||
rule.from.split(QLatin1Char('.'), Qt::SkipEmptyParts);
|
||||
const QStringList to =
|
||||
rule.to.split(QLatin1Char('.'), Qt::SkipEmptyParts);
|
||||
|
||||
if (from.isEmpty() || to.isEmpty()) continue;
|
||||
|
||||
const QJsonValue oldValue = valueAt(obj, from);
|
||||
if (isMissing(oldValue)) continue;
|
||||
|
||||
const bool targetExists = !isMissing(valueAt(obj, to));
|
||||
if (!targetExists) obj = placeAt(obj, to, oldValue);
|
||||
|
||||
if (removeAt(obj, from)) {
|
||||
qInfo() << "Config migration:" << rule.from << "->" << rule.to
|
||||
<< (targetExists ? "(target already set, old value dropped)"
|
||||
: "(value moved)");
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
} // namespace ZShell::config
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QStringList>
|
||||
|
||||
namespace ZShell::config {
|
||||
|
||||
// A single migration rule: move the value stored at the dotted `from` path
|
||||
// to the dotted `to` path, then delete the old key (and any parent objects
|
||||
// left empty).
|
||||
//
|
||||
// Rules are applied to the raw JSON file on every load, before the schema
|
||||
// loads it. They are idempotent: once the old key is gone the rule never
|
||||
// fires again, so no version stamping is needed. Old rules can stay in the
|
||||
// table forever.
|
||||
//
|
||||
// WARNING: the target path must exist in the schema (i.e. a ConfigObject
|
||||
// property somewhere in the tree). If it does not, the migrated value is
|
||||
// loaded as an unknown key and silently dropped on the next save.
|
||||
struct ConfigMigrationRule {
|
||||
QString from;
|
||||
QString to;
|
||||
};
|
||||
|
||||
// Helper for writing rules: migrate("general.color.foo", "display.bar.foo")
|
||||
inline ConfigMigrationRule migrate(const QString& from, const QString& to) {
|
||||
return {from, to};
|
||||
}
|
||||
|
||||
class ConfigMigrations {
|
||||
public:
|
||||
[[nodiscard]] static const QList<ConfigMigrationRule>& rules();
|
||||
|
||||
// Applies all rules to `json`, returning the migrated object.
|
||||
[[nodiscard]] static QJsonObject apply(const QJsonObject& json);
|
||||
};
|
||||
|
||||
} // namespace ZShell::config
|
||||
@@ -0,0 +1,12 @@
|
||||
pkg_check_modules(HYPRLAND REQUIRED hyprland)
|
||||
|
||||
add_library(zshell-nightlight SHARED main.cpp)
|
||||
|
||||
target_compile_features(zshell-nightlight PRIVATE cxx_std_23)
|
||||
target_include_directories(zshell-nightlight PRIVATE ${HYPRLAND_INCLUDE_DIRS})
|
||||
target_link_directories(zshell-nightlight PRIVATE ${HYPRLAND_LIBRARY_DIRS})
|
||||
target_compile_options(zshell-nightlight PRIVATE ${HYPRLAND_CFLAGS_OTHER})
|
||||
|
||||
target_compile_options(zshell-nightlight PRIVATE -fno-gnu-unique)
|
||||
|
||||
install(TARGETS zshell-nightlight LIBRARY DESTINATION "${INSTALL_LIBDIR}/plugins")
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
namespace ZShell::services {
|
||||
|
||||
inline std::array<float, 3> kelvinToGain(int kelvin) {
|
||||
const double t = std::clamp(kelvin, 1000, 40000) / 100.0;
|
||||
double r, g, b;
|
||||
|
||||
r = t <= 66.0
|
||||
? 1.0
|
||||
: std::clamp(
|
||||
1.29293618606 * std::pow(t - 60.0, -0.1332047592), 0.0, 1.0);
|
||||
g = t <= 66.0
|
||||
? std::clamp(0.39008157876 * std::log(t) - 0.63184144378, 0.0, 1.0)
|
||||
: std::clamp(
|
||||
1.12989086089 * std::pow(t - 60.0, -0.0755148492), 0.0, 1.0);
|
||||
b = t >= 66.0
|
||||
? 1.0
|
||||
: (t <= 19.0
|
||||
? 0.0
|
||||
: std::clamp(
|
||||
0.54320678911 * std::log(t - 10.0) - 1.19625408914,
|
||||
0.0,
|
||||
1.0));
|
||||
|
||||
return {static_cast<float>(r), static_cast<float>(g), static_cast<float>(b)};
|
||||
}
|
||||
|
||||
} // namespace ZShell::services
|
||||
@@ -0,0 +1,447 @@
|
||||
#include <any>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#define private public
|
||||
#include <hyprland/src/plugins/PluginAPI.hpp>
|
||||
#include <hyprland/src/render/OpenGL.hpp>
|
||||
#include <hyprland/src/render/Renderer.hpp>
|
||||
#undef private
|
||||
|
||||
#include <hyprland/src/managers/EventManager.hpp>
|
||||
|
||||
extern "C" {
|
||||
#include <lua.h>
|
||||
#include <lauxlib.h>
|
||||
}
|
||||
|
||||
#include "colortemp.hpp"
|
||||
|
||||
using ZShell::services::kelvinToGain;
|
||||
|
||||
using Render::IFramebuffer;
|
||||
using Render::GL::CHyprOpenGLImpl;
|
||||
using Render::GL::g_pHyprOpenGL;
|
||||
|
||||
inline HANDLE PHANDLE = nullptr;
|
||||
|
||||
static std::filesystem::path g_runtimeDir{"/tmp"};
|
||||
static std::filesystem::path g_cacheBase{"/tmp/.cache"};
|
||||
|
||||
static void initEnvPaths() {
|
||||
if (const char* env = getenv("XDG_RUNTIME_DIR")) g_runtimeDir = env;
|
||||
|
||||
if (const char* env = getenv("XDG_CACHE_HOME")) {
|
||||
if (*env) g_cacheBase = env;
|
||||
} else if (const char* home = getenv("HOME")) {
|
||||
g_cacheBase = std::filesystem::path(home) / ".cache";
|
||||
}
|
||||
}
|
||||
|
||||
static std::ofstream& debugLog() {
|
||||
static std::ofstream log(
|
||||
g_runtimeDir / "zshell-nightlight-debug.log", std::ios::app);
|
||||
return log;
|
||||
}
|
||||
|
||||
static std::mutex g_logMutex;
|
||||
static std::atomic<uint64_t> g_logSeq{0};
|
||||
|
||||
static void dlog(const std::string& msg) {
|
||||
std::lock_guard<std::mutex> lk(g_logMutex);
|
||||
auto& log = debugLog();
|
||||
log << "[" << g_logSeq++ << "][tid=" << std::this_thread::get_id() << "] "
|
||||
<< msg << std::endl;
|
||||
}
|
||||
|
||||
static constexpr int BAKED_STEPS = 24;
|
||||
|
||||
static std::string glslFloat(float v) {
|
||||
std::ostringstream ss;
|
||||
ss << std::fixed << std::setprecision(6) << v;
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
static std::filesystem::path cacheDir() {
|
||||
return g_cacheBase / "zshell" / "night-light";
|
||||
}
|
||||
|
||||
static std::filesystem::path shaderPath() {
|
||||
return cacheDir() / "nightlight.frag";
|
||||
}
|
||||
|
||||
static std::filesystem::path bakedShaderPath(const std::array<float, 3>& gain) {
|
||||
return cacheDir() /
|
||||
("nightlight-" + glslFloat(gain[0]) + "-" + glslFloat(gain[1]) +
|
||||
"-" + glslFloat(gain[2]) + ".frag");
|
||||
}
|
||||
|
||||
static std::string defaultShaderSource() {
|
||||
return "#version 100\n"
|
||||
"precision highp float;\n"
|
||||
"varying vec2 v_texcoord;\n"
|
||||
"uniform sampler2D tex;\n"
|
||||
"uniform vec3 tint;\n"
|
||||
"void main() {\n"
|
||||
" vec4 pixColor = texture2D(tex, v_texcoord);\n"
|
||||
" gl_FragColor = vec4(pixColor.rgb * tint, pixColor.a);\n"
|
||||
"}\n";
|
||||
}
|
||||
|
||||
static std::string bakedShaderSource(const std::array<float, 3>& gain) {
|
||||
std::ostringstream src;
|
||||
src << "#version 100\n"
|
||||
"precision highp float;\n"
|
||||
"varying vec2 v_texcoord;\n"
|
||||
"uniform sampler2D tex;\n"
|
||||
"const vec3 GAIN = vec3("
|
||||
<< glslFloat(gain[0]) << "," << glslFloat(gain[1]) << ","
|
||||
<< glslFloat(gain[2])
|
||||
<< ");\n"
|
||||
"void main() {\n"
|
||||
" vec4 pixColor = texture2D(tex, v_texcoord);\n"
|
||||
" gl_FragColor = vec4(pixColor.rgb * GAIN, pixColor.a);\n"
|
||||
"}\n";
|
||||
return src.str();
|
||||
}
|
||||
|
||||
static void ensureShaderFile(
|
||||
const std::filesystem::path& path, const std::string& source) {
|
||||
std::error_code ec;
|
||||
if (std::filesystem::is_regular_file(path, ec)) return;
|
||||
std::filesystem::create_directories(path.parent_path(), ec);
|
||||
std::ofstream out(path, std::ios::trunc);
|
||||
out << source;
|
||||
}
|
||||
|
||||
static bool g_active = false;
|
||||
static bool g_uniformMode = false;
|
||||
static bool g_modeDecided = false;
|
||||
static std::array<float, 3> g_currentGain{1.f, 1.f, 1.f};
|
||||
|
||||
static int g_currentKelvin = 6500;
|
||||
static int g_lastKelvin = 6500;
|
||||
|
||||
struct STransition {
|
||||
bool active = false;
|
||||
std::chrono::steady_clock::time_point start;
|
||||
float durationSec = 0.4f;
|
||||
std::array<float, 3> fromGain{1.f, 1.f, 1.f};
|
||||
std::array<float, 3> toGain{1.f, 1.f, 1.f};
|
||||
int lastBakedStep = -1;
|
||||
};
|
||||
static STransition g_transition;
|
||||
|
||||
static void postStateEvent(bool enabled, bool animating);
|
||||
|
||||
static bool gainIsNeutral(const std::array<float, 3>& gain) {
|
||||
for (float v : gain)
|
||||
if (std::abs(v - 1.f) > 0.001f) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static CFunctionHook* g_pBeginHook = nullptr;
|
||||
using origBegin = void (*)(
|
||||
CHyprOpenGLImpl*,
|
||||
PHLMONITOR,
|
||||
const CRegion&,
|
||||
SP<IFramebuffer>,
|
||||
std::optional<CRegion>);
|
||||
|
||||
static CFunctionHook* g_pSaveMirrorHook = nullptr;
|
||||
using origSaveMirror = bool (*)(CHyprOpenGLImpl*, const CBox&);
|
||||
|
||||
static bool hkSaveBufferForMirror(CHyprOpenGLImpl* thisptr, const CBox& box) {
|
||||
const bool saved = thisptr->m_applyFinalShader;
|
||||
thisptr->m_applyFinalShader = false;
|
||||
const bool result = (*reinterpret_cast<origSaveMirror>(
|
||||
g_pSaveMirrorHook->m_original))(thisptr, box);
|
||||
thisptr->m_applyFinalShader = saved;
|
||||
return result;
|
||||
}
|
||||
|
||||
static void installShader() {
|
||||
auto* gl = g_pHyprOpenGL.get();
|
||||
if (!gl) return;
|
||||
|
||||
ensureShaderFile(shaderPath(), defaultShaderSource());
|
||||
gl->applyScreenShader(shaderPath().string());
|
||||
|
||||
auto* shader = gl->m_finalScreenShader.get();
|
||||
if (shader && shader->program() >= 1 &&
|
||||
shader->getUniformLocation(SHADER_TINT) != -1) {
|
||||
g_uniformMode = true;
|
||||
} else {
|
||||
g_uniformMode = false;
|
||||
if (!g_modeDecided)
|
||||
dlog(
|
||||
"shader has no 'tint' uniform, falling back to "
|
||||
"per-step recompiled shaders");
|
||||
}
|
||||
g_modeDecided = true;
|
||||
}
|
||||
|
||||
static void setUniformGain(const std::array<float, 3>& gain) {
|
||||
auto* gl = g_pHyprOpenGL.get();
|
||||
auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr;
|
||||
if (!gl || !shader) return;
|
||||
|
||||
gl->useShader(gl->m_finalScreenShader);
|
||||
shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]);
|
||||
}
|
||||
|
||||
static void hkBegin(
|
||||
CHyprOpenGLImpl* thisptr,
|
||||
PHLMONITOR mon,
|
||||
const CRegion& damage,
|
||||
SP<IFramebuffer> fb,
|
||||
std::optional<CRegion> finalDamage) {
|
||||
(*reinterpret_cast<origBegin>(
|
||||
g_pBeginHook->m_original))(thisptr, mon, damage, fb, finalDamage);
|
||||
|
||||
if (fb) {
|
||||
auto* renderer = g_pHyprRenderer.get();
|
||||
if (renderer) renderer->m_renderData.blockScreenShader = true;
|
||||
}
|
||||
|
||||
if (!g_active) return;
|
||||
|
||||
auto* gl = g_pHyprOpenGL.get();
|
||||
if (!gl) return;
|
||||
|
||||
if (gl->m_finalScreenShader.get() && gl->m_finalScreenShader->program() < 1)
|
||||
installShader();
|
||||
if (!gl->m_finalScreenShader.get() ||
|
||||
gl->m_finalScreenShader->program() < 1)
|
||||
return;
|
||||
|
||||
if (g_transition.active) {
|
||||
auto* monPtr = mon.get();
|
||||
if (!monPtr) return;
|
||||
const float elapsed =
|
||||
std::chrono::duration<float>(
|
||||
std::chrono::steady_clock::now() - g_transition.start)
|
||||
.count();
|
||||
const float t =
|
||||
std::clamp(elapsed / g_transition.durationSec, 0.f, 1.f);
|
||||
|
||||
for (size_t i = 0; i < 3; ++i)
|
||||
g_currentGain[i] =
|
||||
g_transition.fromGain[i] +
|
||||
(g_transition.toGain[i] - g_transition.fromGain[i]) * t;
|
||||
|
||||
if (g_uniformMode) {
|
||||
setUniformGain(g_currentGain);
|
||||
} else {
|
||||
const int step = static_cast<int>(t * BAKED_STEPS);
|
||||
if (step != g_transition.lastBakedStep) {
|
||||
g_transition.lastBakedStep = step;
|
||||
ensureShaderFile(
|
||||
bakedShaderPath(g_currentGain),
|
||||
bakedShaderSource(g_currentGain));
|
||||
gl->applyScreenShader(bakedShaderPath(g_currentGain).string());
|
||||
}
|
||||
}
|
||||
|
||||
if (t >= 1.f) {
|
||||
g_transition.active = false;
|
||||
if (gainIsNeutral(g_currentGain)) {
|
||||
g_active = false;
|
||||
gl->applyScreenShader("");
|
||||
}
|
||||
postStateEvent(g_active, false);
|
||||
} else {
|
||||
monPtr->m_forceFullFrames = 2;
|
||||
monPtr->scheduleFrame();
|
||||
}
|
||||
} else if (g_uniformMode) {
|
||||
setUniformGain(g_currentGain);
|
||||
}
|
||||
}
|
||||
|
||||
static constexpr int KELVIN_MIN = 2000;
|
||||
static constexpr int KELVIN_MAX = 6500;
|
||||
|
||||
static int luaNightlightSet(lua_State* L) {
|
||||
const int rawKelvin = static_cast<int>(luaL_checknumber(L, 1));
|
||||
const float durationSec =
|
||||
lua_gettop(L) >= 2 ? static_cast<float>(luaL_checknumber(L, 2)) : 0.4f;
|
||||
|
||||
const int targetKelvin = std::clamp(rawKelvin, KELVIN_MIN, KELVIN_MAX);
|
||||
if (targetKelvin != rawKelvin)
|
||||
dlog(
|
||||
"clamped " + std::to_string(rawKelvin) + "K to " +
|
||||
std::to_string(targetKelvin) + "K");
|
||||
|
||||
g_transition.fromGain = g_currentGain;
|
||||
g_transition.toGain = kelvinToGain(targetKelvin);
|
||||
g_transition.active = true;
|
||||
g_transition.start = std::chrono::steady_clock::now();
|
||||
g_transition.durationSec = durationSec;
|
||||
g_transition.lastBakedStep = -1;
|
||||
g_active = true;
|
||||
|
||||
g_lastKelvin = g_currentKelvin;
|
||||
g_currentKelvin = targetKelvin;
|
||||
|
||||
postStateEvent(true, true);
|
||||
|
||||
lua_pushinteger(L, targetKelvin);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int luaNightlightDisable(lua_State* L) {
|
||||
if (!g_active) return 0;
|
||||
|
||||
const float durationSec =
|
||||
lua_gettop(L) >= 1 ? static_cast<float>(luaL_checknumber(L, 1)) : 0.4f;
|
||||
|
||||
g_transition.fromGain = g_currentGain;
|
||||
g_transition.toGain = {1.f, 1.f, 1.f};
|
||||
g_transition.active = true;
|
||||
g_transition.start = std::chrono::steady_clock::now();
|
||||
g_transition.durationSec = durationSec;
|
||||
g_transition.lastBakedStep = -1;
|
||||
|
||||
g_lastKelvin = g_currentKelvin;
|
||||
g_currentKelvin = 6500;
|
||||
|
||||
postStateEvent(false, true);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int luaNightlightState(lua_State* L) {
|
||||
lua_pushstring(
|
||||
L,
|
||||
std::format(
|
||||
"{{\"enabled\":{},\"animating\":{},\"last\":{},\"current\":{}}}",
|
||||
g_active ? "true" : "false",
|
||||
g_transition.active ? "true" : "false",
|
||||
g_lastKelvin,
|
||||
g_currentKelvin)
|
||||
.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void postStateEvent(bool enabled, bool animating) {
|
||||
if (!g_pEventManager) return;
|
||||
g_pEventManager->postEvent(
|
||||
SHyprIPCEvent{
|
||||
.event = "zshell-nightlight",
|
||||
.data = std::format(
|
||||
"{},{},{},{}",
|
||||
enabled ? 1 : 0,
|
||||
animating ? 1 : 0,
|
||||
g_lastKelvin,
|
||||
g_currentKelvin)});
|
||||
}
|
||||
|
||||
static const SFunctionMatch* findExactMethod(
|
||||
const std::vector<SFunctionMatch>& methods,
|
||||
const std::string& classAndMethod) {
|
||||
for (const auto& m : methods) {
|
||||
if (m.signature.find(classAndMethod) != std::string::npos) return &m;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static std::string manglePrefix(
|
||||
const std::string& className, const std::string& methodName) {
|
||||
return std::to_string(className.size()) + className +
|
||||
std::to_string(methodName.size()) + methodName;
|
||||
}
|
||||
|
||||
APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) {
|
||||
PHANDLE = handle;
|
||||
initEnvPaths();
|
||||
dlog("=== PLUGIN_INIT, build timestamp " __DATE__ " " __TIME__ " ===");
|
||||
|
||||
const std::string serverHash = __hyprland_api_get_hash();
|
||||
const std::string clientHash = __hyprland_api_get_client_hash();
|
||||
|
||||
if (serverHash != clientHash) {
|
||||
HyprlandAPI::addNotification(
|
||||
PHANDLE,
|
||||
"[zshell-nightlight] Mismatched headers! Can't proceed.",
|
||||
CHyprColor{1.0f, 0.2f, 0.2f, 1.0f},
|
||||
5000);
|
||||
throw std::runtime_error("[zshell-nightlight] version mismatch");
|
||||
}
|
||||
|
||||
static const auto SAVEMIRROR_METHODS =
|
||||
HyprlandAPI::findFunctionsByName(PHANDLE, "saveBufferForMirror");
|
||||
const SFunctionMatch* saveMirrorTarget = findExactMethod(
|
||||
SAVEMIRROR_METHODS,
|
||||
manglePrefix("CHyprOpenGLImpl", "saveBufferForMirror"));
|
||||
|
||||
if (saveMirrorTarget) {
|
||||
g_pSaveMirrorHook = HyprlandAPI::createFunctionHook(
|
||||
PHANDLE,
|
||||
saveMirrorTarget->address,
|
||||
reinterpret_cast<void*>(&hkSaveBufferForMirror));
|
||||
if (g_pSaveMirrorHook && g_pSaveMirrorHook->hook())
|
||||
dlog(
|
||||
"saveBufferForMirror hook installed: " +
|
||||
saveMirrorTarget->signature);
|
||||
}
|
||||
|
||||
if (!g_pSaveMirrorHook)
|
||||
HyprlandAPI::addNotification(
|
||||
PHANDLE,
|
||||
"[zshell-nightlight] WARNING: couldn't hook saveBufferForMirror — "
|
||||
"captures may still show the nightlight",
|
||||
CHyprColor{1.f, 0.6f, 0.2f, 1.f},
|
||||
8000);
|
||||
|
||||
static const auto METHODS =
|
||||
HyprlandAPI::findFunctionsByName(PHANDLE, "begin");
|
||||
const SFunctionMatch* target =
|
||||
findExactMethod(METHODS, manglePrefix("CHyprOpenGLImpl", "begin"));
|
||||
if (!target)
|
||||
throw std::runtime_error(
|
||||
"[zshell-nightlight] couldn't uniquely resolve "
|
||||
"CHyprOpenGLImpl::begin");
|
||||
|
||||
g_pBeginHook = HyprlandAPI::createFunctionHook(
|
||||
PHANDLE, target->address, reinterpret_cast<void*>(&hkBegin));
|
||||
if (!g_pBeginHook || !g_pBeginHook->hook())
|
||||
throw std::runtime_error("[zshell-nightlight] failed to hook begin()");
|
||||
|
||||
if (!HyprlandAPI::addLuaFunction(
|
||||
PHANDLE, "zshell", "nlSet", luaNightlightSet))
|
||||
throw std::runtime_error(
|
||||
"[zshell-nightlight] failed to register Lua function");
|
||||
|
||||
if (!HyprlandAPI::addLuaFunction(
|
||||
PHANDLE, "zshell", "nlDisable", luaNightlightDisable))
|
||||
throw std::runtime_error(
|
||||
"[zshell-nightlight] failed to register Lua function");
|
||||
|
||||
if (!HyprlandAPI::addLuaFunction(
|
||||
PHANDLE, "zshell", "nlState", luaNightlightState))
|
||||
throw std::runtime_error(
|
||||
"[zshell-nightlight] failed to register Lua function");
|
||||
|
||||
return {
|
||||
"zshell-nightlight",
|
||||
"Animated nightlight, excluded from screencopy",
|
||||
"ZShell",
|
||||
"1.2"};
|
||||
}
|
||||
|
||||
|
||||
APICALL EXPORT std::string PLUGIN_API_VERSION() {
|
||||
return HYPRLAND_API_VERSION;
|
||||
}
|
||||
|
||||
|
||||
APICALL EXPORT void PLUGIN_EXIT() {}
|
||||
@@ -288,6 +288,7 @@ qml_module(ZShell-llm
|
||||
chat.hpp chat.cpp
|
||||
chatstore.hpp chatstore.cpp
|
||||
codehighlighter.hpp codehighlighter.cpp
|
||||
filetool.hpp filetool.cpp
|
||||
generation.hpp generation.cpp
|
||||
llmclient.hpp llmclient.cpp
|
||||
markdownblock.hpp
|
||||
|
||||
+19
-18
@@ -1,6 +1,7 @@
|
||||
#include "chat.hpp"
|
||||
|
||||
#include "config.hpp"
|
||||
#include "filetool.hpp"
|
||||
#include "llm.hpp"
|
||||
#include "llmclient.hpp"
|
||||
#include "webfetchtool.hpp"
|
||||
@@ -10,12 +11,14 @@
|
||||
namespace ZShell::llm {
|
||||
|
||||
Chat::Chat(QObject* parent)
|
||||
: QObject(parent), m_store(new ChatStore(this)), m_client(new LlmClient(this)) {
|
||||
if (!config::Config::instance())
|
||||
new config::Config();
|
||||
: QObject(parent)
|
||||
, m_store(new ChatStore(this))
|
||||
, m_client(new LlmClient(this)) {
|
||||
if (!config::Config::instance()) new config::Config();
|
||||
|
||||
m_store->setLlmClient(m_client);
|
||||
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
|
||||
m_client->tools()->registerTool(new FileReadTool(m_client->tools()));
|
||||
|
||||
const auto* llm = config::Config::instance()->llm();
|
||||
m_client->setEndpoint(llm->endpoint());
|
||||
@@ -37,17 +40,14 @@ Chat::Chat(QObject* parent)
|
||||
});
|
||||
|
||||
connect(m_client, &LlmClient::busyChanged, this, [this]() {
|
||||
// A fresh run supersedes the previous error.
|
||||
if (m_client->busy() && !m_lastError.isEmpty()) {
|
||||
m_lastError.clear();
|
||||
Q_EMIT lastErrorChanged();
|
||||
}
|
||||
Q_EMIT busyChanged();
|
||||
});
|
||||
connect(
|
||||
m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged);
|
||||
connect(
|
||||
m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
|
||||
connect(m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged);
|
||||
connect(m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::availableModelsChanged,
|
||||
@@ -97,8 +97,8 @@ Chat::Chat(QObject* parent)
|
||||
&LlmClient::iconSuggested,
|
||||
this,
|
||||
[this](ChatSession* session, const QString& icon) {
|
||||
qInfo() << "Chat: applying generated icon" << session->id()
|
||||
<< icon << "(was" << session->icon() << ")";
|
||||
qInfo() << "Chat: applying generated icon" << session->id() << icon
|
||||
<< "(was" << session->icon() << ")";
|
||||
session->setIcon(icon);
|
||||
m_store->saveMeta(session);
|
||||
});
|
||||
@@ -138,11 +138,11 @@ QString Chat::streamingChatId() const {
|
||||
return m_client->streamingChatId();
|
||||
}
|
||||
|
||||
|
||||
Chat* Chat::s_instance = nullptr;
|
||||
|
||||
Chat* Chat::create(QQmlEngine*, QJSEngine*) {
|
||||
if (!s_instance)
|
||||
s_instance = new Chat();
|
||||
if (!s_instance) s_instance = new Chat();
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
@@ -151,8 +151,7 @@ void Chat::stop() {
|
||||
}
|
||||
|
||||
void Chat::dismissError() {
|
||||
if (m_lastError.isEmpty())
|
||||
return;
|
||||
if (m_lastError.isEmpty()) return;
|
||||
m_lastError.clear();
|
||||
Q_EMIT lastErrorChanged();
|
||||
}
|
||||
@@ -161,12 +160,14 @@ void Chat::refreshModels() {
|
||||
m_client->refreshModels();
|
||||
}
|
||||
|
||||
void Chat::refreshFromServer() {
|
||||
m_client->refreshFromServer();
|
||||
}
|
||||
|
||||
void Chat::selectModel(const QString& id) {
|
||||
if (id.isEmpty())
|
||||
return;
|
||||
if (id.isEmpty()) return;
|
||||
m_client->setModel(id);
|
||||
if (auto* config = config::Config::instance())
|
||||
config->llm()->set_model(id);
|
||||
if (auto* config = config::Config::instance()) config->llm()->set_model(id);
|
||||
}
|
||||
|
||||
} // namespace ZShell::llm
|
||||
|
||||
@@ -14,9 +14,6 @@ namespace ZShell::llm {
|
||||
|
||||
class LlmClient;
|
||||
|
||||
// QML-facing facade. Persistence lives in ChatStore, network streaming in
|
||||
// LlmClient; this class only wires them together and exposes the
|
||||
// application-wide state.
|
||||
class Chat : public QObject {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
@@ -25,12 +22,18 @@ class Chat : public QObject {
|
||||
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
|
||||
Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged)
|
||||
Q_PROPERTY(QString model READ model NOTIFY modelChanged)
|
||||
Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged)
|
||||
Q_PROPERTY(
|
||||
QStringList availableModels READ availableModels NOTIFY
|
||||
availableModelsChanged)
|
||||
Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged)
|
||||
Q_PROPERTY(bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY toolsEnabledChanged)
|
||||
Q_PROPERTY(
|
||||
bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY
|
||||
toolsEnabledChanged)
|
||||
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
|
||||
Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT)
|
||||
Q_PROPERTY(QString streamingChatId READ streamingChatId NOTIFY streamingChatIdChanged)
|
||||
Q_PROPERTY(
|
||||
QString streamingChatId READ streamingChatId NOTIFY
|
||||
streamingChatIdChanged)
|
||||
|
||||
public:
|
||||
explicit Chat(QObject* parent = nullptr);
|
||||
@@ -49,6 +52,7 @@ class Chat : public QObject {
|
||||
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*);
|
||||
|
||||
@@ -37,24 +37,16 @@ QString segmentTypeName(LlmSegment::Type type) {
|
||||
}
|
||||
|
||||
LlmSegment::Type segmentTypeFromName(const QString& name) {
|
||||
if (name == QLatin1String("tool_call"))
|
||||
return LlmSegment::Type::ToolCall;
|
||||
if (name == QLatin1String("content"))
|
||||
return LlmSegment::Type::Content;
|
||||
if (name == QLatin1String("tool_call")) return LlmSegment::Type::ToolCall;
|
||||
if (name == QLatin1String("content")) return LlmSegment::Type::Content;
|
||||
return LlmSegment::Type::Reasoning;
|
||||
}
|
||||
|
||||
// A null QString binds as SQL NULL, which violates the NOT NULL columns;
|
||||
// DEFAULT only applies to omitted columns, not explicit NULLs.
|
||||
QString sqlText(const QString& value) {
|
||||
if (value.isNull())
|
||||
return QStringLiteral("");
|
||||
if (value.isNull()) return QStringLiteral("");
|
||||
return value;
|
||||
}
|
||||
|
||||
// Plain data for one session's messages, fetched on a worker thread
|
||||
// and turned into the QObject tree on the GUI thread. Messages are
|
||||
// ordered as the model displays them (newest first).
|
||||
struct SegmentRow {
|
||||
QString type;
|
||||
QString text;
|
||||
@@ -88,8 +80,7 @@ ChatStore::ChatStore(QObject* parent)
|
||||
}
|
||||
|
||||
ChatStore::~ChatStore() {
|
||||
if (m_connectionName.isEmpty())
|
||||
return;
|
||||
if (m_connectionName.isEmpty()) return;
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
|
||||
db.close();
|
||||
QSqlDatabase::removeDatabase(m_connectionName);
|
||||
@@ -119,8 +110,7 @@ void ChatStore::openDb() {
|
||||
}
|
||||
{
|
||||
QSqlQuery query(db);
|
||||
query.exec(
|
||||
QStringLiteral(
|
||||
query.exec(QStringLiteral(
|
||||
"CREATE TABLE IF NOT EXISTS sessions (\n"
|
||||
" id TEXT PRIMARY KEY,\n"
|
||||
" title TEXT NOT NULL DEFAULT '',\n"
|
||||
@@ -132,8 +122,7 @@ void ChatStore::openDb() {
|
||||
}
|
||||
{
|
||||
QSqlQuery query(db);
|
||||
query.exec(
|
||||
QStringLiteral(
|
||||
query.exec(QStringLiteral(
|
||||
"CREATE TABLE IF NOT EXISTS messages (\n"
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
|
||||
" session_id TEXT NOT NULL REFERENCES sessions (id) "
|
||||
@@ -144,8 +133,7 @@ void ChatStore::openDb() {
|
||||
}
|
||||
{
|
||||
QSqlQuery query(db);
|
||||
query.exec(
|
||||
QStringLiteral(
|
||||
query.exec(QStringLiteral(
|
||||
"CREATE TABLE IF NOT EXISTS generations (\n"
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
|
||||
" message_id INTEGER NOT NULL REFERENCES messages "
|
||||
@@ -156,8 +144,7 @@ void ChatStore::openDb() {
|
||||
}
|
||||
{
|
||||
QSqlQuery query(db);
|
||||
query.exec(
|
||||
QStringLiteral(
|
||||
query.exec(QStringLiteral(
|
||||
"CREATE TABLE IF NOT EXISTS segments (\n"
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
|
||||
" generation_id INTEGER NOT NULL REFERENCES generations "
|
||||
@@ -200,8 +187,7 @@ QVariantList ChatStore::values() const {
|
||||
}
|
||||
|
||||
ChatSession* ChatStore::at(int index) const {
|
||||
if (index < 0 || index >= m_sessions.size())
|
||||
return nullptr;
|
||||
if (index < 0 || index >= m_sessions.size()) return nullptr;
|
||||
return m_sessions.at(index);
|
||||
}
|
||||
|
||||
@@ -238,8 +224,7 @@ void ChatStore::remove(ChatSession* chat) {
|
||||
}
|
||||
|
||||
void ChatStore::removeSession(ChatSession* session) {
|
||||
if (!session || !m_sessions.contains(session))
|
||||
return;
|
||||
if (!session || !m_sessions.contains(session)) return;
|
||||
const QList<ChatSession*> before = m_sessions;
|
||||
Q_EMIT sessionRemoved(session);
|
||||
{
|
||||
@@ -269,8 +254,7 @@ void ChatStore::clear() {
|
||||
|
||||
ChatSession* ChatStore::sessionById(const QString& id) {
|
||||
for (auto* session : m_sessions)
|
||||
if (session->id() == id)
|
||||
return session;
|
||||
if (session->id() == id) return session;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -279,25 +263,21 @@ void ChatStore::setLlmClient(LlmClient* client) {
|
||||
}
|
||||
|
||||
void ChatStore::persist(ChatSession* session) {
|
||||
if (!session || !m_sessions.contains(session))
|
||||
return;
|
||||
if (!session || !m_sessions.contains(session)) return;
|
||||
if (!session->isLoaded()) {
|
||||
// Saving now would persist an incomplete model and wipe the
|
||||
// stored history; run it again when the load lands.
|
||||
m_pendingPersists.insert(session);
|
||||
return;
|
||||
}
|
||||
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
|
||||
if (!saveSession(session))
|
||||
return;
|
||||
if (!saveSession(session)) return;
|
||||
sortAndNotify();
|
||||
}
|
||||
|
||||
void ChatStore::saveMeta(ChatSession* session) {
|
||||
if (!session || !m_sessions.contains(session))
|
||||
return;
|
||||
if (!session || !m_sessions.contains(session)) return;
|
||||
QSqlQuery query(db());
|
||||
query.prepare("UPDATE sessions SET title = :title, icon = :icon "
|
||||
query.prepare(
|
||||
"UPDATE sessions SET title = :title, icon = :icon "
|
||||
"WHERE id = :id");
|
||||
query.bindValue(":title", sqlText(session->title()));
|
||||
query.bindValue(":icon", sqlText(session->icon()));
|
||||
@@ -338,18 +318,18 @@ bool ChatStore::saveSession(ChatSession* session) {
|
||||
"INSERT INTO messages (session_id, role, timestamp) "
|
||||
"VALUES (:id, :role, :timestamp)");
|
||||
QSqlQuery generationInsert(handle);
|
||||
ok = ok && generationInsert.prepare(
|
||||
ok = ok &&
|
||||
generationInsert.prepare(
|
||||
"INSERT INTO generations (message_id, timestamp, is_active) "
|
||||
"VALUES (:mid, :timestamp, :is_active)");
|
||||
QSqlQuery segmentInsert(handle);
|
||||
ok = ok && segmentInsert.prepare(
|
||||
ok = ok &&
|
||||
segmentInsert.prepare(
|
||||
"INSERT INTO segments (generation_id, type, text, name, "
|
||||
"tool_call_id, arguments, result, status, elapsed_ms, "
|
||||
"timestamp) VALUES (:gid, :type, :text, :name, "
|
||||
":tool_call_id, :arguments, :result, :status, :elapsed_ms, "
|
||||
":timestamp)");
|
||||
// The model holds messages most recent first; the database keeps
|
||||
// natural rowid order, so iterate from the oldest row up.
|
||||
const auto* model = session->messagesModel();
|
||||
for (int row = model->rowCount() - 1; ok && row >= 0; --row) {
|
||||
const auto* message = model->at(row);
|
||||
@@ -395,13 +375,12 @@ bool ChatStore::saveSession(ChatSession* session) {
|
||||
":tool_call_id", sqlText(segment->toolCallId()));
|
||||
segmentInsert.bindValue(
|
||||
":arguments", sqlText(segment->arguments()));
|
||||
segmentInsert.bindValue(":result", sqlText(segment->result()));
|
||||
segmentInsert.bindValue(
|
||||
":result", sqlText(segment->result()));
|
||||
segmentInsert.bindValue(
|
||||
":status", static_cast<int>(segment->status()));
|
||||
segmentInsert.bindValue(
|
||||
":elapsed_ms", segment->elapsedMs());
|
||||
segmentInsert.bindValue(
|
||||
":timestamp", segment->timestamp());
|
||||
segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs());
|
||||
segmentInsert.bindValue(":timestamp", segment->timestamp());
|
||||
if (!segmentInsert.exec()) {
|
||||
ok = false;
|
||||
qWarning() << "ChatStore: saveSession" << id
|
||||
@@ -414,53 +393,47 @@ bool ChatStore::saveSession(ChatSession* session) {
|
||||
}
|
||||
}
|
||||
if (!ok || !handle.commit()) {
|
||||
qWarning() << "ChatStore: saveSession" << id << "commit failed, rolling back";
|
||||
qWarning() << "ChatStore: saveSession" << id
|
||||
<< "commit failed, rolling back";
|
||||
handle.rollback();
|
||||
ok = false;
|
||||
qWarning() << "ChatStore: failed to save session" << session->id() << ":"
|
||||
<< handle.lastError().text();
|
||||
qWarning() << "ChatStore: failed to save session" << session->id()
|
||||
<< ":" << handle.lastError().text();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
void ChatStore::loadMessagesInto(ChatSession* session) {
|
||||
if (!session)
|
||||
return;
|
||||
if (!session) return;
|
||||
const QString sessionId = session->id();
|
||||
const QString path = m_dbPath;
|
||||
|
||||
// SQL on a worker thread (its own connection; QSqlDatabase objects
|
||||
// are thread-affine). Rows come back as plain data.
|
||||
QThreadPool::globalInstance()->start(
|
||||
[store = QPointer<ChatStore>(this),
|
||||
session = QPointer<ChatSession>(session), sessionId, path]() {
|
||||
QThreadPool::globalInstance()->start([store = QPointer<ChatStore>(this),
|
||||
session =
|
||||
QPointer<ChatSession>(session),
|
||||
sessionId,
|
||||
path]() {
|
||||
QList<MessageRow> rows;
|
||||
const QString connName = QUuid::createUuid().toString();
|
||||
{
|
||||
QSqlDatabase db = QSqlDatabase::addDatabase(
|
||||
QStringLiteral("QSQLITE"), connName);
|
||||
QSqlDatabase db =
|
||||
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName);
|
||||
db.setDatabaseName(path);
|
||||
if (db.open()) {
|
||||
// Tolerate the GUI thread writing while we read.
|
||||
QSqlQuery busy(db);
|
||||
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
|
||||
// Newest first so the model receives rows in
|
||||
// display order.
|
||||
QSqlQuery query(db);
|
||||
query.prepare(
|
||||
"SELECT id, role, timestamp FROM messages "
|
||||
"WHERE session_id = :id ORDER BY rowid DESC");
|
||||
query.bindValue(":id", sessionId);
|
||||
if (!query.exec()) {
|
||||
qWarning()
|
||||
<< "ChatStore: failed to load messages for"
|
||||
<< sessionId << ":"
|
||||
<< query.lastError().text();
|
||||
qWarning() << "ChatStore: failed to load messages for"
|
||||
<< sessionId << ":" << query.lastError().text();
|
||||
} else {
|
||||
while (query.next()) {
|
||||
const int messageId = query.value(0).toInt();
|
||||
MessageRow message;
|
||||
message.user =
|
||||
query.value(1).toString() ==
|
||||
message.user = query.value(1).toString() ==
|
||||
QLatin1String("user");
|
||||
message.timestamp = query.value(2).toLongLong();
|
||||
QSqlQuery generationQuery(db);
|
||||
@@ -483,8 +456,7 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
|
||||
"timestamp FROM segments WHERE "
|
||||
"generation_id = :gid ORDER BY rowid");
|
||||
segmentQuery.bindValue(
|
||||
":gid",
|
||||
generationQuery.value(0).toInt());
|
||||
":gid", generationQuery.value(0).toInt());
|
||||
if (segmentQuery.exec()) {
|
||||
while (segmentQuery.next()) {
|
||||
SegmentRow segment;
|
||||
@@ -530,32 +502,21 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
|
||||
}
|
||||
db.close();
|
||||
} else {
|
||||
qWarning()
|
||||
<< "ChatStore: failed to open database for load:"
|
||||
qWarning() << "ChatStore: failed to open database for load:"
|
||||
<< db.lastError().text();
|
||||
}
|
||||
}
|
||||
// Remove only once every QSqlDatabase copy and query is gone;
|
||||
// while any reference is alive Qt refuses the removal and the
|
||||
// connection is left dangling in a broken state.
|
||||
QSqlDatabase::removeDatabase(connName);
|
||||
|
||||
// Build the object tree on the GUI thread. Deliver through
|
||||
// the app instance (never destroyed) and re-check the
|
||||
// pointers there: posting to `store` from the pool thread
|
||||
// would race with its destruction.
|
||||
QMetaObject::invokeMethod(
|
||||
QCoreApplication::instance(),
|
||||
[store, session, rows = std::move(rows)]() mutable {
|
||||
ChatStore* st = store;
|
||||
ChatSession* s = session;
|
||||
if (!st || !s)
|
||||
return;
|
||||
if (!st || !s) return;
|
||||
|
||||
// Rows fetched from disk; newest first.
|
||||
auto* model = s->model();
|
||||
if (!model)
|
||||
return;
|
||||
if (!model) return;
|
||||
QList<ChatMessage*> messages;
|
||||
for (const MessageRow& row : rows) {
|
||||
auto* message = model->createMessage(
|
||||
@@ -585,14 +546,11 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
|
||||
segment->restore(segmentRow.elapsedMs);
|
||||
generation->addSegment(segment);
|
||||
}
|
||||
if (generationRow.active)
|
||||
activeIndex = i;
|
||||
if (generationRow.active) activeIndex = i;
|
||||
}
|
||||
message->setActiveGeneration(activeIndex);
|
||||
messages.append(message);
|
||||
}
|
||||
// Rows added live while the load was in flight are
|
||||
// newer than anything on disk; keep them in front.
|
||||
if (model->rowCount() > 0) {
|
||||
QList<ChatMessage*> live = messages;
|
||||
for (int r = 0; r < model->rowCount(); ++r)
|
||||
@@ -602,16 +560,9 @@ void ChatStore::loadMessagesInto(ChatSession* session) {
|
||||
if (!messages.isEmpty() || model->rowCount() > 0)
|
||||
s->adoptMessages(messages);
|
||||
|
||||
// Mark loaded only once the model holds both the
|
||||
// fetched history and the rows added live while the
|
||||
// load ran, so a deferred startGeneration (triggered
|
||||
// by loaded()) builds its context from the complete
|
||||
// conversation.
|
||||
s->markLoaded();
|
||||
|
||||
if (s->takeClearPending()) {
|
||||
// Cleared while the load was in flight; drop
|
||||
// everything now that the model is populated.
|
||||
s->clear();
|
||||
} else if (st->m_pendingPersists.remove(s)) {
|
||||
st->persist(s);
|
||||
@@ -647,22 +598,18 @@ void ChatStore::sortAndNotify() {
|
||||
m_sessions.begin(),
|
||||
m_sessions.end(),
|
||||
[](const ChatSession* a, const ChatSession* b) {
|
||||
if (a->pinned() != b->pinned())
|
||||
return a->pinned() > b->pinned();
|
||||
if (a->pinned() != b->pinned()) return a->pinned() > b->pinned();
|
||||
return a->updatedAtMs() > b->updatedAtMs();
|
||||
});
|
||||
notify(before);
|
||||
}
|
||||
|
||||
void ChatStore::notify(const QList<ChatSession*>& before) {
|
||||
if (before.size() != m_sessions.size())
|
||||
Q_EMIT countChanged();
|
||||
if (before.size() != m_sessions.size()) Q_EMIT countChanged();
|
||||
bool same = before.size() == m_sessions.size();
|
||||
for (int i = 0; same && i < m_sessions.size(); ++i)
|
||||
if (before.at(i) != m_sessions.at(i))
|
||||
same = false;
|
||||
if (!same)
|
||||
Q_EMIT valuesChanged();
|
||||
if (before.at(i) != m_sessions.at(i)) same = false;
|
||||
if (!same) Q_EMIT valuesChanged();
|
||||
}
|
||||
|
||||
} // namespace ZShell::llm
|
||||
|
||||
@@ -39,9 +39,6 @@ class ChatStore : public QObject {
|
||||
|
||||
void persist(ChatSession* session);
|
||||
void saveMeta(ChatSession* session);
|
||||
// Loads the session's messages from the database. The SQL runs on a
|
||||
// worker thread; the object tree is built and the model updated on
|
||||
// the GUI thread when it arrives (ChatSession::loaded).
|
||||
void loadMessagesInto(ChatSession* session);
|
||||
|
||||
Q_SIGNALS:
|
||||
@@ -61,8 +58,6 @@ class ChatStore : public QObject {
|
||||
LlmClient* m_llmClient = nullptr;
|
||||
QString m_connectionName;
|
||||
QString m_dbPath;
|
||||
// Sessions whose persist() ran before their messages finished
|
||||
// loading; persisted once the load lands.
|
||||
QSet<ChatSession*> m_pendingPersists;
|
||||
|
||||
[[nodiscard]] QSqlDatabase db() const;
|
||||
|
||||
@@ -21,7 +21,6 @@ namespace ZShell::llm {
|
||||
|
||||
namespace hl {
|
||||
|
||||
// Role ids; 0 means "no color".
|
||||
enum Role : uint8_t {
|
||||
None = 0,
|
||||
Comment,
|
||||
@@ -43,29 +42,43 @@ enum Role : uint8_t {
|
||||
|
||||
const char* roleName(Role role) {
|
||||
switch (role) {
|
||||
case Comment: return "comment";
|
||||
case String: return "string";
|
||||
case StringKey: return "string.key";
|
||||
case Number: return "number";
|
||||
case Constant: return "constant";
|
||||
case Keyword: return "keyword";
|
||||
case Type: return "type";
|
||||
case Function: return "function";
|
||||
case Method: return "method";
|
||||
case Macro: return "macro";
|
||||
case Preproc: return "preproc";
|
||||
case Operator: return "operator";
|
||||
case Property: return "property";
|
||||
case Label: return "label";
|
||||
case Attribute: return "attribute";
|
||||
default: return "";
|
||||
case Comment:
|
||||
return "comment";
|
||||
case String:
|
||||
return "string";
|
||||
case StringKey:
|
||||
return "string.key";
|
||||
case Number:
|
||||
return "number";
|
||||
case Constant:
|
||||
return "constant";
|
||||
case Keyword:
|
||||
return "keyword";
|
||||
case Type:
|
||||
return "type";
|
||||
case Function:
|
||||
return "function";
|
||||
case Method:
|
||||
return "method";
|
||||
case Macro:
|
||||
return "macro";
|
||||
case Preproc:
|
||||
return "preproc";
|
||||
case Operator:
|
||||
return "operator";
|
||||
case Property:
|
||||
return "property";
|
||||
case Label:
|
||||
return "label";
|
||||
case Attribute:
|
||||
return "attribute";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
using LanguageFn = const TSLanguage* (*)();
|
||||
|
||||
// Grammar registry generated by CMake from the installed grammars and
|
||||
// their highlight queries (see CMakeLists.txt).
|
||||
const QHash<QString, CodeHighlighter::Grammar>& grammars() {
|
||||
static const QHash<QString, CodeHighlighter::Grammar> grammars = [] {
|
||||
QHash<QString, CodeHighlighter::Grammar> map;
|
||||
@@ -89,8 +102,6 @@ const QHash<QString, CodeHighlighter::Grammar>& grammars() {
|
||||
CodeHighlighter* CodeHighlighter::s_instance = nullptr;
|
||||
|
||||
const QHash<QString, QString>& CodeHighlighter::aliases() {
|
||||
// Language tags as written in code fences (and common variants) to
|
||||
// grammar id.
|
||||
static const QHash<QString, QString> aliases = [] {
|
||||
QHash<QString, QString> map;
|
||||
map.insert("c", "c");
|
||||
@@ -145,49 +156,38 @@ const QHash<QString, QString>& CodeHighlighter::aliases() {
|
||||
|
||||
uint8_t CodeHighlighter::roleFor(const char* name, uint32_t length) {
|
||||
const QString n = QString::fromUtf8(name, length);
|
||||
if (n == "comment")
|
||||
return hl::Role::Comment;
|
||||
if (n == "comment") return hl::Role::Comment;
|
||||
if (n.startsWith("string"))
|
||||
return n == "string.special.key" ? hl::Role::StringKey : hl::Role::String;
|
||||
if (n == "escape" || n == "regexp")
|
||||
return hl::Role::String;
|
||||
if (n.startsWith("number"))
|
||||
return hl::Role::Number;
|
||||
if (n.startsWith("constant") || n == "boolean" || n == "bool"
|
||||
|| n.startsWith("character"))
|
||||
return n == "string.special.key" ? hl::Role::StringKey
|
||||
: hl::Role::String;
|
||||
if (n == "escape" || n == "regexp") return hl::Role::String;
|
||||
if (n.startsWith("number")) return hl::Role::Number;
|
||||
if (n.startsWith("constant") || n == "boolean" || n == "bool" ||
|
||||
n.startsWith("character"))
|
||||
return hl::Role::Constant;
|
||||
if (n.startsWith("keyword"))
|
||||
return hl::Role::Keyword;
|
||||
if (n == "type" || n.startsWith("type."))
|
||||
if (n.startsWith("keyword")) return hl::Role::Keyword;
|
||||
if (n == "type" || n.startsWith("type.")) return hl::Role::Type;
|
||||
if (n.startsWith("namespace") || n.startsWith("module") ||
|
||||
n == "support.type" || n == "support.namespace")
|
||||
return hl::Role::Type;
|
||||
if (n.startsWith("namespace") || n.startsWith("module")
|
||||
|| n == "support.type" || n == "support.namespace")
|
||||
return hl::Role::Type;
|
||||
if (n.startsWith("function") || n == "constructor"
|
||||
|| n.startsWith("support.function"))
|
||||
if (n.startsWith("function") || n == "constructor" ||
|
||||
n.startsWith("support.function"))
|
||||
return hl::Role::Function;
|
||||
if (n == "method" || n == "method.builtin")
|
||||
return hl::Role::Method;
|
||||
if (n.startsWith("macro"))
|
||||
return hl::Role::Macro;
|
||||
if (n.startsWith("preproc"))
|
||||
return hl::Role::Preproc;
|
||||
if (n == "operator" || n == "punctuation.operator" || n.startsWith("operator.")
|
||||
|| n.startsWith("punctuation"))
|
||||
if (n == "method" || n == "method.builtin") return hl::Role::Method;
|
||||
if (n.startsWith("macro")) return hl::Role::Macro;
|
||||
if (n.startsWith("preproc")) return hl::Role::Preproc;
|
||||
if (n == "operator" || n == "punctuation.operator" ||
|
||||
n.startsWith("operator.") || n.startsWith("punctuation"))
|
||||
return hl::Role::Operator;
|
||||
if (n == "property" || n == "field" || n.startsWith("property."))
|
||||
return hl::Role::Property;
|
||||
if (n == "label")
|
||||
return hl::Role::Label;
|
||||
if (n == "label") return hl::Role::Label;
|
||||
if (n.startsWith("attribute") || n == "annotation")
|
||||
return hl::Role::Attribute;
|
||||
// HTML tag names, CSS variables and friends.
|
||||
if (n == "tag" || (n.startsWith("tag.") && n != "tag.delimiter"))
|
||||
return hl::Role::Keyword;
|
||||
if (n.startsWith("variable"))
|
||||
return hl::Role::Constant;
|
||||
if (n.startsWith("support"))
|
||||
return hl::Role::Function;
|
||||
if (n.startsWith("variable")) return hl::Role::Constant;
|
||||
if (n.startsWith("support")) return hl::Role::Function;
|
||||
return hl::Role::None;
|
||||
}
|
||||
|
||||
@@ -195,26 +195,85 @@ const char* CodeHighlighter::roleName(uint8_t role) {
|
||||
return hl::roleName(static_cast<hl::Role>(role));
|
||||
}
|
||||
|
||||
QString CodeHighlighter::resolveId(const QString& language) {
|
||||
const QString tag = language.trimmed().toLower();
|
||||
const QString alias = aliases().value(tag);
|
||||
return alias.isEmpty() ? tag : alias; // unknown tags = grammar id
|
||||
}
|
||||
|
||||
QString CodeHighlighter::cacheKey(const QString& id, const QString& code) {
|
||||
return id + QLatin1Char('\x01') + QString::number(code.size()) +
|
||||
QLatin1Char('\x01') + QString::number(qHash(code));
|
||||
}
|
||||
|
||||
QVariantList CodeHighlighter::lookupSpans(
|
||||
const QString& code, const QString& language) const {
|
||||
if (code.isEmpty()) return {};
|
||||
const QString key = cacheKey(resolveId(language), code);
|
||||
QMutexLocker locker(&m_cacheMutex);
|
||||
const auto it = m_spanCache.constFind(key);
|
||||
if (it == m_spanCache.constEnd() || it->code != code) return {};
|
||||
const qsizetype pos = m_spanCacheOrder.indexOf(key);
|
||||
if (pos >= 0) m_spanCacheOrder.move(pos, m_spanCacheOrder.size() - 1);
|
||||
return it->spans;
|
||||
}
|
||||
|
||||
void CodeHighlighter::storeSpans(
|
||||
const QString& code,
|
||||
const QString& language,
|
||||
const QVariantList& spans) const {
|
||||
if (spans.isEmpty() || code.isEmpty()) return;
|
||||
static constexpr int kMaxEntries = 32;
|
||||
static constexpr int kMaxBytes = 1024 * 1024;
|
||||
const QString key = cacheKey(resolveId(language), code);
|
||||
const int bytes = static_cast<int>(code.toUtf8().size());
|
||||
QMutexLocker locker(&m_cacheMutex);
|
||||
auto it = m_spanCache.find(key);
|
||||
if (it != m_spanCache.end()) {
|
||||
m_spanCacheBytes -= static_cast<int>(it->code.toUtf8().size());
|
||||
m_spanCache.erase(it);
|
||||
m_spanCacheOrder.removeAll(key);
|
||||
}
|
||||
while (m_spanCacheOrder.size() >= kMaxEntries ||
|
||||
m_spanCacheBytes + bytes > kMaxBytes) {
|
||||
if (m_spanCacheOrder.isEmpty()) break;
|
||||
const QString oldest = m_spanCacheOrder.takeFirst();
|
||||
m_spanCacheBytes -=
|
||||
static_cast<int>(m_spanCache.value(oldest).code.toUtf8().size());
|
||||
m_spanCache.remove(oldest);
|
||||
}
|
||||
m_spanCache.insert(key, SpanCacheEntry{code, spans});
|
||||
m_spanCacheOrder.append(key);
|
||||
m_spanCacheBytes += bytes;
|
||||
}
|
||||
|
||||
void CodeHighlighter::highlight(
|
||||
const QString& code, const QString& language, QObject* target, int token) {
|
||||
QThreadPool::globalInstance()->start([this, target, token, code, language]() {
|
||||
QPointer<QObject> targetGuard(target);
|
||||
const QVariantList cached = lookupSpans(code, language);
|
||||
if (!cached.isEmpty()) {
|
||||
QMetaObject::invokeMethod(
|
||||
targetGuard,
|
||||
"onHighlightSpans",
|
||||
Qt::DirectConnection,
|
||||
Q_ARG(QVariant, token),
|
||||
Q_ARG(QVariant, cached));
|
||||
return;
|
||||
}
|
||||
QThreadPool::globalInstance()->start(
|
||||
[this, target, token, code, language]() {
|
||||
const QVariantList spans = doHighlight(code, language);
|
||||
// The target item may be long gone by now (delegates are
|
||||
// recreated constantly while chats load); a destroyed target is
|
||||
// simply skipped. Deliver through the app instance (never
|
||||
// destroyed) and re-check there: posting to `target` from the
|
||||
// pool thread would race with its destruction.
|
||||
storeSpans(code, language, spans);
|
||||
QPointer<QObject> guard(target);
|
||||
QMetaObject::invokeMethod(
|
||||
QCoreApplication::instance(),
|
||||
[guard, token, spans]() {
|
||||
if (!guard)
|
||||
return;
|
||||
// QML functions are only invokable by their generic
|
||||
// QVariant overload, so pass untyped arguments.
|
||||
if (!guard) return;
|
||||
QMetaObject::invokeMethod(
|
||||
guard, "onHighlightSpans",
|
||||
Q_ARG(QVariant, token), Q_ARG(QVariant, spans));
|
||||
guard,
|
||||
"onHighlightSpans",
|
||||
Q_ARG(QVariant, token),
|
||||
Q_ARG(QVariant, spans));
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
});
|
||||
@@ -223,40 +282,28 @@ void CodeHighlighter::highlight(
|
||||
QVariantList CodeHighlighter::doHighlight(
|
||||
const QString& code, const QString& language) const {
|
||||
QVariantList spans;
|
||||
if (code.isEmpty())
|
||||
return spans;
|
||||
if (code.isEmpty()) return spans;
|
||||
|
||||
const QString tag = language.trimmed().toLower();
|
||||
const QString id = [&] {
|
||||
const QString alias = aliases().value(tag);
|
||||
return alias.isEmpty() ? tag : alias; // unknown tags = grammar id
|
||||
}();
|
||||
const QString id = resolveId(language);
|
||||
const Grammar& grammar = hl::grammars().value(id);
|
||||
if (grammar.libs.empty())
|
||||
return spans;
|
||||
if (grammar.libs.empty()) return spans;
|
||||
|
||||
// Guard against pathological blocks; highlighting is best-effort.
|
||||
static constexpr size_t kMaxBytes = 512 * 1024;
|
||||
const QByteArray utf8 = code.toUtf8();
|
||||
if (static_cast<size_t>(utf8.size()) > kMaxBytes)
|
||||
return spans;
|
||||
if (static_cast<size_t>(utf8.size()) > kMaxBytes) return spans;
|
||||
|
||||
const TSLanguage* lang = nullptr;
|
||||
TSQuery* query = nullptr;
|
||||
{
|
||||
QMutexLocker locker(&m_stateMutex);
|
||||
auto& state = m_states[id];
|
||||
// A missing library is retriable (it may be installed while the
|
||||
// shell runs); an ABI mismatch on every candidate is not. Cache
|
||||
// successes and permanent failures; leave retriable misses out.
|
||||
if (!state || (!state->lang && !state->bad)) {
|
||||
std::shared_ptr<State> fresh = std::make_shared<State>();
|
||||
bool abiMismatch = false;
|
||||
for (size_t i = 0; i < grammar.libs.size(); ++i) {
|
||||
void* lib = dlopen(grammar.libs[i].c_str(),
|
||||
RTLD_NOW | RTLD_LOCAL);
|
||||
if (!lib)
|
||||
continue;
|
||||
void* lib =
|
||||
dlopen(grammar.libs[i].c_str(), RTLD_NOW | RTLD_LOCAL);
|
||||
if (!lib) continue;
|
||||
auto* symbol = reinterpret_cast<hl::LanguageFn>(
|
||||
dlsym(lib, grammar.symbols[i].c_str()));
|
||||
if (!symbol) {
|
||||
@@ -276,7 +323,6 @@ QVariantList CodeHighlighter::doHighlight(
|
||||
break;
|
||||
}
|
||||
if (fresh->lang) {
|
||||
// Candidates in priority order; first that compiles wins.
|
||||
for (const char* source : grammar.queries) {
|
||||
TSQueryError errorType = TSQueryErrorNone;
|
||||
uint32_t errorOffset = 0;
|
||||
@@ -286,21 +332,17 @@ QVariantList CodeHighlighter::doHighlight(
|
||||
static_cast<uint32_t>(std::strlen(source)),
|
||||
&errorOffset,
|
||||
&errorType);
|
||||
if (!candidate)
|
||||
continue;
|
||||
if (!candidate) continue;
|
||||
fresh->query = candidate;
|
||||
break;
|
||||
}
|
||||
if (!fresh->query)
|
||||
fresh->bad = true;
|
||||
if (!fresh->query) fresh->bad = true;
|
||||
} else if (abiMismatch) {
|
||||
fresh->bad = true;
|
||||
}
|
||||
if (fresh->lang || fresh->bad)
|
||||
state = std::move(fresh);
|
||||
if (fresh->lang || fresh->bad) state = std::move(fresh);
|
||||
}
|
||||
if (!state || state->bad || !state->lang)
|
||||
return spans;
|
||||
if (!state || state->bad || !state->lang) return spans;
|
||||
lang = static_cast<const TSLanguage*>(state->lang);
|
||||
query = static_cast<TSQuery*>(state->query);
|
||||
}
|
||||
@@ -317,13 +359,9 @@ QVariantList CodeHighlighter::doHighlight(
|
||||
TSQueryCursor* cursor = ts_query_cursor_new();
|
||||
ts_query_cursor_exec(cursor, query, ts_tree_root_node(tree));
|
||||
|
||||
// Per-byte winner table: captures arrive in document order and later
|
||||
// captures overwrite earlier ones (tree-sitter highlight convention).
|
||||
const uint32_t size = static_cast<uint32_t>(utf8.size());
|
||||
std::vector<uint8_t> kinds(size, 0);
|
||||
|
||||
// QML slices the code by UTF-16 code unit, so spans must be in code
|
||||
// units, not bytes. cu[b] = code units before byte b.
|
||||
std::vector<uint32_t> cu(size + 1, 0);
|
||||
for (uint32_t b = 0; b < size; ++b) {
|
||||
cu[b + 1] = cu[b];
|
||||
@@ -333,9 +371,9 @@ QVariantList CodeHighlighter::doHighlight(
|
||||
else if (c < 0xC0)
|
||||
; // continuation byte
|
||||
else if (c < 0xF0)
|
||||
cu[b + 1] += 1; // 2/3-byte lead -> BMP -> one unit
|
||||
cu[b + 1] += 1;
|
||||
else
|
||||
cu[b + 1] += 2; // 4-byte lead -> surrogate pair
|
||||
cu[b + 1] += 2;
|
||||
}
|
||||
|
||||
TSQueryMatch match;
|
||||
@@ -343,14 +381,13 @@ QVariantList CodeHighlighter::doHighlight(
|
||||
while (ts_query_cursor_next_capture(cursor, &match, &captureIndex)) {
|
||||
const TSQueryCapture& capture = match.captures[captureIndex];
|
||||
uint32_t nameLength = 0;
|
||||
const char* name = ts_query_capture_name_for_id(query, capture.index, &nameLength);
|
||||
const char* name =
|
||||
ts_query_capture_name_for_id(query, capture.index, &nameLength);
|
||||
const uint8_t role = roleFor(name, nameLength);
|
||||
if (role == 0)
|
||||
continue;
|
||||
if (role == 0) continue;
|
||||
const uint32_t start = ts_node_start_byte(capture.node);
|
||||
const uint32_t end = ts_node_end_byte(capture.node);
|
||||
if (end <= start || end > size)
|
||||
continue;
|
||||
if (end <= start || end > size) continue;
|
||||
std::fill(kinds.begin() + start, kinds.begin() + end, role);
|
||||
}
|
||||
|
||||
@@ -378,8 +415,7 @@ QVariantList CodeHighlighter::doHighlight(
|
||||
}
|
||||
|
||||
CodeHighlighter* CodeHighlighter::create(QQmlEngine*, QJSEngine*) {
|
||||
if (!s_instance)
|
||||
s_instance = new CodeHighlighter();
|
||||
if (!s_instance) s_instance = new CodeHighlighter();
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,34 +15,6 @@ class QJSEngine;
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
// Syntax highlighting for LLM code blocks via tree-sitter.
|
||||
//
|
||||
// The tree-sitter runtime is linked. Grammar libraries are dlopen()'d
|
||||
// lazily, so a missing grammar degrades that language to plain text
|
||||
// instead of breaking the build or the app. At configure time CMake
|
||||
// discovers installed grammars (system packages plus the parsers
|
||||
// Neovim's nvim-treesitter installs) and pairs each with highlight
|
||||
// queries (vendored, Neovim's, or fetched from
|
||||
// tree-sitter/highlighting); both are embedded in the generated
|
||||
// highlight-queries.hpp.
|
||||
//
|
||||
// For each grammar the first loadable (ABI-compatible) library wins
|
||||
// and the first query that compiles against it wins, so a version
|
||||
// skew between a library and its query degrades gracefully.
|
||||
//
|
||||
// The fence language the LLM wrote (```cpp, ```python, ...) is mapped
|
||||
// to a grammar through an alias table; tags not in the table are used
|
||||
// as grammar ids as-is.
|
||||
//
|
||||
// highlight() parses the code off the GUI thread and delivers a list of
|
||||
// span maps by calling target's "onHighlightSpans(token, spans)" method
|
||||
// (on the GUI thread):
|
||||
// { "start": int, "length": int, "kind": QString }
|
||||
// where kind is a semantic role (keyword, string, comment, number,
|
||||
// function, type, ...) that QML maps to theme colors. An empty list
|
||||
// means "no highlighting" (unknown language or grammar not installed).
|
||||
// token is passed back unchanged so the caller can drop results for
|
||||
// superseded code; a destroyed target is simply skipped.
|
||||
class CodeHighlighter : public QObject {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
@@ -50,39 +22,53 @@ class CodeHighlighter : public QObject {
|
||||
|
||||
public:
|
||||
Q_INVOKABLE void highlight(
|
||||
const QString& code, const QString& language, QObject* target, int token);
|
||||
const QString& code,
|
||||
const QString& language,
|
||||
QObject* target,
|
||||
int token);
|
||||
|
||||
static CodeHighlighter* create(QQmlEngine*, QJSEngine*);
|
||||
|
||||
struct Grammar {
|
||||
// Library candidates in priority order (system package, then
|
||||
// Neovim copies); libs[i] pairs with symbols[i].
|
||||
std::vector<std::string> libs;
|
||||
std::vector<std::string> symbols;
|
||||
// Candidate query sources in priority order; first that
|
||||
// compiles against the loaded grammar wins.
|
||||
std::vector<const char*> queries;
|
||||
};
|
||||
|
||||
private:
|
||||
struct State {
|
||||
bool bad = false; // permanent failure, do not retry
|
||||
bool bad = false;
|
||||
void* lib = nullptr;
|
||||
const void* lang = nullptr; // const TSLanguage*
|
||||
void* query = nullptr; // TSQuery*
|
||||
const void* lang = nullptr;
|
||||
void* query = nullptr;
|
||||
};
|
||||
|
||||
[[nodiscard]] static const QHash<QString, QString>& aliases();
|
||||
// Maps a tree-sitter capture name to a role index (0 = unstyled).
|
||||
[[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length);
|
||||
[[nodiscard]] static const char* roleName(uint8_t role);
|
||||
// The parsing work; runs on worker threads, so the per-language
|
||||
// state must be initialized under m_stateMutex and is shared as an
|
||||
// immutable object afterwards.
|
||||
[[nodiscard]] QVariantList doHighlight(const QString& code, const QString& language) const;
|
||||
[[nodiscard]] static QString resolveId(const QString& language);
|
||||
[[nodiscard]] static QString cacheKey(
|
||||
const QString& id, const QString& code);
|
||||
[[nodiscard]] QVariantList doHighlight(
|
||||
const QString& code, const QString& language) const;
|
||||
[[nodiscard]] QVariantList lookupSpans(
|
||||
const QString& code, const QString& language) const;
|
||||
void storeSpans(
|
||||
const QString& code,
|
||||
const QString& language,
|
||||
const QVariantList& spans) const;
|
||||
|
||||
struct SpanCacheEntry {
|
||||
QString code;
|
||||
QVariantList spans;
|
||||
};
|
||||
|
||||
mutable QHash<QString, std::shared_ptr<const State>> m_states;
|
||||
mutable QMutex m_stateMutex;
|
||||
mutable QHash<QString, SpanCacheEntry> m_spanCache;
|
||||
mutable QStringList m_spanCacheOrder;
|
||||
mutable int m_spanCacheBytes = 0;
|
||||
mutable QMutex m_cacheMutex;
|
||||
static CodeHighlighter* s_instance;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
#include "filetool.hpp"
|
||||
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
namespace {
|
||||
|
||||
QJsonObject makeOutput(const QString& text) {
|
||||
QJsonObject obj;
|
||||
obj[QStringLiteral("output")] = text;
|
||||
return obj;
|
||||
}
|
||||
|
||||
QJsonObject makeError(const QString& message) {
|
||||
QJsonObject obj;
|
||||
obj[QStringLiteral("error")] = message;
|
||||
return obj;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FileReadTool::FileReadTool(QObject* parent) : LlmTool(parent) {}
|
||||
|
||||
QString FileReadTool::name() const {
|
||||
return QStringLiteral("readfile");
|
||||
}
|
||||
|
||||
QString FileReadTool::description() const {
|
||||
return QStringLiteral(
|
||||
"Read a text file from the local filesystem and return part of "
|
||||
"its content. At most a limited number of characters is returned "
|
||||
"per call; use offset and limit to page through larger files. "
|
||||
"Binary files are not supported. Full output of webfetch calls "
|
||||
"is stored under /tmp/zshell-llm/webfetch/ and can be read this "
|
||||
"way. This tool is read-only.");
|
||||
}
|
||||
|
||||
QJsonObject FileReadTool::parameters() const {
|
||||
QJsonObject path;
|
||||
path[QStringLiteral("type")] = QStringLiteral("string");
|
||||
path[QStringLiteral("description")] =
|
||||
QStringLiteral("Path of the file to read");
|
||||
|
||||
QJsonObject offset;
|
||||
offset[QStringLiteral("type")] = QStringLiteral("integer");
|
||||
offset[QStringLiteral("minimum")] = 0;
|
||||
offset[QStringLiteral("description")] =
|
||||
QStringLiteral("Byte offset to start reading from. Defaults to 0.");
|
||||
|
||||
QJsonObject limit;
|
||||
limit[QStringLiteral("type")] = QStringLiteral("integer");
|
||||
limit[QStringLiteral("minimum")] = 1;
|
||||
limit[QStringLiteral("description")] = QStringLiteral(
|
||||
"Maximum number of characters to return. Defaults "
|
||||
"to a value that fits the model's context window.");
|
||||
|
||||
QJsonObject properties;
|
||||
properties[QStringLiteral("path")] = path;
|
||||
properties[QStringLiteral("offset")] = offset;
|
||||
properties[QStringLiteral("limit")] = limit;
|
||||
|
||||
QJsonObject schema;
|
||||
schema[QStringLiteral("type")] = QStringLiteral("object");
|
||||
schema[QStringLiteral("properties")] = properties;
|
||||
QJsonArray required;
|
||||
required.append(QStringLiteral("path"));
|
||||
schema[QStringLiteral("required")] = required;
|
||||
return schema;
|
||||
}
|
||||
|
||||
void FileReadTool::execute(
|
||||
const QString& toolCallId,
|
||||
const QJsonObject& args,
|
||||
int outputBudgetChars,
|
||||
std::function<void(const QJsonObject&)> done) {
|
||||
Q_UNUSED(toolCallId);
|
||||
const QString path = args[QStringLiteral("path")].toString().trimmed();
|
||||
if (path.isEmpty()) {
|
||||
done(makeError(QStringLiteral("Missing required argument 'path'")));
|
||||
return;
|
||||
}
|
||||
const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0));
|
||||
int budget = inlineBudgetChars();
|
||||
if (outputBudgetChars > 0) budget = qMin(budget, outputBudgetChars);
|
||||
const int limit =
|
||||
qBound(1, args[QStringLiteral("limit")].toInt(budget), budget);
|
||||
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
const QFileInfo info(path);
|
||||
if (info.exists() && info.isDir()) {
|
||||
done(makeError(QStringLiteral(
|
||||
"Path is a directory, not a "
|
||||
"file: %1")
|
||||
.arg(path)));
|
||||
} else {
|
||||
done(makeError(QStringLiteral("Cannot open file: %1").arg(path)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const qint64 fileSize = file.size();
|
||||
if (fileSize == 0) {
|
||||
file.close();
|
||||
done(makeOutput(QString()));
|
||||
return;
|
||||
}
|
||||
if (offset >= fileSize) {
|
||||
file.close();
|
||||
done(makeError(QStringLiteral(
|
||||
"Offset %1 is beyond the end of the "
|
||||
"file (%2 bytes)")
|
||||
.arg(offset)
|
||||
.arg(fileSize)));
|
||||
return;
|
||||
}
|
||||
if (!file.seek(offset)) {
|
||||
file.close();
|
||||
done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path)));
|
||||
return;
|
||||
}
|
||||
const qint64 wanted = qint64(limit) * 4 + 16;
|
||||
const QByteArray chunk = file.read(qMin(wanted, fileSize - offset));
|
||||
file.close();
|
||||
|
||||
if (chunk.contains('\0')) {
|
||||
done(makeError(
|
||||
QStringLiteral("Binary files are not supported: %1").arg(path)));
|
||||
return;
|
||||
}
|
||||
const QString content = QString::fromUtf8(chunk);
|
||||
if (content.size() <= limit) {
|
||||
done(makeOutput(content));
|
||||
return;
|
||||
}
|
||||
const QString head = content.left(limit);
|
||||
const int nextOffset = offset + head.toUtf8().size();
|
||||
const QString output =
|
||||
head +
|
||||
QStringLiteral(
|
||||
"\n\n[... truncated: showing %1 characters (bytes %2-%3 of a "
|
||||
"%4 byte file). Continue with offset=%5.]")
|
||||
.arg(limit)
|
||||
.arg(offset)
|
||||
.arg(nextOffset)
|
||||
.arg(fileSize)
|
||||
.arg(nextOffset);
|
||||
done(makeOutput(output));
|
||||
}
|
||||
|
||||
} // namespace ZShell::llm
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include "tool.hpp"
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
class FileReadTool : public LlmTool {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit FileReadTool(QObject* parent = nullptr);
|
||||
|
||||
QString name() const override;
|
||||
QString description() const override;
|
||||
QJsonObject parameters() const override;
|
||||
void execute(
|
||||
const QString& toolCallId,
|
||||
const QJsonObject& args,
|
||||
int outputBudgetChars,
|
||||
std::function<void(const QJsonObject& result)> done) override;
|
||||
};
|
||||
|
||||
} // namespace ZShell::llm
|
||||
@@ -1,5 +1,9 @@
|
||||
#include "generation.hpp"
|
||||
|
||||
#include "llmclient.hpp"
|
||||
#include "messagemodel.hpp"
|
||||
#include "session.hpp"
|
||||
|
||||
#include <QDateTime>
|
||||
|
||||
namespace ZShell::llm {
|
||||
@@ -12,15 +16,12 @@ ChatGeneration::ChatGeneration(qint64 timestamp, QObject* parent)
|
||||
connect(&m_timer, &QTimer::timeout, this, [this]() {
|
||||
bool anyRunning = false;
|
||||
for (auto* segment : m_segments) {
|
||||
if (!segment->running())
|
||||
continue;
|
||||
if (!segment->running()) continue;
|
||||
anyRunning = true;
|
||||
segment->elapsedMsChanged();
|
||||
}
|
||||
if (anyRunning)
|
||||
Q_EMIT elapsedMsChanged();
|
||||
if (!anyRunning && !m_streaming)
|
||||
m_timer.stop();
|
||||
if (anyRunning) Q_EMIT elapsedMsChanged();
|
||||
if (!anyRunning && !m_streaming) m_timer.stop();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,11 +48,9 @@ QString ChatGeneration::reasoning() const {
|
||||
}
|
||||
|
||||
bool ChatGeneration::reasoningActive() const {
|
||||
if (!m_streaming)
|
||||
return false;
|
||||
if (!content().isEmpty())
|
||||
return false;
|
||||
return !hasRunningTool();
|
||||
if (!m_streaming) return false;
|
||||
if (!content().isEmpty()) return false;
|
||||
return !hasRunningTool() && !hasPendingTool();
|
||||
}
|
||||
|
||||
qint64 ChatGeneration::reasoningElapsedMs() const {
|
||||
@@ -81,44 +80,74 @@ qint64 ChatGeneration::toolsElapsedMs() const {
|
||||
int ChatGeneration::toolCallCount() const {
|
||||
int count = 0;
|
||||
for (const auto* segment : m_segments)
|
||||
if (segment->type() == LlmSegment::Type::ToolCall)
|
||||
++count;
|
||||
if (segment->type() == LlmSegment::Type::ToolCall) ++count;
|
||||
return count;
|
||||
}
|
||||
|
||||
bool ChatGeneration::hasRunningTool() const {
|
||||
for (const auto* segment : m_segments)
|
||||
if (segment->type() == LlmSegment::Type::ToolCall &&
|
||||
segment->running())
|
||||
if (segment->type() == LlmSegment::Type::ToolCall && segment->running())
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ChatGeneration::hasPendingTool() const {
|
||||
for (const auto* segment : m_segments)
|
||||
if (segment->type() == LlmSegment::Type::ToolCall &&
|
||||
segment->status() == LlmSegment::Status::Pending)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void ChatGeneration::setApprovalPending(bool value) {
|
||||
if (m_approvalPending == value) return;
|
||||
m_approvalPending = value;
|
||||
Q_EMIT toolApprovalPendingChanged();
|
||||
}
|
||||
|
||||
QVariantList ChatGeneration::pendingToolCalls() const {
|
||||
QVariantList out;
|
||||
if (!m_approvalPending) return out;
|
||||
for (const auto* segment : m_segments)
|
||||
if (segment->type() == LlmSegment::Type::ToolCall &&
|
||||
segment->status() == LlmSegment::Status::Pending)
|
||||
out.append(QVariant::fromValue(segment));
|
||||
return out;
|
||||
}
|
||||
|
||||
void ChatGeneration::approveTools() {
|
||||
if (auto* llmClient = client()) llmClient->approveTools();
|
||||
}
|
||||
|
||||
void ChatGeneration::denyTools() {
|
||||
if (auto* llmClient = client()) llmClient->denyTools();
|
||||
}
|
||||
|
||||
LlmClient* ChatGeneration::client() const {
|
||||
if (auto* message = qobject_cast<ChatMessage*>(parent()))
|
||||
if (auto* model = qobject_cast<ChatMessageModel*>(message->parent()))
|
||||
if (auto* session = model->session()) return session->client();
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void ChatGeneration::updateReasoningActive() {
|
||||
const bool active = reasoningActive();
|
||||
if (m_reasoningActive == active)
|
||||
return;
|
||||
if (m_reasoningActive == active) return;
|
||||
m_reasoningActive = active;
|
||||
Q_EMIT reasoningActiveChanged();
|
||||
}
|
||||
|
||||
void ChatGeneration::setContent(const QString& value) {
|
||||
// Replaces the entire answer: the first content segment takes the
|
||||
// new text, any later content bursts are cleared.
|
||||
LlmSegment* first = nullptr;
|
||||
for (auto* segment : m_segments) {
|
||||
if (segment->type() != LlmSegment::Type::Content)
|
||||
continue;
|
||||
if (segment->type() != LlmSegment::Type::Content) continue;
|
||||
if (!first)
|
||||
first = segment;
|
||||
else
|
||||
segment->setText(QString());
|
||||
}
|
||||
if (!first) {
|
||||
// Do not materialize an empty content segment (e.g. the assistant
|
||||
// placeholder created before the stream starts).
|
||||
if (value.isEmpty())
|
||||
return;
|
||||
if (value.isEmpty()) return;
|
||||
first = new LlmSegment(
|
||||
LlmSegment::Type::Content,
|
||||
QDateTime::currentMSecsSinceEpoch(),
|
||||
@@ -129,8 +158,7 @@ void ChatGeneration::setContent(const QString& value) {
|
||||
}
|
||||
|
||||
void ChatGeneration::appendContent(const QString& piece) {
|
||||
if (piece.isEmpty())
|
||||
return;
|
||||
if (piece.isEmpty()) return;
|
||||
for (auto* segment : m_segments) {
|
||||
if (segment->type() == LlmSegment::Type::Reasoning &&
|
||||
segment->running())
|
||||
@@ -140,24 +168,20 @@ void ChatGeneration::appendContent(const QString& piece) {
|
||||
}
|
||||
|
||||
void ChatGeneration::appendReasoning(const QString& piece) {
|
||||
if (piece.isEmpty())
|
||||
return;
|
||||
if (piece.isEmpty()) return;
|
||||
for (auto* segment : m_segments) {
|
||||
if (segment->type() == LlmSegment::Type::Content &&
|
||||
segment->running())
|
||||
if (segment->type() == LlmSegment::Type::Content && segment->running())
|
||||
segment->close();
|
||||
}
|
||||
openReasoningSegment()->appendText(piece);
|
||||
}
|
||||
|
||||
void ChatGeneration::setStreaming(bool value) {
|
||||
if (m_streaming == value)
|
||||
return;
|
||||
if (m_streaming == value) return;
|
||||
m_streaming = value;
|
||||
Q_EMIT streamingChanged();
|
||||
if (value) {
|
||||
if (!m_timer.isActive())
|
||||
m_timer.start();
|
||||
if (!m_timer.isActive()) m_timer.start();
|
||||
} else {
|
||||
closeOpenSegments();
|
||||
m_timer.stop();
|
||||
@@ -168,13 +192,10 @@ void ChatGeneration::setStreaming(bool value) {
|
||||
|
||||
LlmSegment* ChatGeneration::openContentSegment() {
|
||||
for (auto* segment : m_segments)
|
||||
if (segment->type() == LlmSegment::Type::Content &&
|
||||
segment->running())
|
||||
if (segment->type() == LlmSegment::Type::Content && segment->running())
|
||||
return segment;
|
||||
auto* segment = new LlmSegment(
|
||||
LlmSegment::Type::Content,
|
||||
QDateTime::currentMSecsSinceEpoch(),
|
||||
this);
|
||||
LlmSegment::Type::Content, QDateTime::currentMSecsSinceEpoch(), this);
|
||||
segment->begin();
|
||||
addSegment(segment);
|
||||
return segment;
|
||||
@@ -186,9 +207,7 @@ LlmSegment* ChatGeneration::openReasoningSegment() {
|
||||
segment->running())
|
||||
return segment;
|
||||
auto* segment = new LlmSegment(
|
||||
LlmSegment::Type::Reasoning,
|
||||
QDateTime::currentMSecsSinceEpoch(),
|
||||
this);
|
||||
LlmSegment::Type::Reasoning, QDateTime::currentMSecsSinceEpoch(), this);
|
||||
segment->begin();
|
||||
addSegment(segment);
|
||||
return segment;
|
||||
@@ -198,12 +217,10 @@ LlmSegment* ChatGeneration::beginToolCall(
|
||||
const QString& name, const QString& toolCallId) {
|
||||
closeOpenSegments();
|
||||
auto* segment = new LlmSegment(
|
||||
LlmSegment::Type::ToolCall,
|
||||
QDateTime::currentMSecsSinceEpoch(),
|
||||
this);
|
||||
LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this);
|
||||
segment->setName(name);
|
||||
segment->setToolCallId(toolCallId);
|
||||
segment->setStatus(LlmSegment::Status::Running);
|
||||
segment->setStatus(LlmSegment::Status::Pending);
|
||||
segment->begin();
|
||||
addSegment(segment);
|
||||
Q_EMIT toolStateChanged();
|
||||
@@ -211,27 +228,22 @@ LlmSegment* ChatGeneration::beginToolCall(
|
||||
}
|
||||
|
||||
void ChatGeneration::addSegment(LlmSegment* segment) {
|
||||
if (!segment || m_segments.contains(segment))
|
||||
return;
|
||||
if (!segment || m_segments.contains(segment)) return;
|
||||
segment->setParent(this);
|
||||
connect(
|
||||
segment, &LlmSegment::textChanged, this, [this, segment]() {
|
||||
connect(segment, &LlmSegment::textChanged, this, [this, segment]() {
|
||||
if (segment->type() == LlmSegment::Type::Reasoning)
|
||||
Q_EMIT reasoningChanged();
|
||||
else if (segment->type() == LlmSegment::Type::Content)
|
||||
Q_EMIT contentChanged();
|
||||
updateReasoningActive();
|
||||
});
|
||||
connect(
|
||||
segment, &LlmSegment::statusChanged, this, [this]() {
|
||||
connect(segment, &LlmSegment::statusChanged, this, [this]() {
|
||||
Q_EMIT toolStateChanged();
|
||||
});
|
||||
connect(
|
||||
segment, &LlmSegment::resultChanged, this, [this]() {
|
||||
connect(segment, &LlmSegment::resultChanged, this, [this]() {
|
||||
Q_EMIT toolStateChanged();
|
||||
});
|
||||
connect(
|
||||
segment, &LlmSegment::runningChanged, this, [this]() {
|
||||
connect(segment, &LlmSegment::runningChanged, this, [this]() {
|
||||
Q_EMIT elapsedMsChanged();
|
||||
Q_EMIT toolStateChanged();
|
||||
updateReasoningActive();
|
||||
@@ -242,8 +254,7 @@ void ChatGeneration::addSegment(LlmSegment* segment) {
|
||||
|
||||
void ChatGeneration::closeOpenSegments() {
|
||||
for (auto* segment : m_segments)
|
||||
if (segment->running())
|
||||
segment->close();
|
||||
if (segment->running()) segment->close();
|
||||
updateReasoningActive();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,43 +6,48 @@
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QTimer>
|
||||
#include <QVariantList>
|
||||
#include <QtQml>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
// One attempt at answering a message: a chronologically ordered list
|
||||
// of segments. Content bursts, reasoning bursts and tool calls all
|
||||
// appear in the order the model produced them; a new content (or
|
||||
// reasoning) segment starts whenever the model switches between them.
|
||||
// Together with the attempt's aggregate state.
|
||||
class LlmClient;
|
||||
|
||||
class ChatGeneration : public QObject {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_UNCREATABLE("Chat generations are managed by ChatMessage")
|
||||
|
||||
Q_PROPERTY(QString content READ content WRITE setContent NOTIFY contentChanged)
|
||||
Q_PROPERTY(
|
||||
QString content READ content WRITE setContent NOTIFY contentChanged)
|
||||
Q_PROPERTY(QString reasoning READ reasoning NOTIFY reasoningChanged)
|
||||
Q_PROPERTY(qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY elapsedMsChanged)
|
||||
Q_PROPERTY(qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged)
|
||||
Q_PROPERTY(
|
||||
qint64 reasoningElapsedMs READ reasoningElapsedMs NOTIFY
|
||||
elapsedMsChanged)
|
||||
Q_PROPERTY(
|
||||
qint64 contentElapsedMs READ contentElapsedMs NOTIFY elapsedMsChanged)
|
||||
Q_PROPERTY(qint64 toolsElapsedMs READ toolsElapsedMs NOTIFY elapsedMsChanged)
|
||||
Q_PROPERTY(bool streaming READ streaming NOTIFY streamingChanged)
|
||||
Q_PROPERTY(bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged)
|
||||
Q_PROPERTY(
|
||||
bool reasoningActive READ reasoningActive NOTIFY reasoningActiveChanged)
|
||||
Q_PROPERTY(bool hasRunningTool READ hasRunningTool NOTIFY toolStateChanged)
|
||||
Q_PROPERTY(qint64 timestamp READ timestamp CONSTANT)
|
||||
Q_PROPERTY(
|
||||
QList<ZShell::llm::LlmSegment*> segments READ segments
|
||||
NOTIFY segmentsChanged)
|
||||
QList<ZShell::llm::LlmSegment*> segments READ segments NOTIFY
|
||||
segmentsChanged)
|
||||
Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged)
|
||||
Q_PROPERTY(
|
||||
bool toolApprovalPending READ toolApprovalPending NOTIFY
|
||||
toolApprovalPendingChanged)
|
||||
Q_PROPERTY(
|
||||
QVariantList pendingToolCalls READ pendingToolCalls NOTIFY
|
||||
toolApprovalPendingChanged)
|
||||
|
||||
public:
|
||||
explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr);
|
||||
|
||||
// All content bursts, joined (the model's full answer).
|
||||
[[nodiscard]] QString content() const;
|
||||
// Every reasoning burst, joined.
|
||||
[[nodiscard]] QString reasoning() const;
|
||||
// True while the model is thinking: streaming, no content yet, and
|
||||
// no tool call in flight.
|
||||
[[nodiscard]] bool reasoningActive() const;
|
||||
[[nodiscard]] qint64 reasoningElapsedMs() const;
|
||||
[[nodiscard]] qint64 contentElapsedMs() const;
|
||||
@@ -52,24 +57,25 @@ class ChatGeneration : public QObject {
|
||||
[[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; }
|
||||
[[nodiscard]] int toolCallCount() const;
|
||||
[[nodiscard]] bool hasRunningTool() const;
|
||||
[[nodiscard]] bool hasPendingTool() const;
|
||||
|
||||
[[nodiscard]] bool toolApprovalPending() const { return m_approvalPending; }
|
||||
void setApprovalPending(bool value);
|
||||
[[nodiscard]] QVariantList pendingToolCalls() const;
|
||||
|
||||
Q_INVOKABLE void approveTools();
|
||||
Q_INVOKABLE void denyTools();
|
||||
|
||||
void setContent(const QString& value);
|
||||
void appendContent(const QString& piece);
|
||||
void appendReasoning(const QString& piece);
|
||||
void setStreaming(bool value);
|
||||
|
||||
// The in-flight content segment, or a fresh one. A new content
|
||||
// segment starts whenever the model resumes writing after reasoning
|
||||
// or a tool call.
|
||||
[[nodiscard]] LlmSegment* openContentSegment();
|
||||
// The in-flight reasoning segment, or a fresh one.
|
||||
[[nodiscard]] LlmSegment* openReasoningSegment();
|
||||
// Creates and appends a running tool-call segment.
|
||||
[[nodiscard]] LlmSegment* beginToolCall(
|
||||
const QString& name, const QString& toolCallId);
|
||||
// Appends a segment created by the persistence layer.
|
||||
void addSegment(LlmSegment* segment);
|
||||
// Stops the clocks of every in-flight segment.
|
||||
void closeOpenSegments();
|
||||
|
||||
Q_SIGNALS:
|
||||
@@ -80,14 +86,17 @@ class ChatGeneration : public QObject {
|
||||
void streamingChanged();
|
||||
void toolStateChanged();
|
||||
void segmentsChanged();
|
||||
void toolApprovalPendingChanged();
|
||||
|
||||
private:
|
||||
void updateReasoningActive();
|
||||
LlmClient* client() const;
|
||||
|
||||
QTimer m_timer;
|
||||
QList<LlmSegment*> m_segments;
|
||||
bool m_reasoningActive = false;
|
||||
bool m_streaming = false;
|
||||
bool m_approvalPending = false;
|
||||
qint64 m_timestamp;
|
||||
};
|
||||
|
||||
|
||||
+432
-219
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QTimer>
|
||||
|
||||
#include <functional>
|
||||
|
||||
@@ -23,9 +24,6 @@ class ChatSession;
|
||||
class LlmSegment;
|
||||
class LlmTool;
|
||||
|
||||
// The only component that talks to the LLM server: owns the network
|
||||
// manager, the in-flight streaming state, the SSE parsing and the
|
||||
// tool-calling loop.
|
||||
class LlmClient : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
@@ -45,7 +43,8 @@ class LlmClient : public QObject {
|
||||
void setModel(const QString& value);
|
||||
void setTemperature(double value);
|
||||
void setContextSize(int size);
|
||||
void probeContextSize();
|
||||
void probeContextSize(std::function<void()> done = {});
|
||||
Q_INVOKABLE void refreshFromServer();
|
||||
|
||||
[[nodiscard]] bool busy() const { return m_busy; }
|
||||
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
|
||||
@@ -53,19 +52,15 @@ class LlmClient : public QObject {
|
||||
[[nodiscard]] QString streamingChatId() const { return m_streamingChatId; }
|
||||
[[nodiscard]] ChatSession* streamingSession() const { return m_active; }
|
||||
|
||||
// Streams a new assistant reply into `target` (the active generation
|
||||
// of a message of `session`). The context sent to the model is every
|
||||
// message of the session that is older than the target message.
|
||||
// Tool calls made by the model are executed and fed back
|
||||
// transparently until the model produces its final answer.
|
||||
void startGeneration(ChatSession* session, ChatGeneration* target);
|
||||
void stop();
|
||||
void endStream();
|
||||
// Clears the session's conversation once the current stream ends.
|
||||
void clearOnFinish(ChatSession* session);
|
||||
// A session is about to be destroyed; drop any state pointing at it.
|
||||
void sessionRemoved(ChatSession* session);
|
||||
|
||||
void approveTools();
|
||||
void denyTools();
|
||||
|
||||
void refreshModels();
|
||||
void requestTitle(ChatSession* session, const QString& userText);
|
||||
void requestIcon(ChatSession* session, const QString& userText);
|
||||
@@ -91,23 +86,15 @@ class LlmClient : public QObject {
|
||||
bool seen = false;
|
||||
};
|
||||
|
||||
// Sends one streaming round: context + transcript so far.
|
||||
void sendRound();
|
||||
// The session context for a round, oldest first, ending just before
|
||||
// `stopBeforeRow` (the message of the generation being streamed).
|
||||
QJsonArray buildContextMessages(
|
||||
ChatSession* session, int stopBeforeRow) const;
|
||||
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const;
|
||||
void applyToolCallDelta(const QJsonObject& call);
|
||||
// One round's stream ended; either ends the turn or executes the
|
||||
// requested tool calls and sends the next round. Runs at most once
|
||||
// per round ([DONE] and the reply's finished signal both reach it).
|
||||
void roundFinished();
|
||||
// Dispatches every call of the round; tools run concurrently.
|
||||
void executeAllCalls();
|
||||
// All results in: appends the tool messages (in call order) and
|
||||
// sends the next round.
|
||||
void flushCallResults();
|
||||
// Ends the current turn gracefully and persists the session.
|
||||
void setApprovalPending(bool value);
|
||||
void finishPendingCalls(const QString& resultText);
|
||||
void finishTurn();
|
||||
void fail(const QString& message);
|
||||
void handleLine(const QByteArray& line);
|
||||
@@ -120,7 +107,8 @@ class LlmClient : public QObject {
|
||||
const QString& systemPrompt,
|
||||
const QString& userText,
|
||||
std::function<void(QString result)> onResult);
|
||||
static QString completionsPath(const QString& endpoint, const QString& subpath);
|
||||
static QString completionsPath(
|
||||
const QString& endpoint, const QString& subpath);
|
||||
static QString serverErrorMessage(
|
||||
const QByteArray& body, const QString& fallback);
|
||||
|
||||
@@ -128,8 +116,9 @@ class LlmClient : public QObject {
|
||||
ToolRegistry* m_tools = nullptr;
|
||||
QNetworkReply* m_reply = nullptr;
|
||||
QByteArray m_buffer;
|
||||
ChatSession* m_active = nullptr;
|
||||
ChatGeneration* m_streaming = nullptr;
|
||||
QTimer m_refreshTimer;
|
||||
QPointer<ChatSession> m_active;
|
||||
QPointer<ChatGeneration> m_streaming;
|
||||
QPointer<ChatSession> m_pendingClear;
|
||||
bool m_busy = false;
|
||||
QString m_streamingChatId;
|
||||
@@ -139,7 +128,6 @@ class LlmClient : public QObject {
|
||||
double m_temperature = 0.7;
|
||||
int m_contextSize = 0;
|
||||
|
||||
// State of the multi-round tool loop of the current turn.
|
||||
QJsonArray m_transcript;
|
||||
QList<ToolCallBuilder> m_callBuilders;
|
||||
struct ToolCallResult {
|
||||
@@ -153,6 +141,8 @@ class LlmClient : public QObject {
|
||||
qsizetype m_reasoningMark = 0;
|
||||
bool m_roundDone = false;
|
||||
bool m_toolPhase = false;
|
||||
bool m_approvalPending = false;
|
||||
bool m_contextRetryUsed = false;
|
||||
int m_pendingCalls = 0;
|
||||
static constexpr int kMaxToolRounds = 12;
|
||||
};
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
// Marker class exposing the LlmMarkdown block type enum to QML
|
||||
// (LlmMarkdown.Type.*). Blocks themselves are value maps produced by
|
||||
// MarkdownParser and stored on LlmSegment.
|
||||
class LlmMarkdown : public QObject {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user