Feat: LLM Chat #155
@@ -0,0 +1,5 @@
|
|||||||
|
CompileFlags:
|
||||||
|
Remove:
|
||||||
|
# GCC-only flags from the system's default spec that clang/clangd cannot
|
||||||
|
# parse. They appear in compile_commands.json but are not set by the project.
|
||||||
|
- -mno-direct-extern-access
|
||||||
@@ -18,3 +18,6 @@ dist/
|
|||||||
network-dev/
|
network-dev/
|
||||||
**/zshell.build/
|
**/zshell.build/
|
||||||
**/zshell.dist/
|
**/zshell.dist/
|
||||||
|
.opencode/
|
||||||
|
run-agent.sh
|
||||||
|
AGENTS.md
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
.venv/
|
.venv/
|
||||||
scripts/fzf.js
|
scripts/fzf.js
|
||||||
scripts/fuzzysort.js
|
scripts/fuzzysort.js
|
||||||
|
scripts/settings-indexer.js
|
||||||
|
|||||||
+2
-1
@@ -3,8 +3,9 @@ FunctionsSpacing=true
|
|||||||
IndentWidth=4
|
IndentWidth=4
|
||||||
MaxColumnWidth=-1
|
MaxColumnWidth=-1
|
||||||
NewlineType=native
|
NewlineType=native
|
||||||
NormalizeOrder=true
|
GroupAttributesTogether=false
|
||||||
ObjectsSpacing=true
|
ObjectsSpacing=true
|
||||||
SemicolonRule=always
|
SemicolonRule=always
|
||||||
|
SingleLineEmptyObjects=true
|
||||||
SortImports=false
|
SortImports=false
|
||||||
UseTabs=true
|
UseTabs=true
|
||||||
|
|||||||
+11
-2
@@ -61,10 +61,12 @@ set(CMAKE_INSTALL_MESSAGE NEVER)
|
|||||||
|
|
||||||
add_compile_options(
|
add_compile_options(
|
||||||
-Wall -Wextra -Wpedantic -Wshadow -Wconversion
|
-Wall -Wextra -Wpedantic -Wshadow -Wconversion
|
||||||
-Wold-style-cast -Wnull-dereference -Wdouble-promotion
|
-Wnull-dereference -Wdouble-promotion
|
||||||
-Wformat=2 -Wfloat-equal -Woverloaded-virtual
|
-Wformat=2 -Wfloat-equal
|
||||||
-Wsign-conversion -Wredundant-decls -Wswitch
|
-Wsign-conversion -Wredundant-decls -Wswitch
|
||||||
-Wunreachable-code
|
-Wunreachable-code
|
||||||
|
$<$<COMPILE_LANGUAGE:CXX>:-Wold-style-cast>
|
||||||
|
$<$<COMPILE_LANGUAGE:CXX>:-Woverloaded-virtual>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -188,4 +190,11 @@ if("m3shapes" IN_LIST ENABLE_MODULES)
|
|||||||
if(TARGET m3shapesplugin)
|
if(TARGET m3shapesplugin)
|
||||||
set_target_properties(m3shapesplugin PROPERTIES INSTALL_RPATH "$ORIGIN")
|
set_target_properties(m3shapesplugin PROPERTIES INSTALL_RPATH "$ORIGIN")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# Silence warnings that come from m3shapes files we do not manage.
|
||||||
|
foreach(m3shapes_target m3shapes_core m3shapes_morph m3shapes_shapes m3shapes m3shapesplugin)
|
||||||
|
if(TARGET ${m3shapes_target})
|
||||||
|
target_compile_options(${m3shapes_target} PRIVATE -Wno-shadow -Wno-float-equal)
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ BusyIndicator {
|
|||||||
property int animState
|
property int animState
|
||||||
property color bgColor: Colors.palette.m3secondaryContainer
|
property color bgColor: Colors.palette.m3secondaryContainer
|
||||||
property color fgColor: Colors.palette.m3primary
|
property color fgColor: Colors.palette.m3primary
|
||||||
property real implicitSize: Tokens.font.size.normal * 3
|
property real implicitSize: Tokens.font.body.medium.pointSize * 3
|
||||||
property real internalStrokeWidth: strokeWidth
|
property real internalStrokeWidth: strokeWidth
|
||||||
readonly property alias progress: manager.progress
|
readonly property alias progress: manager.progress
|
||||||
property real strokeWidth: Tokens.padding.extraSmall * 0.8
|
property real strokeWidth: Tokens.padding.extraSmall * 0.8
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ Flickable {
|
|||||||
interval: 10
|
interval: 10
|
||||||
running: root.doneFakeFlick
|
running: root.doneFakeFlick
|
||||||
|
|
||||||
onTriggered: root.doneFakeFlick = false
|
onTriggered: {
|
||||||
|
root.doneFakeFlick = false;
|
||||||
|
root.returnToBounds();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,24 +10,27 @@ ListView {
|
|||||||
maximumFlickVelocity: 3000
|
maximumFlickVelocity: 3000
|
||||||
|
|
||||||
rebound: Transition {
|
rebound: Transition {
|
||||||
onRunningChanged: {
|
// onRunningChanged: {
|
||||||
if (!running && !root.doneFakeFlick) {
|
// if (!running && !root.doneFakeFlick) {
|
||||||
root.doneFakeFlick = true;
|
// root.doneFakeFlick = true;
|
||||||
root.flick(1, 1);
|
// root.flick(1, 1);
|
||||||
root.flick(-1, -1);
|
// root.flick(-1, -1);
|
||||||
Qt.callLater(() => root.cancelFlick());
|
// Qt.callLater(() => root.cancelFlick());
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
Anim {
|
Anim {
|
||||||
properties: "x,y"
|
properties: "x,y"
|
||||||
|
type: Anim.DefaultEffects
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
// Timer {
|
||||||
interval: 10
|
// interval: 10
|
||||||
running: root.doneFakeFlick
|
// running: root.doneFakeFlick
|
||||||
|
//
|
||||||
onTriggered: root.doneFakeFlick = false
|
// onTriggered: {
|
||||||
}
|
// root.doneFakeFlick = false;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ MouseArea {
|
|||||||
property int scrollAccumulatedY: 0
|
property int scrollAccumulatedY: 0
|
||||||
|
|
||||||
function onWheel(event: WheelEvent): void {
|
function onWheel(event: WheelEvent): void {
|
||||||
|
event.accepted = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onWheel: event => {
|
onWheel: event => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Rectangle {
|
|||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
CAnim {
|
CAnim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,41 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Templates
|
import QtQuick.Templates
|
||||||
import qs.Services
|
import qs.Services
|
||||||
import ZShell.Config
|
import ZShell.Config
|
||||||
import qs.Services
|
|
||||||
|
|
||||||
ScrollBar {
|
ScrollBar {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
required property Flickable flickable
|
||||||
|
|
||||||
|
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
|
||||||
|
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
|
||||||
|
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
|
||||||
|
readonly property real axisContentPos: isHorizontal ? (reversed ? flickable.contentX : (flickable.contentX - flickable.originX)) : (reversed ? flickable.contentY : (flickable.contentY - flickable.originY))
|
||||||
|
readonly property real axisLength: isHorizontal ? root.width : root.height
|
||||||
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
|
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
|
||||||
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
|
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
|
||||||
required property Flickable flickable
|
readonly property real nonAnimHeight: root.axisSize / root.axisContentSize
|
||||||
readonly property real nonAnimHeight: flickable.height / flickable.contentHeight
|
readonly property real nonAnimY: root.axisContentPos / root.axisContentSize
|
||||||
readonly property real nonAnimY: flickable.contentY / flickable.contentHeight
|
|
||||||
readonly property real rawTravel: Math.max(0, 1 - root.nonAnimHeight)
|
readonly property real rawTravel: Math.max(0, 1 - root.nonAnimHeight)
|
||||||
readonly property bool reversed: flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop
|
readonly property bool reversed: isHorizontal ? (flickable instanceof ListView && flickable.layoutDirection === Qt.RightToLeft) : (flickable instanceof ListView && flickable.verticalLayoutDirection === ListView.BottomToTop)
|
||||||
property bool shouldBeActive
|
|
||||||
readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0
|
readonly property real travelScale: root.rawTravel > 0 ? root.effectiveTravel / root.rawTravel : 0
|
||||||
|
|
||||||
enabled: !ShellState.forActive().isDrawing
|
property bool shouldBeActive
|
||||||
implicitHeight: Tokens.padding.extraSmall * 2
|
property bool inverted
|
||||||
implicitWidth: Tokens.padding.extraSmall * 2
|
|
||||||
|
|
||||||
contentItem: Item {
|
enabled: !ShellState.forActive().isDrawing
|
||||||
}
|
|
||||||
|
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 {
|
Behavior on position {
|
||||||
enabled: !fullMouse.pressed
|
enabled: !fullMouse.pressed
|
||||||
|
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onHoveredChanged: {
|
onHoveredChanged: {
|
||||||
@@ -48,54 +56,27 @@ ScrollBar {
|
|||||||
target: root.flickable
|
target: root.flickable
|
||||||
}
|
}
|
||||||
|
|
||||||
CustomClippingRect {
|
Loader {
|
||||||
anchors.bottom: parent.bottom
|
anchors.fill: parent
|
||||||
anchors.right: parent.right
|
active: root.size < 1
|
||||||
anchors.top: parent.top
|
sourceComponent: root.isHorizontal ? horizontalTrack : verticalTrack
|
||||||
implicitWidth: handle.implicitWidth
|
}
|
||||||
radius: Tokens.rounding.full
|
|
||||||
|
|
||||||
CustomRect {
|
Component {
|
||||||
id: handle
|
id: verticalTrack
|
||||||
|
|
||||||
anchors.right: parent.right
|
VerticalScrollBarTrack {
|
||||||
color: Colors.palette.m3secondary
|
scrollBar: root
|
||||||
implicitHeight: root.height * root.effectiveSize
|
mouseArea: fullMouse
|
||||||
implicitWidth: fullMouse.pressed || fullMouse.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall
|
}
|
||||||
opacity: {
|
}
|
||||||
if (!root.enabled)
|
|
||||||
return 0;
|
|
||||||
if (root.size === 1)
|
|
||||||
return 0;
|
|
||||||
if (fullMouse.pressed)
|
|
||||||
return 1;
|
|
||||||
if (fullMouse.containsMouse)
|
|
||||||
return 0.8;
|
|
||||||
if (root.policy === ScrollBar.AlwaysOn || root.shouldBeActive)
|
|
||||||
return 0.6;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
radius: Tokens.rounding.full
|
|
||||||
y: root.reversed ? root.height * (1 + root.nonAnimY) * root.travelScale : root.height * root.nonAnimY * root.travelScale
|
|
||||||
|
|
||||||
Behavior on implicitWidth {
|
Component {
|
||||||
Anim {
|
id: horizontalTrack
|
||||||
}
|
|
||||||
}
|
|
||||||
Behavior on opacity {
|
|
||||||
Anim {
|
|
||||||
type: Anim.DefaultEffects
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
HorizontalScrollBarTrack {
|
||||||
id: mouse
|
scrollBar: root
|
||||||
|
mouseArea: fullMouse
|
||||||
acceptedButtons: Qt.NoButton
|
|
||||||
anchors.fill: parent
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
hoverEnabled: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,25 +93,40 @@ ScrollBar {
|
|||||||
|
|
||||||
property real pressOffset: 0
|
property real pressOffset: 0
|
||||||
|
|
||||||
function contentYFromThumbTop(thumbTop) {
|
function contentPosFromThumbStart(thumbStart) {
|
||||||
var visualPos = root.effectiveTravel > 0 ? thumbTop / root.travelScale : 0;
|
var visualPos = root.effectiveTravel > 0 ? thumbStart / root.travelScale : 0;
|
||||||
|
return root.reversed ? (visualPos - 1) * root.axisContentSize : visualPos * root.axisContentSize;
|
||||||
return root.reversed ? (visualPos - 1) * root.flickable.contentHeight : visualPos * root.flickable.contentHeight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFromEvent(event) {
|
function updateFromEvent(event) {
|
||||||
var posInTrack = event.y / root.height;
|
var eventPos = root.isHorizontal ? event.x : event.y;
|
||||||
var thumbTop = posInTrack - pressOffset;
|
var posInTrack = eventPos / root.axisLength;
|
||||||
thumbTop = Math.max(0, Math.min(root.effectiveTravel, thumbTop));
|
var thumbStart = posInTrack - pressOffset;
|
||||||
|
thumbStart = Math.max(0, Math.min(root.effectiveTravel, thumbStart));
|
||||||
|
|
||||||
root.flickable.contentY = contentYFromThumbTop(thumbTop);
|
var newPos = contentPosFromThumbStart(thumbStart);
|
||||||
|
if (root.isHorizontal)
|
||||||
|
root.flickable.contentX = newPos + (root.flickable.originX + (root.reversed ? root.flickable.contentWidth : 0));
|
||||||
|
else
|
||||||
|
root.flickable.contentY = newPos + (root.flickable.originY + (root.reversed ? root.flickable.contentHeight : 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
function visualThumbTop() {
|
function visualThumbStart() {
|
||||||
const visualPos = root.reversed ? (1 + root.nonAnimY) : root.nonAnimY;
|
const visualPos = root.reversed ? (1 + root.nonAnimY) : root.nonAnimY;
|
||||||
return visualPos * root.travelScale;
|
return visualPos * root.travelScale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onWheel(event: WheelEvent): void {
|
||||||
|
if (root.horizontal) {
|
||||||
|
event.accepted = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var delta = event.angleDelta.y > 0 ? -0.1 : 0.1;
|
||||||
|
var newPos = Math.max(0, Math.min(1 - root.size, root.position + (root.inverted ? -delta : delta)));
|
||||||
|
root.position = newPos;
|
||||||
|
}
|
||||||
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
cursorShape: undefined
|
cursorShape: undefined
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
@@ -141,24 +137,15 @@ ScrollBar {
|
|||||||
updateFromEvent(event);
|
updateFromEvent(event);
|
||||||
}
|
}
|
||||||
onPressed: event => {
|
onPressed: event => {
|
||||||
var currentTop = visualThumbTop();
|
var currentStart = visualThumbStart();
|
||||||
var currentBottom = currentTop + root.effectiveSize;
|
var currentEnd = currentStart + root.effectiveSize;
|
||||||
var clickPos = event.y / root.height;
|
var eventPos = root.isHorizontal ? event.x : event.y;
|
||||||
|
var clickPos = eventPos / root.axisLength;
|
||||||
|
|
||||||
var clickedInsideThumb = clickPos >= currentTop && clickPos <= currentBottom;
|
var clickedInsideThumb = clickPos >= currentStart && clickPos <= currentEnd;
|
||||||
|
pressOffset = clickedInsideThumb ? (clickPos - currentStart) : root.effectiveSize / 2;
|
||||||
if (clickedInsideThumb) {
|
|
||||||
pressOffset = clickPos - currentTop;
|
|
||||||
} else {
|
|
||||||
pressOffset = root.effectiveSize / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFromEvent(event);
|
updateFromEvent(event);
|
||||||
}
|
}
|
||||||
onWheel: event => {
|
|
||||||
var delta = event.angleDelta.y > 0 ? -0.1 : 0.1;
|
|
||||||
var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta));
|
|
||||||
root.position = newPos;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
|
||||||
|
ShaderEffect {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property Item target: parent
|
||||||
|
|
||||||
|
property real glowSize: 40
|
||||||
|
property real innerGlow: 0
|
||||||
|
property real lineWidth: 2
|
||||||
|
property real intensity: 1.0
|
||||||
|
property real speed: 1.0
|
||||||
|
property int period: 6000
|
||||||
|
property bool running: true
|
||||||
|
|
||||||
|
property color color1: "#4285F4"
|
||||||
|
property color color2: "#9B72CB"
|
||||||
|
property color color3: "#D96570"
|
||||||
|
property color color4: "#F2A54A"
|
||||||
|
|
||||||
|
readonly property size itemSize: Qt.size(width, height)
|
||||||
|
readonly property size rectSize: target ? Qt.size(target.width, target.height) : Qt.size(0, 0)
|
||||||
|
readonly property real radius: (target as Rectangle).radius ?? 0
|
||||||
|
property real phase: 0
|
||||||
|
|
||||||
|
anchors.fill: target
|
||||||
|
anchors.margins: -glowSize
|
||||||
|
z: -1
|
||||||
|
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/geminiglow.frag.qsb`)
|
||||||
|
|
||||||
|
FrameAnimation {
|
||||||
|
running: root.running && root.visible
|
||||||
|
onTriggered: {
|
||||||
|
const dt = frameTime * 1000;
|
||||||
|
root.phase = (root.phase + dt * root.speed / root.period) % 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import QtQuick
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
CustomClippingRect {
|
||||||
|
id: track
|
||||||
|
|
||||||
|
required property var scrollBar
|
||||||
|
required property var mouseArea
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
implicitHeight: handle.implicitHeight
|
||||||
|
radius: Tokens.rounding.full
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: handle
|
||||||
|
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
color: Colors.palette.m3secondary
|
||||||
|
implicitWidth: track.scrollBar.width * track.scrollBar.effectiveSize
|
||||||
|
implicitHeight: track.mouseArea.pressed || track.mouseArea.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall
|
||||||
|
opacity: {
|
||||||
|
if (!track.scrollBar.enabled)
|
||||||
|
return 0;
|
||||||
|
if (track.scrollBar.size === 1)
|
||||||
|
return 0;
|
||||||
|
if (track.mouseArea.pressed)
|
||||||
|
return 1;
|
||||||
|
if (track.mouseArea.containsMouse)
|
||||||
|
return 0.8;
|
||||||
|
if (track.scrollBar.policy === CustomScrollBar.AlwaysOn || track.scrollBar.shouldBeActive)
|
||||||
|
return 0.6;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
radius: Tokens.rounding.full
|
||||||
|
x: track.scrollBar.reversed ? track.scrollBar.width * (1 + track.scrollBar.nonAnimY) * track.scrollBar.travelScale : track.scrollBar.width * track.scrollBar.nonAnimY * track.scrollBar.travelScale
|
||||||
|
|
||||||
|
Behavior on implicitHeight {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
hoverEnabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,11 +19,11 @@ PersistentProperties {
|
|||||||
property bool sidebar
|
property bool sidebar
|
||||||
|
|
||||||
// Dashboard state
|
// Dashboard state
|
||||||
property int dashboardTab
|
property int dashboardTab: 0
|
||||||
property date dashboardDate: new Date()
|
property date dashboardDate: new Date()
|
||||||
|
|
||||||
// Sidebar state
|
// Sidebar state
|
||||||
property int sidebarTab
|
property int sidebarTab: 0
|
||||||
property list<string> expandedNotifs: []
|
property list<string> expandedNotifs: []
|
||||||
|
|
||||||
// Utilities state
|
// Utilities state
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Templates
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
TextArea {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
color: Colors.palette.m3onSurface
|
||||||
|
cursorVisible: !readOnly
|
||||||
|
font: Tokens.font.body.small
|
||||||
|
implicitHeight: contentHeight + topPadding + bottomPadding
|
||||||
|
implicitWidth: contentWidth + leftPadding + rightPadding
|
||||||
|
placeholderTextColor: Colors.palette.m3onSurfaceVariant // No anim cause placeholder is custom
|
||||||
|
renderType: TextArea.NativeRendering
|
||||||
|
selectedTextColor: color
|
||||||
|
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
|
||||||
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
CAnim {}
|
||||||
|
}
|
||||||
|
Behavior on selectionColor {
|
||||||
|
CAnim {}
|
||||||
|
}
|
||||||
|
cursorDelegate: Item {}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: cursor
|
||||||
|
|
||||||
|
property bool disableBlink
|
||||||
|
|
||||||
|
color: Colors.palette.m3primary
|
||||||
|
implicitHeight: root.cursorRectangle.height
|
||||||
|
implicitWidth: 1.5
|
||||||
|
radius: Tokens.rounding.largeIncreased
|
||||||
|
x: root.cursorRectangle.x
|
||||||
|
y: root.cursorRectangle.y
|
||||||
|
|
||||||
|
Behavior on x {
|
||||||
|
Anim {
|
||||||
|
duration: Tokens.anim.durations.expressiveFastEffects
|
||||||
|
easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.StandardSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
function onCursorPositionChanged(): void {
|
||||||
|
if (root.activeFocus && root.cursorVisible) {
|
||||||
|
cursor.opacity = 1;
|
||||||
|
cursor.disableBlink = true;
|
||||||
|
enableBlink.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target: root
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: enableBlink
|
||||||
|
|
||||||
|
interval: 500
|
||||||
|
|
||||||
|
onTriggered: cursor.disableBlink = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 500
|
||||||
|
repeat: true
|
||||||
|
running: root.activeFocus && root.cursorVisible && !cursor.disableBlink
|
||||||
|
triggeredOnStart: true
|
||||||
|
|
||||||
|
onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Binding {
|
||||||
|
cursor.opacity: 0
|
||||||
|
when: !root.activeFocus || !root.cursorVisible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Templates
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
TextEdit {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property bool animateCursor: true
|
||||||
|
property alias cursor: cursor
|
||||||
|
|
||||||
|
color: Colors.palette.m3onSurface
|
||||||
|
cursorVisible: !readOnly
|
||||||
|
font: Tokens.font.body.small
|
||||||
|
renderType: TextField.NativeRendering
|
||||||
|
selectedTextColor: color
|
||||||
|
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
|
||||||
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
CAnim {}
|
||||||
|
}
|
||||||
|
cursorDelegate: Item {}
|
||||||
|
Behavior on selectionColor {
|
||||||
|
CAnim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: cursor
|
||||||
|
|
||||||
|
property bool disableBlink
|
||||||
|
|
||||||
|
color: Colors.palette.m3primary
|
||||||
|
implicitHeight: root.cursorRectangle.height
|
||||||
|
implicitWidth: 1.5
|
||||||
|
radius: Tokens.rounding.largeIncreased
|
||||||
|
x: root.cursorRectangle.x
|
||||||
|
y: root.cursorRectangle.y
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.StandardSmall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Behavior on x {
|
||||||
|
enabled: root.animateCursor
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
duration: Tokens.anim.durations.expressiveFastEffects
|
||||||
|
easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Behavior on y {
|
||||||
|
enabled: root.animateCursor
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
duration: Tokens.anim.durations.expressiveFastEffects
|
||||||
|
easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
function onCursorPositionChanged(): void {
|
||||||
|
if (root.activeFocus && root.cursorVisible) {
|
||||||
|
cursor.opacity = 1;
|
||||||
|
cursor.disableBlink = true;
|
||||||
|
enableBlink.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target: root
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: enableBlink
|
||||||
|
|
||||||
|
interval: 500
|
||||||
|
|
||||||
|
onTriggered: cursor.disableBlink = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 500
|
||||||
|
repeat: true
|
||||||
|
running: root.activeFocus && root.cursorVisible && !cursor.disableBlink
|
||||||
|
triggeredOnStart: true
|
||||||
|
|
||||||
|
onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Binding {
|
||||||
|
cursor.opacity: 0
|
||||||
|
when: !root.activeFocus || !root.cursorVisible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ CustomListView {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1
|
property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1
|
||||||
|
property bool fadeEnabled: true
|
||||||
property real fadeAmount: 0.1
|
property real fadeAmount: 0.1
|
||||||
|
property real fadeThreshold: 0.0
|
||||||
readonly property bool horizontal: orientation === ListView.Horizontal
|
readonly property bool horizontal: orientation === ListView.Horizontal
|
||||||
property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1
|
property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1
|
||||||
|
|
||||||
@@ -16,7 +18,9 @@ CustomListView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fadeShouldBeActive(isStart: bool): bool {
|
function fadeShouldBeActive(isStart: bool): bool {
|
||||||
// When content is smaller than flickable size, hide fade when rebound starts.
|
if (!fadeEnabled)
|
||||||
|
return false;
|
||||||
|
|
||||||
if (contentSize() + marginStart() + marginEnd() < viewportSize() && rebound.running && ((isStart ? overshootStart() > 0 : overshootStart() < 0))) {
|
if (contentSize() + marginStart() + marginEnd() < viewportSize() && rebound.running && ((isStart ? overshootStart() > 0 : overshootStart() < 0))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -28,11 +32,11 @@ CustomListView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function marginEnd(): real {
|
function marginEnd(): real {
|
||||||
return horizontal ? rightMargin : bottomMargin;
|
return horizontal ? rightMargin - fadeThreshold : bottomMargin - fadeThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
function marginStart(): real {
|
function marginStart(): real {
|
||||||
return horizontal ? leftMargin : topMargin;
|
return horizontal ? leftMargin - fadeThreshold : topMargin - fadeThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
function overshootStart(): real {
|
function overshootStart(): real {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import QtQuick
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
CustomClippingRect {
|
||||||
|
id: track
|
||||||
|
|
||||||
|
required property var scrollBar
|
||||||
|
required property var mouseArea
|
||||||
|
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
implicitWidth: handle.implicitWidth
|
||||||
|
radius: Tokens.rounding.full
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: handle
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
color: Colors.palette.m3secondary
|
||||||
|
implicitHeight: track.scrollBar.height * track.scrollBar.effectiveSize
|
||||||
|
implicitWidth: track.mouseArea.pressed || track.mouseArea.containsMouse ? Tokens.padding.extraSmall * 2 : Tokens.padding.extraSmall
|
||||||
|
opacity: {
|
||||||
|
if (!track.scrollBar.enabled)
|
||||||
|
return 0;
|
||||||
|
if (track.scrollBar.size === 1)
|
||||||
|
return 0;
|
||||||
|
if (track.mouseArea.pressed)
|
||||||
|
return 1;
|
||||||
|
if (track.mouseArea.containsMouse)
|
||||||
|
return 0.8;
|
||||||
|
if (track.scrollBar.policy === CustomScrollBar.AlwaysOn || track.scrollBar.shouldBeActive)
|
||||||
|
return 0.6;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
radius: Tokens.rounding.full
|
||||||
|
y: track.scrollBar.reversed ? track.scrollBar.height * (1 + track.scrollBar.nonAnimY) * track.scrollBar.travelScale : track.scrollBar.height * track.scrollBar.nonAnimY * track.scrollBar.travelScale
|
||||||
|
|
||||||
|
Behavior on implicitWidth {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
hoverEnabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-6
@@ -63,12 +63,10 @@ CustomWindow {
|
|||||||
name: "Bar"
|
name: "Bar"
|
||||||
|
|
||||||
Behavior on fsTransitionProg {
|
Behavior on fsTransitionProg {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Behavior on surfaceColor {
|
Behavior on surfaceColor {
|
||||||
CAnim {
|
CAnim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
contentItem.Keys.onEscapePressed: {
|
contentItem.Keys.onEscapePressed: {
|
||||||
@@ -288,8 +286,7 @@ CustomWindow {
|
|||||||
y: panels.popoutsWrapper.y + panels.popouts.y + geometry.insetTop(root.borderThickness) - (geometry.barOnTop ? panels.popouts.height * extraExtent : 0)
|
y: panels.popoutsWrapper.y + panels.popouts.y + geometry.insetTop(root.borderThickness) - (geometry.barOnTop ? panels.popouts.height * extraExtent : 0)
|
||||||
|
|
||||||
Behavior on extraExtent {
|
Behavior on extraExtent {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-13
@@ -123,33 +123,29 @@ Item {
|
|||||||
focus: true
|
focus: true
|
||||||
opacity: 0
|
opacity: 0
|
||||||
|
|
||||||
Behavior on opacity {
|
|
||||||
Anim {
|
|
||||||
duration: 300
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Behavior on rsx {
|
Behavior on rsx {
|
||||||
enabled: !selectArea.pressed && root.mode === "select"
|
enabled: !selectArea.pressed && root.mode === "select"
|
||||||
|
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Behavior on rsy {
|
Behavior on rsy {
|
||||||
enabled: !selectArea.pressed && root.mode === "select"
|
enabled: !selectArea.pressed && root.mode === "select"
|
||||||
|
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Behavior on sh {
|
Behavior on sh {
|
||||||
enabled: !selectArea.pressed && root.mode === "select"
|
enabled: !selectArea.pressed && root.mode === "select"
|
||||||
|
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Behavior on sw {
|
Behavior on sw {
|
||||||
enabled: !selectArea.pressed && root.mode === "select"
|
enabled: !selectArea.pressed && root.mode === "select"
|
||||||
|
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
Behavior on opacity {
|
||||||
Anim {
|
Anim {
|
||||||
|
duration: 300
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,8 +389,7 @@ Item {
|
|||||||
y: selectionRect.y - root.realBorderWidth
|
y: selectionRect.y - root.realBorderWidth
|
||||||
|
|
||||||
Behavior on border.color {
|
Behavior on border.color {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ WidgetBase {
|
|||||||
visible: root.horizontal
|
visible: root.horizontal
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
CAnim {
|
CAnim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +87,7 @@ WidgetBase {
|
|||||||
text: Qt.formatDateTime(Time.date, modelData)
|
text: Qt.formatDateTime(Time.date, modelData)
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
CAnim {
|
CAnim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,8 +124,7 @@ WidgetBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
CAnim {
|
CAnim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,8 +137,7 @@ WidgetBase {
|
|||||||
visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0
|
visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
CAnim {
|
CAnim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ CustomListView {
|
|||||||
return [0];
|
return [0];
|
||||||
case "variant":
|
case "variant":
|
||||||
return SchemeVariants.query(text);
|
return SchemeVariants.query(text);
|
||||||
|
case "files":
|
||||||
|
return Files.query(text);
|
||||||
default:
|
default:
|
||||||
return Apps.search(text);
|
return Apps.search(text);
|
||||||
}
|
}
|
||||||
@@ -33,7 +35,7 @@ CustomListView {
|
|||||||
function stateForText(text: string): string {
|
function stateForText(text: string): string {
|
||||||
const prefix = Config.launcher.actionPrefix;
|
const prefix = Config.launcher.actionPrefix;
|
||||||
if (text.startsWith(prefix)) {
|
if (text.startsWith(prefix)) {
|
||||||
for (const action of ["calc", "scheme", "variant"])
|
for (const action of ["calc", "scheme", "variant", "files"])
|
||||||
if (text.startsWith(`${prefix}${action} `))
|
if (text.startsWith(`${prefix}${action} `))
|
||||||
return action;
|
return action;
|
||||||
|
|
||||||
@@ -163,6 +165,13 @@ CustomListView {
|
|||||||
PropertyChanges {
|
PropertyChanges {
|
||||||
root.delegate: variantItem
|
root.delegate: variantItem
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
State {
|
||||||
|
name: "files"
|
||||||
|
|
||||||
|
PropertyChanges {
|
||||||
|
root.delegate: filesItem
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
transitions: Transition {
|
transitions: Transition {
|
||||||
@@ -264,6 +273,14 @@ CustomListView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: filesItem
|
||||||
|
|
||||||
|
FilesItem {
|
||||||
|
list: root
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
function onTextChanged() {
|
function onTextChanged() {
|
||||||
root.syncDisplayText();
|
root.syncDisplayText();
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ Item {
|
|||||||
} else if (text.startsWith(Config.launcher.actionPrefix)) {
|
} else if (text.startsWith(Config.launcher.actionPrefix)) {
|
||||||
if (text.startsWith(`${Config.launcher.actionPrefix}calc `))
|
if (text.startsWith(`${Config.launcher.actionPrefix}calc `))
|
||||||
currentItem.onClicked();
|
currentItem.onClicked();
|
||||||
|
else if (text.startsWith(`${Config.launcher.actionPrefix}files `))
|
||||||
|
currentItem.onClicked();
|
||||||
else
|
else
|
||||||
currentItem.modelData.onClicked(list.currentList);
|
currentItem.modelData.onClicked(list.currentList);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Item {
|
|||||||
implicitHeight: Config.launcher.sizes.itemHeight
|
implicitHeight: Config.launcher.sizes.itemHeight
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
radius: Tokens.rounding.small
|
radius: Tokens.rounding.medium
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.modelData?.onClicked(root.list);
|
root.modelData?.onClicked(root.list);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Item {
|
|||||||
implicitHeight: Config.launcher.sizes.itemHeight
|
implicitHeight: Config.launcher.sizes.itemHeight
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
radius: Tokens.rounding.small
|
radius: Tokens.rounding.medium
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
Apps.launch(root.modelData);
|
Apps.launch(root.modelData);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Item {
|
|||||||
implicitHeight: Config.launcher.sizes.itemHeight
|
implicitHeight: Config.launcher.sizes.itemHeight
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
radius: Tokens.rounding.small
|
radius: Tokens.rounding.medium
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.onClicked();
|
root.onClicked();
|
||||||
@@ -97,8 +97,7 @@ Item {
|
|||||||
text: qsTr("Open in calculator")
|
text: qsTr("Open in calculator")
|
||||||
|
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Item {
|
|||||||
implicitHeight: Config.launcher.sizes.itemHeight
|
implicitHeight: Config.launcher.sizes.itemHeight
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
radius: Tokens.rounding.small
|
radius: Tokens.rounding.medium
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.modelData?.onClicked(root.list);
|
root.modelData?.onClicked(root.list);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
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
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property ChatSession chatData
|
||||||
|
// 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
|
||||||
|
|
||||||
|
readonly property bool atNewestEnd: !listLoader.item || listLoader.item.contentY < 4
|
||||||
|
|
||||||
|
property var pendingAttachments: []
|
||||||
|
|
||||||
|
function attachFiles(): void {
|
||||||
|
Files.pickFiles().then(files => {
|
||||||
|
if (!files || files.length === 0)
|
||||||
|
return;
|
||||||
|
const added = chatData.addAttachments(files);
|
||||||
|
root.pendingAttachments = root.pendingAttachments.concat(added);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(text: string): void {
|
||||||
|
if (text.trim() === "" && root.pendingAttachments.length === 0)
|
||||||
|
return;
|
||||||
|
root.scrollToLatest();
|
||||||
|
chatData.sendMessage(text, root.pendingAttachments);
|
||||||
|
input.text = "";
|
||||||
|
root.pendingAttachments = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToLatest(): void {
|
||||||
|
if (!listLoader.item || root.atNewestEnd)
|
||||||
|
return;
|
||||||
|
scrollAnim.stop();
|
||||||
|
scrollAnim.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
id: scrollAnim
|
||||||
|
|
||||||
|
target: listLoader.item
|
||||||
|
property: "contentY"
|
||||||
|
to: 0
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
|
||||||
|
onFinished: if (target)
|
||||||
|
target.contentY = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: header
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
spacing: Tokens.spacing.large
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
icon: "arrow_back"
|
||||||
|
inactiveColor: Colors.tPalette.m3surfaceContainerHigh
|
||||||
|
inactiveOnColor: Colors.palette.m3onSurfaceVariant
|
||||||
|
isRound: true
|
||||||
|
type: IconButton.Tonal
|
||||||
|
|
||||||
|
enabled: !ChatState.isWindow
|
||||||
|
visible: enabled
|
||||||
|
|
||||||
|
onClicked: root.requestClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
Layout.fillWidth: ChatState.isWindow ? false : true
|
||||||
|
Layout.alignment: ChatState.isWindow ? Qt.AlignCenter : Qt.AlignLeft | Qt.AlignVCenter
|
||||||
|
elide: Text.ElideRight
|
||||||
|
font: Tokens.font.title.medium
|
||||||
|
text: qsTr(root.chatData?.title || ChatState.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: listLoader
|
||||||
|
|
||||||
|
anchors.bottom: input.top
|
||||||
|
anchors.bottomMargin: Tokens.spacing.medium
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: header.bottom
|
||||||
|
anchors.topMargin: Tokens.spacing.medium
|
||||||
|
active: root.chatData && root.chatData.loaded
|
||||||
|
|
||||||
|
sourceComponent: Flickable {
|
||||||
|
id: flick
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
clip: true
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: list.layoutHeight
|
||||||
|
|
||||||
|
transform: Rotation {
|
||||||
|
origin.y: flick.height / 2
|
||||||
|
origin.x: flick.width / 2
|
||||||
|
angle: 180
|
||||||
|
}
|
||||||
|
|
||||||
|
WheelInverter {
|
||||||
|
target: flick
|
||||||
|
}
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
id: restoreAnim
|
||||||
|
|
||||||
|
target: flick
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
property: "contentY"
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatListView {
|
||||||
|
id: list
|
||||||
|
|
||||||
|
width: flick.width
|
||||||
|
height: flick.height
|
||||||
|
yAnimDuration: Tokens.anim.durations.expressiveDefaultEffects
|
||||||
|
|
||||||
|
asynchronous: true
|
||||||
|
cacheBuffer: 50
|
||||||
|
estimatedHeight: 100
|
||||||
|
readyDelay: 5
|
||||||
|
removeDuration: Tokens.anim.durations.normal
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
model: root.chatData.messagesModel
|
||||||
|
|
||||||
|
useCustomViewport: true
|
||||||
|
viewport: {
|
||||||
|
tWatcher.transform;
|
||||||
|
return Qt.rect(0, flick.contentY, width, flick.height);
|
||||||
|
}
|
||||||
|
onViewportAdjustNeeded: delta => {
|
||||||
|
restoreAnim.stop();
|
||||||
|
flick.contentY += delta;
|
||||||
|
}
|
||||||
|
onViewportRestoreRequested: target => {
|
||||||
|
restoreAnim.to = target;
|
||||||
|
restoreAnim.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: MessageDelegate {
|
||||||
|
id: message
|
||||||
|
|
||||||
|
rootParent: flick
|
||||||
|
chatList: list
|
||||||
|
ChatListView.preferredHeight: implicitHeight
|
||||||
|
ChatListView.trackViewport: true
|
||||||
|
ChatListView.visibleHeight: ChatListView.removing ? 0 : implicitHeight
|
||||||
|
opacity: ChatListView.removing || ChatListView.adding ? 0 : 1
|
||||||
|
transform: Rotation {
|
||||||
|
origin.y: message.height / 2
|
||||||
|
origin.x: message.width / 2
|
||||||
|
angle: 180
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on y {
|
||||||
|
enabled: message.ChatListView.ready
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
id: yAnim
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on x {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomScrollBar.vertical: CustomScrollBar {
|
||||||
|
id: scrollBar
|
||||||
|
|
||||||
|
flickable: flick
|
||||||
|
parent: flick.parent
|
||||||
|
anchors.top: flick.top
|
||||||
|
anchors.right: flick.right
|
||||||
|
anchors.bottom: flick.bottom
|
||||||
|
inverted: true
|
||||||
|
|
||||||
|
transform: Rotation {
|
||||||
|
origin.y: flick.height / 2
|
||||||
|
origin.x: scrollBar.width / 2
|
||||||
|
angle: 180
|
||||||
|
|
||||||
|
axis {
|
||||||
|
y: 0
|
||||||
|
x: 1
|
||||||
|
z: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TransformWatcher {
|
||||||
|
id: tWatcher
|
||||||
|
|
||||||
|
a: flick.contentItem
|
||||||
|
b: list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: if (root.chatData)
|
||||||
|
root.chatData.messagesModel
|
||||||
|
}
|
||||||
|
|
||||||
|
EmptyBackground {
|
||||||
|
id: emptyState
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: Tokens.spacing.small
|
||||||
|
visible: root.chatData.messageCount < 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
anchors.bottom: input.top
|
||||||
|
anchors.bottomMargin: Tokens.spacing.extraLarge
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
implicitHeight: scrollToBottom.implicitHeight
|
||||||
|
implicitWidth: scrollToBottom.implicitWidth
|
||||||
|
scale: root.atNewestEnd || !listLoader.item || listLoader.item.contentHeight <= listLoader.item.height ? 0 : 1
|
||||||
|
visible: scale > 0
|
||||||
|
|
||||||
|
Behavior on scale {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Elevation {
|
||||||
|
anchors.fill: parent
|
||||||
|
level: 2
|
||||||
|
radius: scrollToBottom.radius
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
id: scrollToBottom
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
font: Tokens.font.icon.large
|
||||||
|
icon: "arrow_downward"
|
||||||
|
isRound: true
|
||||||
|
padding: Tokens.padding.extraSmall
|
||||||
|
type: IconButton.Tonal
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
root.scrollToLatest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GridView {
|
||||||
|
id: attachments
|
||||||
|
|
||||||
|
readonly property int columns: ChatState.isWindow ? 5 : 3
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: input.top
|
||||||
|
anchors.bottomMargin: Tokens.spacing.medium
|
||||||
|
|
||||||
|
flow: GridView.LeftToRight
|
||||||
|
verticalLayoutDirection: GridView.BottomToTop
|
||||||
|
|
||||||
|
cellWidth: width / columns
|
||||||
|
cellHeight: cellWidth
|
||||||
|
|
||||||
|
implicitHeight: Math.ceil(count / columns) * cellHeight
|
||||||
|
|
||||||
|
displaced: Transition {
|
||||||
|
Anim {
|
||||||
|
property: "x"
|
||||||
|
}
|
||||||
|
Anim {
|
||||||
|
property: "y"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add: Transition {
|
||||||
|
id: addTrans
|
||||||
|
Anim {
|
||||||
|
property: "y"
|
||||||
|
from: addTrans.ViewTransition.destination.y + attachments.cellHeight / 4
|
||||||
|
}
|
||||||
|
Anim {
|
||||||
|
property: "opacity"
|
||||||
|
from: 0
|
||||||
|
to: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remove: Transition {
|
||||||
|
id: removeTrans
|
||||||
|
Anim {
|
||||||
|
property: "y"
|
||||||
|
to: removeTrans.ViewTransition.destination.y + attachments.cellHeight / 4
|
||||||
|
}
|
||||||
|
Anim {
|
||||||
|
property: "opacity"
|
||||||
|
from: 1
|
||||||
|
to: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model: ScriptModel {
|
||||||
|
objectProp: "path"
|
||||||
|
values: root.pendingAttachments
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: Item {
|
||||||
|
id: thumb
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
function fileIcon(mime): string {
|
||||||
|
if (mime.startsWith("archive/") || mime === "application/zip")
|
||||||
|
return "folder_zip";
|
||||||
|
if (mime === "application/pdf")
|
||||||
|
return "picture_as_pdf";
|
||||||
|
if (mime.startsWith("audio/"))
|
||||||
|
return "audio_file";
|
||||||
|
if (mime.startsWith("video/"))
|
||||||
|
return "video_file";
|
||||||
|
if (mime.startsWith("text/"))
|
||||||
|
return "description";
|
||||||
|
return "insert_drive_file";
|
||||||
|
}
|
||||||
|
|
||||||
|
width: attachments.cellWidth
|
||||||
|
height: width
|
||||||
|
opacity: modelData ? 1 : 0
|
||||||
|
CustomClippingRect {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Tokens.spacing.extraSmall
|
||||||
|
|
||||||
|
radius: Tokens.rounding.medium
|
||||||
|
color: Colors.tPalette.m3surfaceContainer
|
||||||
|
|
||||||
|
FadeImage {
|
||||||
|
id: attImage
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
fillMode: FadeImage.PreserveAspectCrop
|
||||||
|
asynchronous: true
|
||||||
|
source: visible ? thumb.modelData?.path ?? "" : ""
|
||||||
|
visible: thumb.modelData?.isImage ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
fontStyle: Tokens.font.icon.large
|
||||||
|
text: thumb.fileIcon(thumb.modelData?.mimeType ?? "")
|
||||||
|
visible: !thumb.modelData?.isImage ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: attIndicatorLoader
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
opacity: 0
|
||||||
|
active: opacity > 0 && (thumb.modelData?.isImage ?? false)
|
||||||
|
|
||||||
|
sourceComponent: CustomRect {
|
||||||
|
implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
|
||||||
|
implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
|
||||||
|
|
||||||
|
color: Colors.palette.m3primaryContainer
|
||||||
|
radius: Tokens.rounding.full
|
||||||
|
|
||||||
|
LoadingIndicator {
|
||||||
|
id: attLoadingIndicator
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
containsIcon: true
|
||||||
|
implicitSize: thumb.implicitWidth * 0.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: attLoadDebounceTimer
|
||||||
|
|
||||||
|
interval: 100
|
||||||
|
onTriggered: {
|
||||||
|
if (attImage.status !== Image.Ready)
|
||||||
|
attIndicatorLoader.opacity = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: hover
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: undefined
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
|
||||||
|
icon: "close"
|
||||||
|
visible: opacity > 0
|
||||||
|
enabled: thumb.opacity > 0
|
||||||
|
opacity: hover.containsMouse ? 1 : 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: root.pendingAttachments = root.pendingAttachments.filter(a => a !== thumb.modelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatInput {
|
||||||
|
id: input
|
||||||
|
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
bg.border.color: Colors.palette.m3outlineVariant
|
||||||
|
bg.color: Colors.tPalette.m3surfaceContainerLowest
|
||||||
|
fastGlow: LlmClient.busy
|
||||||
|
glow.opacity: input.activeFocus && (!LlmClient.busy || ChatState.isWindow)
|
||||||
|
font: Tokens.font.body.small
|
||||||
|
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
|
||||||
|
focus: true
|
||||||
|
placeholderText: qsTr("Send a message")
|
||||||
|
sendIcon.font: Tokens.font.icon.medium
|
||||||
|
sendIcon.icon: LlmClient.busy ? "stop" : "arrow_upward"
|
||||||
|
sendIcon.padding: Tokens.padding.extraSmall
|
||||||
|
sendIcon.opacity: text || LlmClient.busy ? 1.0 : 0.5
|
||||||
|
sendIcon.enabled: text || LlmClient.busy
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
root.focusInput();
|
||||||
|
}
|
||||||
|
Keys.onPressed: e => {
|
||||||
|
if (e.key == Qt.Key_Return) {
|
||||||
|
if (!(e.modifiers & Qt.ShiftModifier)) {
|
||||||
|
root.send(text);
|
||||||
|
e.accepted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key == Qt.Key_V && e.modifiers & Qt.ControlModifier) {
|
||||||
|
if (root.chatData.clipboardHasAttachment()) {
|
||||||
|
const att = root.chatData.pasteAttachments();
|
||||||
|
root.pendingAttachments = [...root.pendingAttachments, ...att];
|
||||||
|
e.accepted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onButtonPressed: {
|
||||||
|
if (LlmClient.busy)
|
||||||
|
LlmClient.stop();
|
||||||
|
else
|
||||||
|
root.send(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import QtQuick
|
||||||
|
import ZShell.Components
|
||||||
|
import ZShell.Config
|
||||||
|
import ZShell.Llm
|
||||||
|
import qs.Effects
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
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: 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)
|
||||||
|
h += body.implicitHeight + body.topMargin + actionRow.implicitHeight + actionRow.anchors.topMargin;
|
||||||
|
else
|
||||||
|
h += preview.implicitHeight + preview.topMargin;
|
||||||
|
|
||||||
|
h += Tokens.padding.small;
|
||||||
|
|
||||||
|
const icon = chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2;
|
||||||
|
if (h < icon)
|
||||||
|
return icon;
|
||||||
|
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
|
||||||
|
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
id: chatIcon
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Tokens.padding.large
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: Tokens.padding.large
|
||||||
|
fontStyle: Tokens.font.icon.extraLarge
|
||||||
|
text: root.modelData.icon || "check"
|
||||||
|
color: Colors.palette.m3onSurface
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: infoContainer
|
||||||
|
|
||||||
|
anchors.left: chatIcon.right
|
||||||
|
anchors.leftMargin: Tokens.spacing.medium
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.right: expandBtn.left
|
||||||
|
anchors.rightMargin: Tokens.spacing.medium
|
||||||
|
implicitHeight: {
|
||||||
|
let h = 0;
|
||||||
|
h += title.implicitHeight + title.anchors.topMargin;
|
||||||
|
h += timestamp.implicitHeight + timestamp.anchors.topMargin;
|
||||||
|
if (root.expanded)
|
||||||
|
h += created.implicitHeight + created.anchors.topMargin;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
opacity: root.slim ? 0 : 1
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: title
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.topMargin: Tokens.padding.extraSmall
|
||||||
|
anchors.top: parent.top
|
||||||
|
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 1)
|
||||||
|
implicitHeight: titleText.implicitHeight + Tokens.padding.extraSmall * 2
|
||||||
|
radius: Tokens.rounding.full
|
||||||
|
implicitWidth: titleText.implicitWidth + Tokens.padding.large * 2
|
||||||
|
opacity: titleText.readOnly ? 0 : 1
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
anchors.leftMargin: readOnly ? 0 : Tokens.padding.large
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: readOnly ? Tokens.padding.small : Tokens.padding.extraSmall * 2
|
||||||
|
font: Tokens.font.body.medium
|
||||||
|
text: root.modelData.title || qsTr("New chat")
|
||||||
|
readOnly: true
|
||||||
|
color: Colors.palette.m3onSurface
|
||||||
|
|
||||||
|
Behavior on anchors.leftMargin {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
Behavior on anchors.topMargin {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onEditingFinished: root.modelData.title = text
|
||||||
|
onReadOnlyChanged: {
|
||||||
|
if (!readOnly) {
|
||||||
|
this.forceActiveFocus();
|
||||||
|
cursorPosition = text.length;
|
||||||
|
} else {
|
||||||
|
root.forceActiveFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: fadeMask
|
||||||
|
|
||||||
|
anchors.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
|
||||||
|
|
||||||
|
anchors.top: title.bottom
|
||||||
|
anchors.topMargin: Tokens.spacing.extraSmall
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
font: Tokens.font.label.small
|
||||||
|
color: Colors.palette.m3onSurfaceVariant
|
||||||
|
text: qsTr("Created on: %1").arg(root.modelData.createdAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP"))
|
||||||
|
opacity: root.expanded ? 1 : 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: updated
|
||||||
|
|
||||||
|
anchors.top: created.bottom
|
||||||
|
anchors.topMargin: Tokens.spacing.small
|
||||||
|
anchors.left: parent.left
|
||||||
|
font: Tokens.font.label.small
|
||||||
|
color: Colors.palette.m3onSurfaceVariant
|
||||||
|
text: qsTr("Updated on: ")
|
||||||
|
opacity: root.expanded ? 1 : 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: timestamp
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.top: title.bottom
|
||||||
|
anchors.topMargin: Tokens.spacing.extraSmall
|
||||||
|
color: Colors.palette.m3onSurfaceVariant
|
||||||
|
font: Tokens.font.label.small
|
||||||
|
text: root.modelData.updatedAt.toLocaleString(Qt.locale("en_US"), "MMM d, yyyy - h:mm AP")
|
||||||
|
|
||||||
|
states: State {
|
||||||
|
name: "expanded"
|
||||||
|
when: root.expanded
|
||||||
|
|
||||||
|
AnchorChanges {
|
||||||
|
target: timestamp
|
||||||
|
anchors.left: updated.right
|
||||||
|
anchors.top: undefined
|
||||||
|
anchors.verticalCenter: updated.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transitions: Transition {
|
||||||
|
AnchorAnim {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: preview
|
||||||
|
|
||||||
|
readonly property int topMargin: Tokens.spacing.extraSmall
|
||||||
|
|
||||||
|
anchors.left: chatIcon.right
|
||||||
|
anchors.margins: Tokens.spacing.medium
|
||||||
|
anchors.right: parent.right
|
||||||
|
y: infoContainer.implicitHeight + topMargin
|
||||||
|
elide: Text.ElideRight
|
||||||
|
color: Colors.palette.m3onSurfaceVariant
|
||||||
|
font: Tokens.font.body.small
|
||||||
|
maximumLineCount: 1
|
||||||
|
opacity: !root.expanded && !root.slim ? 1 : 0
|
||||||
|
text: root.modelData.messagesModel.lastMessage?.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet")
|
||||||
|
|
||||||
|
Behavior on y {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: body
|
||||||
|
|
||||||
|
readonly property int topMargin: root.expanded ? Tokens.spacing.medium : Tokens.spacing.extraSmall
|
||||||
|
|
||||||
|
anchors.left: chatIcon.right
|
||||||
|
anchors.margins: Tokens.spacing.medium
|
||||||
|
anchors.right: parent.right
|
||||||
|
y: infoContainer.implicitHeight + topMargin
|
||||||
|
animate: true
|
||||||
|
color: Colors.palette.m3onSurfaceVariant
|
||||||
|
maximumLineCount: 5
|
||||||
|
opacity: root.expanded ? 1 : 0
|
||||||
|
text: root.modelData.messagesModel.lastMessage?.content ?? qsTr("No messages yet")
|
||||||
|
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||||
|
|
||||||
|
Behavior on y {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StateLayer {
|
||||||
|
id: sLayer
|
||||||
|
|
||||||
|
property int startY
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
root.clicked(root.modelData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ButtonRow {
|
||||||
|
id: actionRow
|
||||||
|
|
||||||
|
anchors.top: body.bottom
|
||||||
|
anchors.left: chatIcon.right
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.topMargin: Tokens.spacing.small
|
||||||
|
anchors.leftMargin: Tokens.spacing.medium
|
||||||
|
anchors.rightMargin: Tokens.padding.medium
|
||||||
|
spacing: Tokens.spacing.medium
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
icon: "close"
|
||||||
|
fillWidth: true
|
||||||
|
shapeMorph: true
|
||||||
|
isRound: true
|
||||||
|
font: Tokens.font.icon.medium
|
||||||
|
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
|
||||||
|
inactiveOnColor: Colors.palette.m3onSurfaceVariant
|
||||||
|
|
||||||
|
onClicked: root.remove(root.modelData)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
icon: titleText.readOnly ? "edit" : "check"
|
||||||
|
label.animate: true
|
||||||
|
isToggle: true
|
||||||
|
fillWidth: true
|
||||||
|
shapeMorph: true
|
||||||
|
font: Tokens.font.icon.medium
|
||||||
|
isRound: true
|
||||||
|
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
|
||||||
|
inactiveOnColor: Colors.palette.m3onSurfaceVariant
|
||||||
|
activeColor: Colors.palette.m3secondary
|
||||||
|
activeOnColor: Colors.palette.m3onSecondary
|
||||||
|
checked: !titleText.readOnly
|
||||||
|
|
||||||
|
onClicked: titleText.readOnly = !titleText.readOnly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: expandBtn
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.margins: Tokens.padding.small
|
||||||
|
implicitHeight: expandIcon.implicitHeight
|
||||||
|
implicitWidth: expandIcon.implicitHeight
|
||||||
|
radius: Tokens.rounding.full
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
id: expandIcon
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
Behavior on rotation {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import QtQuick
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
TextAreaBase {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property alias bg: bg
|
||||||
|
readonly property alias sendIcon: sendIcon
|
||||||
|
readonly property alias glow: glow
|
||||||
|
|
||||||
|
property bool fastGlow: false
|
||||||
|
|
||||||
|
signal buttonPressed
|
||||||
|
|
||||||
|
bottomPadding: Tokens.padding.large
|
||||||
|
leftPadding: Tokens.padding.extraLarge
|
||||||
|
rightPadding: sendIcon.width + sendIcon.anchors.rightMargin + Tokens.spacing.small
|
||||||
|
topPadding: Tokens.padding.large
|
||||||
|
wrapMode: TextAreaBase.Wrap
|
||||||
|
|
||||||
|
onFastGlowChanged: {
|
||||||
|
if (fastGlow) {
|
||||||
|
resetAnim.stop();
|
||||||
|
boostAnim.restart();
|
||||||
|
} else {
|
||||||
|
boostAnim.stop();
|
||||||
|
resetAnim.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
background: CustomRect {
|
||||||
|
id: bg
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
color: Colors.tPalette.m3surfaceContainer
|
||||||
|
radius: Tokens.rounding.extraLarge
|
||||||
|
|
||||||
|
GeminiGlow {
|
||||||
|
id: glow
|
||||||
|
glowSize: 32
|
||||||
|
z: 5
|
||||||
|
|
||||||
|
opacity: root.activeFocus ? 1 : 0
|
||||||
|
visible: opacity > 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
SequentialAnimation {
|
||||||
|
id: boostAnim
|
||||||
|
|
||||||
|
NumberAnimation {
|
||||||
|
target: glow
|
||||||
|
property: "speed"
|
||||||
|
to: 4.0
|
||||||
|
duration: 250
|
||||||
|
easing.type: Easing.OutCubic
|
||||||
|
}
|
||||||
|
NumberAnimation {
|
||||||
|
target: glow
|
||||||
|
property: "speed"
|
||||||
|
to: 2.25
|
||||||
|
duration: 1500
|
||||||
|
easing.type: Easing.InOutQuad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ease back down to normal once fastGlow is false
|
||||||
|
NumberAnimation {
|
||||||
|
id: resetAnim
|
||||||
|
target: glow
|
||||||
|
property: "speed"
|
||||||
|
to: 1.0
|
||||||
|
duration: 800
|
||||||
|
easing.type: Easing.InOutQuad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StateLayer {
|
||||||
|
id: stateLayer
|
||||||
|
|
||||||
|
cursorShape: Qt.IBeamCursor
|
||||||
|
enabled: !root.activeFocus
|
||||||
|
manualPressOverride: tapHandler.pressed
|
||||||
|
|
||||||
|
onClicked: root.forceActiveFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPressed: {
|
||||||
|
if (stateLayer.enabled)
|
||||||
|
stateLayer.press(stateLayer.mouseX, stateLayer.mouseY);
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: placeholder
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: root.leftPadding
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
color: root.placeholderTextColor
|
||||||
|
font: root.font
|
||||||
|
opacity: root.text ? 0 : 1
|
||||||
|
text: root.placeholderText
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
id: sendIcon
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Tokens.padding.small
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
enabled: root.text
|
||||||
|
icon: "upward_arrow"
|
||||||
|
opacity: root.text ? 1 : 0
|
||||||
|
radius: Tokens.rounding.full
|
||||||
|
radiusMorph: false
|
||||||
|
stateLayer.hoverEnabled: enabled
|
||||||
|
type: IconButton.Text
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: root.buttonPressed()
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
id: tapHandler
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ZShell.Llm
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Modules.Notifications.Sidebar.Chat.Content
|
||||||
|
import qs.Services
|
||||||
|
import qs.Components
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
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, index: int)
|
||||||
|
signal newChatRequest
|
||||||
|
|
||||||
|
onSlimChanged: {
|
||||||
|
if (slim)
|
||||||
|
modelsContainer.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: chatHeader
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.margins: Tokens.padding.extraSmall
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
color: Colors.palette.m3outline
|
||||||
|
font.family: "CaskaydiaCove NF"
|
||||||
|
font.pointSize: 13
|
||||||
|
font.weight: 500
|
||||||
|
text: qsTr("%1 Chat%2").arg(list.count).arg(list.count > 1 ? "s" : "")
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomListView {
|
||||||
|
id: list
|
||||||
|
|
||||||
|
anchors.bottom: modelsContainer.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: chatHeader.bottom
|
||||||
|
anchors.topMargin: Tokens.spacing.medium
|
||||||
|
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
|
||||||
|
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModelsContainer {
|
||||||
|
id: modelsContainer
|
||||||
|
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: fabRoot.left
|
||||||
|
anchors.margins: Tokens.padding.small
|
||||||
|
anchors.rightMargin: Tokens.spacing.small
|
||||||
|
|
||||||
|
rootParent: root
|
||||||
|
fabRoot: fabRoot
|
||||||
|
|
||||||
|
opacity: root.slim ? 0 : 1
|
||||||
|
visible: opacity > 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
id: fabRoot
|
||||||
|
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: Tokens.padding.small
|
||||||
|
padding: 8
|
||||||
|
font.pointSize: Math.round(18 * 1.2)
|
||||||
|
icon: root.slim ? "left_panel_open" : "add"
|
||||||
|
isRound: ChatState.fabExpanded
|
||||||
|
|
||||||
|
label.transform: Rotation {
|
||||||
|
origin.y: fabRoot.label.height / 2
|
||||||
|
origin.x: fabRoot.label.width / 2
|
||||||
|
angle: ChatState.fabExpanded && (ChatState.narrowSidebarExpanded && !ChatState.isWide || !ChatState.isWindow) ? 135 : 0
|
||||||
|
|
||||||
|
Behavior on angle {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
if (root.slim && !ChatState.narrowSidebarExpanded) {
|
||||||
|
root.requestExpand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatState.fabExpanded = !ChatState.fabExpanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
Elevation {
|
||||||
|
anchors.fill: parent
|
||||||
|
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
|
||||||
|
fontStyle: Tokens.font.icon.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import ZShell.Config
|
||||||
|
import ZShell.Llm
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import Quickshell
|
||||||
|
import qs.Helpers
|
||||||
|
import qs.Modules.Notifications.Sidebar
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Tokens.padding.small
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (ChatState.firstOpen) {
|
||||||
|
ChatState.openDraft();
|
||||||
|
stack.push(chatList);
|
||||||
|
stack.push(chatContent);
|
||||||
|
ChatState.firstOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ChatState.inChat) {
|
||||||
|
stack.push(chatList);
|
||||||
|
stack.push(chatContent);
|
||||||
|
} else
|
||||||
|
stack.push(chatList);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChat(): void {
|
||||||
|
if (stack.depth < 2)
|
||||||
|
stack.push(chatContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
StackView {
|
||||||
|
id: stack
|
||||||
|
anchors.fill: parent
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: chatList
|
||||||
|
|
||||||
|
ChatList {
|
||||||
|
model: LlmClient.chats
|
||||||
|
|
||||||
|
onDeleteChatRequest: chat => {
|
||||||
|
const wasCurrent = chat === ChatState.chatSession;
|
||||||
|
LlmClient.chats.remove(chat);
|
||||||
|
if (wasCurrent)
|
||||||
|
ChatState.openDraft();
|
||||||
|
}
|
||||||
|
onLoadChatRequest: (chat, index) => {
|
||||||
|
ChatState.chatSession = chat;
|
||||||
|
openChat();
|
||||||
|
ChatState.inChat = true;
|
||||||
|
}
|
||||||
|
onNewChatRequest: {
|
||||||
|
ChatState.openDraft();
|
||||||
|
openChat();
|
||||||
|
ChatState.inChat = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: chatContent
|
||||||
|
|
||||||
|
ChatContent {
|
||||||
|
chatData: ChatState.chatSession
|
||||||
|
|
||||||
|
onRequestClose: {
|
||||||
|
stack.pop();
|
||||||
|
ChatState.inChat = false;
|
||||||
|
ChatState.openDraft();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import ZShell.Llm
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property ChatSession chatSession: LlmClient.chats.draft()
|
||||||
|
readonly property string title: chatSession?.title || "Chat"
|
||||||
|
property int currentIdx: -1
|
||||||
|
property bool firstOpen: true
|
||||||
|
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: ShellState.forActive().sidebar
|
||||||
|
|
||||||
|
function openDraft(): void {
|
||||||
|
root.chatSession = LlmClient.chats.draft();
|
||||||
|
root.currentIdx = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSidebarVisibleChanged: if (!sidebarVisible)
|
||||||
|
fabExpanded = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import ZShell.Config
|
||||||
|
import ZShell.Components
|
||||||
|
import ZShell.Llm
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property TextEdit edit
|
||||||
|
required property bool hovered
|
||||||
|
required property bool isUser
|
||||||
|
required property ChatMessage message
|
||||||
|
required property bool running
|
||||||
|
|
||||||
|
implicitHeight: editButton.implicitHeight
|
||||||
|
implicitWidth: generationTools.implicitWidth + editTools.implicitWidth + Tokens.spacing.small
|
||||||
|
|
||||||
|
ButtonRow {
|
||||||
|
id: generationTools
|
||||||
|
|
||||||
|
enabled: visible
|
||||||
|
visible: !root.isUser
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== 0
|
||||||
|
icon: "chevron_left"
|
||||||
|
type: IconButton.Tonal
|
||||||
|
isRound: true
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
if (!root.edit.readOnly)
|
||||||
|
root.edit.readOnly = true;
|
||||||
|
root.message.setActiveGeneration(root.message.activeGenerationIndex - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
enabled: false
|
||||||
|
icon: `${root.message.activeGenerationIndex + 1}`
|
||||||
|
label.font: Tokens.font.body.small
|
||||||
|
type: IconButton.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== root.message.generationCount - 1
|
||||||
|
icon: "chevron_right"
|
||||||
|
type: IconButton.Tonal
|
||||||
|
isRound: true
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
if (!root.edit.readOnly)
|
||||||
|
root.edit.readOnly = true;
|
||||||
|
root.message.setActiveGeneration(root.message.activeGenerationIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ButtonRow {
|
||||||
|
id: editTools
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
opacity: !root.running && root.hovered ? 1 : 0
|
||||||
|
spacing: Tokens.spacing.small
|
||||||
|
visible: opacity > 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
icon: "refresh"
|
||||||
|
inactiveColor: Colors.palette.m3secondary
|
||||||
|
inactiveOnColor: Colors.palette.m3onSecondary
|
||||||
|
scale: root.edit.readOnly ? 1 : 0
|
||||||
|
visible: !root.isUser
|
||||||
|
isRound: true
|
||||||
|
|
||||||
|
Behavior on scale {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
root.message.retry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
icon: "content_copy"
|
||||||
|
inactiveColor: Colors.palette.m3secondary
|
||||||
|
inactiveOnColor: Colors.palette.m3onSecondary
|
||||||
|
scale: root.edit.readOnly ? 1 : 0
|
||||||
|
isRound: true
|
||||||
|
|
||||||
|
Behavior on scale {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: Quickshell.clipboardText = root.message.content
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
id: editButton
|
||||||
|
|
||||||
|
icon: root.edit.readOnly ? "edit" : "check"
|
||||||
|
inactiveColor: Colors.palette.m3tertiary
|
||||||
|
inactiveOnColor: Colors.palette.m3onTertiary
|
||||||
|
visible: root.isUser
|
||||||
|
isRound: true
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
root.edit.readOnly = !root.edit.readOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ZShell.Config
|
||||||
|
import ZShell.Components
|
||||||
|
import ZShell.Llm
|
||||||
|
import ZShell.Blobs
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property Item rootParent
|
||||||
|
required property ChatGeneration current
|
||||||
|
|
||||||
|
implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: approvalBg
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Tokens.rounding.medium
|
||||||
|
color: Colors.palette.m3surface
|
||||||
|
border.width: 1
|
||||||
|
border.color: Colors.palette.m3outlineVariant
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: approvalLayout
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
spacing: Tokens.spacing.small
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: requestHeader
|
||||||
|
text: qsTr("Tool call request")
|
||||||
|
font: Tokens.font.headline.medium
|
||||||
|
color: Colors.palette.m3onSurface
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: toolsBg
|
||||||
|
|
||||||
|
property bool open: false
|
||||||
|
property int maxHeight: root.rootParent.height - Tokens.padding.medium * 2
|
||||||
|
property int openHeight: Math.min(toolsContent.item?.implicitHeight, maxHeight)
|
||||||
|
property int openWidth: root.rootParent.width - Tokens.padding.medium * 2
|
||||||
|
property real openHeightProg: openHeight / maxHeight
|
||||||
|
property int collapsedY: 0
|
||||||
|
|
||||||
|
implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2
|
||||||
|
z: open || dialogTransition.running ? 2 : 0
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
function reparentWrapper(): void {
|
||||||
|
const newParent = open ? root.rootParent : toolsBg;
|
||||||
|
const c = toolsWrapper.parent.mapToItem(newParent, toolsWrapper.x + toolsWrapper.width / 2, toolsWrapper.y + toolsWrapper.height / 2);
|
||||||
|
toolsBg.collapsedY = toolsWrapper.y;
|
||||||
|
toolsWrapper.parent = newParent;
|
||||||
|
toolsWrapper.x = c.x - toolsWrapper.width / 2;
|
||||||
|
toolsWrapper.y = c.y - toolsWrapper.height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChanged: {
|
||||||
|
if (open)
|
||||||
|
root.rootParent.interactive = false;
|
||||||
|
else
|
||||||
|
root.rootParent.interactive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlobGroup {
|
||||||
|
id: blobGroup
|
||||||
|
|
||||||
|
color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
CAnim {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: backdrop
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: false
|
||||||
|
hoverEnabled: enabled
|
||||||
|
preventStealing: true
|
||||||
|
parent: toolsBg.open ? root.rootParent : toolsBg
|
||||||
|
|
||||||
|
onClicked: toolsBg.open = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: toolsWrapper
|
||||||
|
|
||||||
|
width: toolsBg.width
|
||||||
|
height: toolBox.implicitHeight
|
||||||
|
rotation: parent === root.rootParent ? 180 : 0
|
||||||
|
z: 1
|
||||||
|
|
||||||
|
states: State {
|
||||||
|
name: "open"
|
||||||
|
when: toolsBg.open
|
||||||
|
|
||||||
|
PropertyChanges {
|
||||||
|
backdrop.enabled: true
|
||||||
|
toolsBackground.bottomLeftRadius: Tokens.rounding.largeIncreased
|
||||||
|
toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased
|
||||||
|
toolsBackground.topRightRadius: Tokens.rounding.largeIncreased
|
||||||
|
toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased
|
||||||
|
toolsContent.opacity: 1
|
||||||
|
toolsWrapper.height: toolsBg.openHeight
|
||||||
|
toolsWrapper.width: toolsBg.openWidth
|
||||||
|
toolsWrapper.x: (root.rootParent.width - toolsBg.openWidth) / 2
|
||||||
|
toolsWrapper.y: (confirmationRow.implicitHeight + Tokens.spacing.small + Tokens.padding.medium) * (1 - toolsBg.openHeightProg) + Tokens.padding.medium
|
||||||
|
elevation.opacity: 1
|
||||||
|
toolBox.opacity: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transitions: Transition {
|
||||||
|
id: dialogTransition
|
||||||
|
|
||||||
|
SequentialAnimation {
|
||||||
|
ScriptAction {
|
||||||
|
script: toolsBg.reparentWrapper()
|
||||||
|
}
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
properties: "x,y"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PropertyAction {
|
||||||
|
property: "enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
properties: "width,height"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Elevation {
|
||||||
|
id: elevation
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
bottomLeftRadius: toolsBackground.bottomLeftRadius
|
||||||
|
bottomRightRadius: toolsBackground.bottomRightRadius
|
||||||
|
level: 4
|
||||||
|
opacity: 0
|
||||||
|
radius: toolsBackground.radius
|
||||||
|
|
||||||
|
transform: Matrix4x4 {
|
||||||
|
matrix: toolsBackground.deformMatrix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BlobRect {
|
||||||
|
id: toolsBackground
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
|
||||||
|
bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
|
||||||
|
topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
|
||||||
|
topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
|
||||||
|
deformScale: 0
|
||||||
|
group: blobGroup
|
||||||
|
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: toolBox
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
height: Math.min(implicitHeight, parent.height)
|
||||||
|
implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2
|
||||||
|
radius: Tokens.rounding.small
|
||||||
|
|
||||||
|
StateLayer {
|
||||||
|
onClicked: {
|
||||||
|
toolsBg.open = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: toolList
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
id: repeater
|
||||||
|
|
||||||
|
model: root.current.pendingToolCalls?.slice(0, 4) ?? []
|
||||||
|
|
||||||
|
delegate: RowLayout {
|
||||||
|
id: toolText
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
spacing: Tokens.spacing.medium
|
||||||
|
|
||||||
|
function toolTarget(seg: var): string {
|
||||||
|
var a = null;
|
||||||
|
try {
|
||||||
|
a = JSON.parse(seg.arguments);
|
||||||
|
} catch (e) {}
|
||||||
|
if (a && typeof a === "object") {
|
||||||
|
const v = a.url ?? a.path;
|
||||||
|
if (v !== undefined && v !== null)
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
return String(seg.arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
color: Colors.palette.m3onPrimaryContainer
|
||||||
|
text: toolText.modelData.name === "webfetch" ? "web" : "text_snippet"
|
||||||
|
fill: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
color: Colors.palette.m3onPrimaryContainer
|
||||||
|
text: qsTr("%1").arg(toolText.modelData.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
color: Colors.palette.m3onPrimaryContainer
|
||||||
|
text: "arrow_right_alt"
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
color: Colors.palette.m3onPrimaryContainer
|
||||||
|
text: qsTr("%1").arg(toolText.toolTarget(toolText.modelData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
color: Colors.palette.m3onPrimaryContainer
|
||||||
|
text: qsTr("And %1 more").arg(root.current.pendingToolCalls.slice(4).length)
|
||||||
|
visible: repeater.count < root.current.pendingToolCalls.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: toolsContent
|
||||||
|
|
||||||
|
active: opacity > 0
|
||||||
|
anchors.fill: parent
|
||||||
|
asynchronous: false
|
||||||
|
opacity: 0
|
||||||
|
|
||||||
|
sourceComponent: MouseArea {
|
||||||
|
onWheel: event => event.accepted = true
|
||||||
|
preventStealing: true
|
||||||
|
implicitHeight: innerLayout.contentHeight + innerLayout.anchors.margins * 2
|
||||||
|
|
||||||
|
VerticalFadeListView {
|
||||||
|
id: innerLayout
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Tokens.padding.small
|
||||||
|
cacheBuffer: height * 10
|
||||||
|
fadeAmount: 0.1
|
||||||
|
fadeEnabled: interactive
|
||||||
|
spacing: 0
|
||||||
|
clip: true
|
||||||
|
interactive: contentHeight > toolsBg.maxHeight
|
||||||
|
|
||||||
|
model: root.current.pendingToolCalls ?? []
|
||||||
|
|
||||||
|
delegate: Item {
|
||||||
|
id: toolCall
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
property bool expanded: false
|
||||||
|
|
||||||
|
implicitWidth: CustomListView.view.width
|
||||||
|
implicitHeight: collapsedRow.implicitHeight + collapsedRow.anchors.margins + (expanded ? expandedTool.implicitHeight + expandedTool.anchors.margins + expandedTool.anchors.topMargin : collapsedRow.anchors.margins)
|
||||||
|
|
||||||
|
function toolTarget(seg: var): string {
|
||||||
|
var a = null;
|
||||||
|
try {
|
||||||
|
a = JSON.parse(seg.arguments);
|
||||||
|
} catch (e) {}
|
||||||
|
if (a && typeof a === "object") {
|
||||||
|
const v = a.url ?? a.path;
|
||||||
|
if (v !== undefined && v !== null)
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
return String(seg.arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on implicitHeight {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: collapsedRow
|
||||||
|
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
id: toolExpandBtn
|
||||||
|
|
||||||
|
font: Tokens.font.icon.medium
|
||||||
|
icon: "keyboard_arrow_down"
|
||||||
|
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
|
||||||
|
rotation: toolCall.expanded ? 180 : 0
|
||||||
|
type: IconButton.Text
|
||||||
|
|
||||||
|
Behavior on rotation {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: toolCall.expanded = !toolCall.expanded
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: preview
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
text: toolCall.modelData.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomClippingRect {
|
||||||
|
id: expandedTool
|
||||||
|
|
||||||
|
anchors.top: collapsedRow.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
anchors.topMargin: Tokens.spacing.medium
|
||||||
|
|
||||||
|
implicitHeight: fullToolCall.implicitHeight + fullToolCall.anchors.margins * 2
|
||||||
|
radius: Tokens.rounding.medium
|
||||||
|
color: Colors.palette.m3surfaceContainerLow
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: fullToolCall
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
|
||||||
|
text: toolCall.toolTarget(toolCall.modelData)
|
||||||
|
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ButtonRow {
|
||||||
|
id: confirmationRow
|
||||||
|
Layout.alignment: Qt.AlignRight
|
||||||
|
spacing: Tokens.spacing.medium
|
||||||
|
|
||||||
|
IconTextButton {
|
||||||
|
icon: "check_circle"
|
||||||
|
text: qsTr("Allow")
|
||||||
|
horizontalPadding: Tokens.padding.large
|
||||||
|
verticalPadding: Tokens.padding.small
|
||||||
|
isRound: true
|
||||||
|
shapeMorph: true
|
||||||
|
onClicked: root.current.approveTools()
|
||||||
|
}
|
||||||
|
|
||||||
|
IconTextButton {
|
||||||
|
icon: "cancel"
|
||||||
|
text: qsTr("Deny")
|
||||||
|
isRound: true
|
||||||
|
shapeMorph: true
|
||||||
|
inactiveColor: Colors.palette.m3error
|
||||||
|
inactiveOnColor: Colors.palette.m3onError
|
||||||
|
horizontalPadding: Tokens.padding.large
|
||||||
|
verticalPadding: Tokens.padding.small
|
||||||
|
onClicked: root.current.denyTools()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: Tokens.font.body.small
|
||||||
|
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||||
|
|
||||||
|
onLinkActivated: link => {
|
||||||
|
Qt.openUrlExternally(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
cursorShape: root.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor
|
||||||
|
preventStealing: false
|
||||||
|
hoverEnabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
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.top = container.top;
|
||||||
|
incubator.object.anchors.bottom = container.bottom;
|
||||||
|
incubator.object.anchors.margins = Tokens.padding.medium;
|
||||||
|
incubator.object.anchors.horizontalCenter = container.horizontalCenter;
|
||||||
|
incubator.object.requestClose.connect(root.requestClose);
|
||||||
|
incubator.object.implicitWidth = Qt.binding(() => Math.max(Math.min(container.width - Tokens.padding.medium * 2, 800), Config.sidebar.sizes.width));
|
||||||
|
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.icon.extraLarge.pointSize * 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Effects
|
||||||
|
import QtQuick.Shapes
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import ZShell.Config
|
||||||
|
import ZShell.Llm
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property string language
|
||||||
|
required property string code
|
||||||
|
property bool copied: false
|
||||||
|
property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh
|
||||||
|
property color codeHeaderColor: Colors.palette.m3outline
|
||||||
|
property string html: ""
|
||||||
|
property bool colored: false
|
||||||
|
property string requestedCode: ""
|
||||||
|
property string requestedLanguage: ""
|
||||||
|
property int highlightToken: 0
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
requestedCode = root.code;
|
||||||
|
requestedLanguage = root.language;
|
||||||
|
const token = ++root.highlightToken;
|
||||||
|
CodeHighlighter.highlight(root.code, root.language, root, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHighlightSpans(token, spans) {
|
||||||
|
if (token !== root.highlightToken)
|
||||||
|
return;
|
||||||
|
if (root.code !== root.requestedCode || root.language !== root.requestedLanguage)
|
||||||
|
return;
|
||||||
|
html = spans.length ? root.highlightedHtml(root.code, spans) : "";
|
||||||
|
colored = root.visible && html.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleColor(kind) {
|
||||||
|
var s = CodeColors.active;
|
||||||
|
switch (kind) {
|
||||||
|
case "comment":
|
||||||
|
return s.comment;
|
||||||
|
case "string":
|
||||||
|
return s.string;
|
||||||
|
case "string.key":
|
||||||
|
return s.stringKey;
|
||||||
|
case "number":
|
||||||
|
case "constant":
|
||||||
|
return s.number;
|
||||||
|
case "keyword":
|
||||||
|
return s.keyword;
|
||||||
|
case "type":
|
||||||
|
return s.type;
|
||||||
|
case "function":
|
||||||
|
return s.functions;
|
||||||
|
case "method":
|
||||||
|
return s.method ?? s.functions;
|
||||||
|
case "macro":
|
||||||
|
return s.macro;
|
||||||
|
case "preproc":
|
||||||
|
return s.preproc ?? s.macro;
|
||||||
|
case "operator":
|
||||||
|
return s.operator ?? s.normal;
|
||||||
|
case "property":
|
||||||
|
return s.property ?? s.normal;
|
||||||
|
case "label":
|
||||||
|
return s.label;
|
||||||
|
case "attribute":
|
||||||
|
return s.attribute ?? s.label;
|
||||||
|
default:
|
||||||
|
return s.normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text): string {
|
||||||
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the whitespace rules of the rich text rendering below (leading
|
||||||
|
// whitespace kept, mid-line runs collapsed, tabs as single spaces) so the
|
||||||
|
// plain and colored layouts are identical.
|
||||||
|
function plainForDisplay(code): string {
|
||||||
|
return code.split("\n").map(line => {
|
||||||
|
const i = line.search(/\S/);
|
||||||
|
if (i < 0)
|
||||||
|
return "";
|
||||||
|
const lead = line.slice(0, i).replace(/\t/g, " ");
|
||||||
|
return lead + line.slice(i).replace(/\s+/g, " ").replace(/\s+$/, "");
|
||||||
|
}).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightedHtml(code, spans): string {
|
||||||
|
let out;
|
||||||
|
if (!spans.length) {
|
||||||
|
out = escapeHtml(code);
|
||||||
|
} else {
|
||||||
|
out = "";
|
||||||
|
let pos = 0;
|
||||||
|
for (let i = 0; i < spans.length; i++) {
|
||||||
|
const span = spans[i];
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
return out.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, " ")).replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
implicitWidth: headerRow.implicitWidth + headerRow.anchors.leftMargin + headerRow.anchors.rightMargin
|
||||||
|
implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeRect.implicitHeight + codeRect.anchors.margins + codeRect.anchors.topMargin
|
||||||
|
color: root.codeBackgroundColor
|
||||||
|
radius: Tokens.rounding.medium
|
||||||
|
|
||||||
|
onLanguageChanged: {
|
||||||
|
html = "";
|
||||||
|
colored = false;
|
||||||
|
if (root.visible)
|
||||||
|
refreshTimer.restart();
|
||||||
|
}
|
||||||
|
onCodeChanged: {
|
||||||
|
colored = false;
|
||||||
|
if (root.visible)
|
||||||
|
refreshTimer.restart();
|
||||||
|
}
|
||||||
|
onVisibleChanged: if (root.visible)
|
||||||
|
refreshTimer.restart()
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: refreshTimer
|
||||||
|
|
||||||
|
interval: 200
|
||||||
|
repeat: false
|
||||||
|
onTriggered: refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: headerRow
|
||||||
|
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.topMargin: Tokens.padding.small
|
||||||
|
anchors.leftMargin: Tokens.padding.small
|
||||||
|
anchors.rightMargin: Tokens.padding.small
|
||||||
|
spacing: Tokens.spacing.small
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: iconItem
|
||||||
|
|
||||||
|
readonly property string iconPath: CodeIcons.path(root.language)
|
||||||
|
|
||||||
|
Layout.fillHeight: true
|
||||||
|
implicitWidth: height
|
||||||
|
|
||||||
|
Shape {
|
||||||
|
id: shape
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: iconItem.iconPath !== ""
|
||||||
|
preferredRendererType: Shape.CurveRenderer
|
||||||
|
scale: Math.min(langText.implicitHeight / height, langText.implicitHeight / width)
|
||||||
|
|
||||||
|
ShapePath {
|
||||||
|
strokeColor: "transparent"
|
||||||
|
fillColor: Colors.palette.m3tertiary
|
||||||
|
fillRule: ShapePath.WindingFill
|
||||||
|
|
||||||
|
PathSvg {
|
||||||
|
path: iconItem.iconPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Shape {
|
||||||
|
id: fallbackShape
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
anchors.verticalCenterOffset: -1
|
||||||
|
visible: iconItem.iconPath === ""
|
||||||
|
preferredRendererType: Shape.CurveRenderer
|
||||||
|
scale: Math.min(langText.implicitHeight / height, langText.implicitHeight / width)
|
||||||
|
|
||||||
|
ShapePath {
|
||||||
|
strokeColor: Colors.palette.m3tertiary
|
||||||
|
strokeWidth: 16
|
||||||
|
capStyle: ShapePath.RoundCap
|
||||||
|
joinStyle: ShapePath.RoundJoin
|
||||||
|
fillColor: "transparent"
|
||||||
|
|
||||||
|
PathSvg {
|
||||||
|
path: "M 40 64 L 112 128 L 40 192"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShapePath {
|
||||||
|
strokeColor: Colors.palette.m3tertiary
|
||||||
|
strokeWidth: 16
|
||||||
|
capStyle: ShapePath.RoundCap
|
||||||
|
fillColor: "transparent"
|
||||||
|
|
||||||
|
PathSvg {
|
||||||
|
path: "M 120 192 L 216 192"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: langText
|
||||||
|
|
||||||
|
Layout.alignment: Qt.AlignVCenter
|
||||||
|
color: root.codeHeaderColor
|
||||||
|
font: Tokens.font.label.small
|
||||||
|
text: CodeIcons.name(root.language)
|
||||||
|
visible: root.language.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
icon: root.copied ? "check" : "content_copy"
|
||||||
|
inactiveColor: "transparent"
|
||||||
|
inactiveOnColor: root.codeHeaderColor
|
||||||
|
label.animate: true
|
||||||
|
type: IconButton.Text
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
Quickshell.clipboardText = root.code;
|
||||||
|
root.copied = true;
|
||||||
|
copyResetTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: copyResetTimer
|
||||||
|
|
||||||
|
interval: 1500
|
||||||
|
|
||||||
|
onTriggered: root.copied = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomClippingRect {
|
||||||
|
id: codeRect
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.top: headerRow.bottom
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.topMargin: Tokens.padding.small
|
||||||
|
radius: root.radius - anchors.margins
|
||||||
|
anchors.margins: Tokens.padding.extraSmall
|
||||||
|
implicitWidth: codeText.implicitWidth + codeFlick.anchors.margins * 2
|
||||||
|
implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2
|
||||||
|
color: CodeColors.active.bg
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
id: codeFlick
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Tokens.padding.small
|
||||||
|
|
||||||
|
CustomScrollBar.horizontal: CustomScrollBar {
|
||||||
|
flickable: codeFlick
|
||||||
|
parent: codeFlick.parent
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
}
|
||||||
|
|
||||||
|
TextAreaBase.flickable: TextAreaBase {
|
||||||
|
id: codeText
|
||||||
|
|
||||||
|
color: CodeColors.active.normal
|
||||||
|
layer.enabled: true
|
||||||
|
clip: false
|
||||||
|
font: Tokens.font.mono.small
|
||||||
|
textFormat: root.colored ? Text.RichText : Text.PlainText
|
||||||
|
text: root.colored ? root.html : root.plainForDisplay(root.code)
|
||||||
|
readOnly: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import ZShell.Config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property JsonObject schemes: JsonObject {
|
||||||
|
readonly property Scheme oneDark: Scheme {
|
||||||
|
comment: "#5c6370"
|
||||||
|
string: "#98c379"
|
||||||
|
stringKey: "#e06c75"
|
||||||
|
number: "#d19a66"
|
||||||
|
keyword: "#c678dd"
|
||||||
|
type: "#e5c07b"
|
||||||
|
functions: "#61afef"
|
||||||
|
method: "#61afef"
|
||||||
|
macro: "#98c379"
|
||||||
|
preproc: "#abb2bf"
|
||||||
|
operator: "#abb2bf"
|
||||||
|
property: "#abb2bf"
|
||||||
|
label: "#e06c75"
|
||||||
|
attribute: "#d19a66"
|
||||||
|
bg: "#282c34"
|
||||||
|
normal: "#abb2bf"
|
||||||
|
}
|
||||||
|
readonly property Scheme nord: Scheme {
|
||||||
|
comment: "#4c566a"
|
||||||
|
string: "#a3be8c"
|
||||||
|
stringKey: "#ebcb8b"
|
||||||
|
number: "#b48ead"
|
||||||
|
keyword: "#81a1c1"
|
||||||
|
type: "#8fbcbb"
|
||||||
|
functions: "#88c0d0"
|
||||||
|
method: "#88c0d0"
|
||||||
|
macro: "#5e81ac"
|
||||||
|
preproc: "#5e81ac"
|
||||||
|
operator: "#81a1c1"
|
||||||
|
property: "#d8dee9"
|
||||||
|
label: "#d08770"
|
||||||
|
attribute: "#d8dee9"
|
||||||
|
bg: "#2e3440"
|
||||||
|
normal: "#d8dee9"
|
||||||
|
}
|
||||||
|
readonly property Scheme dracula: Scheme {
|
||||||
|
comment: "#6272a4"
|
||||||
|
string: "#f1fa8c"
|
||||||
|
stringKey: "#f8f8f2"
|
||||||
|
number: "#ffb86c"
|
||||||
|
keyword: "#ff79c6"
|
||||||
|
type: "#8be9fd"
|
||||||
|
functions: "#50fa7b"
|
||||||
|
method: "#50fa7b"
|
||||||
|
macro: "#ff79c6"
|
||||||
|
preproc: "#ff79c6"
|
||||||
|
operator: "#f8f8f2"
|
||||||
|
property: "#f8f8f2"
|
||||||
|
label: "#6272a4"
|
||||||
|
attribute: "#8be9fd"
|
||||||
|
bg: "#282a36"
|
||||||
|
normal: "#f8f8f2"
|
||||||
|
}
|
||||||
|
readonly property Scheme githubDark: Scheme {
|
||||||
|
comment: "#8b949e"
|
||||||
|
string: "#a5d6ff"
|
||||||
|
stringKey: "#79c0ff"
|
||||||
|
number: "#79c0ff"
|
||||||
|
keyword: "#ff7b72"
|
||||||
|
type: "#ffa657"
|
||||||
|
functions: "#d2a8ff"
|
||||||
|
method: "#d2a8ff"
|
||||||
|
macro: "#ff7b72"
|
||||||
|
preproc: "#79c0ff"
|
||||||
|
operator: "#ff7b72"
|
||||||
|
property: "#79c0ff"
|
||||||
|
label: "#7ee787"
|
||||||
|
attribute: "#7ee787"
|
||||||
|
bg: "#0d1117"
|
||||||
|
normal: "#c9d1d9"
|
||||||
|
}
|
||||||
|
readonly property Scheme solarizedDark: Scheme {
|
||||||
|
comment: "#586e75"
|
||||||
|
string: "#2aa198"
|
||||||
|
stringKey: "#268bd2"
|
||||||
|
number: "#2aa198"
|
||||||
|
keyword: "#859900"
|
||||||
|
type: "#b58900"
|
||||||
|
functions: "#268bd2"
|
||||||
|
method: "#268bd2"
|
||||||
|
macro: "#cb4b16"
|
||||||
|
preproc: "#cb4b16"
|
||||||
|
operator: "#859900"
|
||||||
|
property: "#268bd2"
|
||||||
|
label: "#6c71c4"
|
||||||
|
attribute: "#657b83"
|
||||||
|
bg: "#002b36"
|
||||||
|
normal: "#839496"
|
||||||
|
}
|
||||||
|
readonly property Scheme monokai: Scheme {
|
||||||
|
comment: "#75715e"
|
||||||
|
string: "#e6db74"
|
||||||
|
stringKey: "#f8f8f2"
|
||||||
|
number: "#ae81ff"
|
||||||
|
keyword: "#f92672"
|
||||||
|
type: "#a6e22e"
|
||||||
|
functions: "#a6e22e"
|
||||||
|
method: "#a6e22e"
|
||||||
|
macro: "#a6e22e"
|
||||||
|
preproc: "#f92672"
|
||||||
|
operator: "#f92672"
|
||||||
|
property: "#fda5ff"
|
||||||
|
label: "#f92672"
|
||||||
|
attribute: "#a6e22e"
|
||||||
|
bg: "#272822"
|
||||||
|
normal: "#f8f8f2"
|
||||||
|
}
|
||||||
|
readonly property Scheme gruvboxDark: Scheme {
|
||||||
|
comment: "#928374"
|
||||||
|
string: "#b8bb26"
|
||||||
|
stringKey: "#ebdbb2"
|
||||||
|
number: "#d3869b"
|
||||||
|
keyword: "#fb4934"
|
||||||
|
type: "#fabd2f"
|
||||||
|
functions: "#b8bb26"
|
||||||
|
method: "#b8bb26"
|
||||||
|
macro: "#8ec07c"
|
||||||
|
preproc: "#8ec07c"
|
||||||
|
operator: "#ebdbb2"
|
||||||
|
property: "#83a598"
|
||||||
|
label: "#fb4934"
|
||||||
|
attribute: "#8ec07c"
|
||||||
|
bg: "#1d2021"
|
||||||
|
normal: "#ebdbb2"
|
||||||
|
}
|
||||||
|
readonly property Scheme catppuccinMocha: Scheme {
|
||||||
|
comment: "#9399b2"
|
||||||
|
string: "#a6e3a1"
|
||||||
|
stringKey: "#b4befe"
|
||||||
|
number: "#fab387"
|
||||||
|
keyword: "#cba6f7"
|
||||||
|
type: "#f9e2af"
|
||||||
|
functions: "#89b4fa"
|
||||||
|
method: "#89b4fa"
|
||||||
|
macro: "#cba6f7"
|
||||||
|
preproc: "#f5c2e7"
|
||||||
|
operator: "#89dceb"
|
||||||
|
property: "#b4befe"
|
||||||
|
label: "#74c7ec"
|
||||||
|
attribute: "#f9e2af"
|
||||||
|
bg: "#1e1e2e"
|
||||||
|
normal: "#cdd6f4"
|
||||||
|
}
|
||||||
|
readonly property Scheme tokyoNight: Scheme {
|
||||||
|
comment: "#565f89"
|
||||||
|
string: "#9ece6a"
|
||||||
|
stringKey: "#73daca"
|
||||||
|
number: "#ff9e64"
|
||||||
|
keyword: "#9d7cd8"
|
||||||
|
type: "#2ac3de"
|
||||||
|
functions: "#7aa2f7"
|
||||||
|
method: "#7aa2f7"
|
||||||
|
macro: "#7dcfff"
|
||||||
|
preproc: "#7dcfff"
|
||||||
|
operator: "#89ddff"
|
||||||
|
property: "#73daca"
|
||||||
|
label: "#7aa2f7"
|
||||||
|
attribute: "#73daca"
|
||||||
|
bg: "#1a1b26"
|
||||||
|
normal: "#c0caf5"
|
||||||
|
}
|
||||||
|
readonly property Scheme ayuDark: Scheme {
|
||||||
|
comment: "#5a6673"
|
||||||
|
string: "#aad94c"
|
||||||
|
stringKey: "#aad94c"
|
||||||
|
number: "#d2a6ff"
|
||||||
|
keyword: "#ff8f40"
|
||||||
|
type: "#59c2ff"
|
||||||
|
functions: "#ffb454"
|
||||||
|
method: "#ffb454"
|
||||||
|
macro: "#59c2ff"
|
||||||
|
preproc: "#ff8f40"
|
||||||
|
operator: "#f29668"
|
||||||
|
property: "#f07178"
|
||||||
|
label: "#59c2ff"
|
||||||
|
attribute: "#ffb454"
|
||||||
|
bg: "#0a0e14"
|
||||||
|
normal: "#bfbdb6"
|
||||||
|
}
|
||||||
|
readonly property Scheme palenight: Scheme {
|
||||||
|
comment: "#697098"
|
||||||
|
string: "#c3e88d"
|
||||||
|
stringKey: "#82b1ff"
|
||||||
|
number: "#f78c6c"
|
||||||
|
keyword: "#ff5370"
|
||||||
|
type: "#ffcb6b"
|
||||||
|
functions: "#82b1ff"
|
||||||
|
method: "#82b1ff"
|
||||||
|
macro: "#c792ea"
|
||||||
|
preproc: "#ffcb6b"
|
||||||
|
operator: "#89ddff"
|
||||||
|
property: "#c3e88d"
|
||||||
|
label: "#c792ea"
|
||||||
|
attribute: "#ffcb6b"
|
||||||
|
bg: "#292d3e"
|
||||||
|
normal: "#bfc7d5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readonly property var active: schemes[Config.llm.appearance.scheme]
|
||||||
|
|
||||||
|
component Scheme: JsonObject {
|
||||||
|
property color comment: "#697098"
|
||||||
|
property color string: "#c3e88d"
|
||||||
|
property color stringKey: "#82b1ff"
|
||||||
|
property color number: "#f78c6c"
|
||||||
|
property color keyword: "#ff5370"
|
||||||
|
property color type: "#ffcb6b"
|
||||||
|
property color functions: "#82b1ff"
|
||||||
|
property color method: "#82b1ff"
|
||||||
|
property color macro: "#c792ea"
|
||||||
|
property color preproc: "#ffcb6b"
|
||||||
|
property color operator: "#89ddff"
|
||||||
|
property color property: "#c3e88d"
|
||||||
|
property color label: "#c792ea"
|
||||||
|
property color attribute: "#ffcb6b"
|
||||||
|
property color bg: "#292d3e"
|
||||||
|
property color normal: "#bfc7d5"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,166 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ZShell.Llm
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Modules.Notifications.Sidebar.Chat
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property bool isUser
|
||||||
|
required property string text
|
||||||
|
required property var blocks
|
||||||
|
required property bool running
|
||||||
|
required property bool hovered
|
||||||
|
required property Repeater repeater
|
||||||
|
required property int index
|
||||||
|
required property ChatMessage message
|
||||||
|
|
||||||
|
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
|
||||||
|
|
||||||
|
signal edit(text: string)
|
||||||
|
|
||||||
|
implicitHeight: bubble.implicitHeight + (actionsRow.shouldBeActive ? actionsRow.implicitHeight + actionsRow.anchors.topMargin : 0)
|
||||||
|
|
||||||
|
CustomClippingRect {
|
||||||
|
id: bubble
|
||||||
|
|
||||||
|
property real liveTargetWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
|
||||||
|
property real frozenWidth: 0.0
|
||||||
|
property bool layoutFrozen: false
|
||||||
|
property real layoutWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
|
||||||
|
|
||||||
|
radius: Tokens.rounding.medium
|
||||||
|
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
|
||||||
|
implicitWidth: layoutFrozen ? frozenWidth : layoutWidth
|
||||||
|
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : markdown.implicitHeight + markdown.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
TextEditBase {
|
||||||
|
id: msgText
|
||||||
|
|
||||||
|
property string cachedText: root.text
|
||||||
|
property bool cancelled: false
|
||||||
|
|
||||||
|
visible: root.isUser
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
animateCursor: false
|
||||||
|
color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
|
||||||
|
cursor.color: root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary
|
||||||
|
font: Tokens.font.body.small
|
||||||
|
readOnly: true
|
||||||
|
selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4)
|
||||||
|
text: root.isUser ? root.text : ""
|
||||||
|
textFormat: CustomText.MarkdownText
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
|
||||||
|
Keys.onPressed: event => {
|
||||||
|
if (event.key == Qt.Key_Return) {
|
||||||
|
if (!(event.modifiers & Qt.ShiftModifier)) {
|
||||||
|
readOnly = true;
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
} else if (event.key == Qt.Key_Escape) {
|
||||||
|
cancelled = true;
|
||||||
|
readOnly = true;
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onEditingFinished: {
|
||||||
|
readOnly = true;
|
||||||
|
}
|
||||||
|
onReadOnlyChanged: {
|
||||||
|
if (readOnly) {
|
||||||
|
animateCursor = false;
|
||||||
|
textFormat = CustomText.MarkdownText;
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
text = cachedText;
|
||||||
|
cancelled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.edit(text);
|
||||||
|
} else {
|
||||||
|
textFormat = CustomText.PlainText;
|
||||||
|
text = cachedText;
|
||||||
|
forceActiveFocus();
|
||||||
|
cursorPosition = text.length;
|
||||||
|
animateCursor = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MarkdownBlocks {
|
||||||
|
id: markdown
|
||||||
|
|
||||||
|
visible: !root.isUser
|
||||||
|
anchors.topMargin: Tokens.padding.medium
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
blocks: root.blocks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Actions {
|
||||||
|
id: actionsRow
|
||||||
|
|
||||||
|
readonly property bool shouldBeActive: !root.running && root.repeater.count === (root.index + 1)
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
|
||||||
|
anchors.top: bubble.bottom
|
||||||
|
opacity: shouldBeActive ? 1 : 0
|
||||||
|
visible: opacity > 0
|
||||||
|
anchors.topMargin: Tokens.spacing.medium
|
||||||
|
edit: msgText
|
||||||
|
hovered: root.hovered
|
||||||
|
isUser: root.isUser
|
||||||
|
running: root.running
|
||||||
|
message: root.message
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
spacing: Tokens.spacing.small
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillHeight: true
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
color: Colors.tPalette.m3outlineVariant
|
||||||
|
fontStyle: Tokens.font.icon.extraLarge
|
||||||
|
text: "chat"
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
color: Colors.tPalette.m3onSurfaceVariant
|
||||||
|
text: qsTr("Send a message to get started")
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.fillHeight: true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ZShell.Llm
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property ChatGeneration current
|
||||||
|
|
||||||
|
signal dismiss
|
||||||
|
|
||||||
|
implicitHeight: 0
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
root.implicitHeight = Qt.binding(() => errorInner.implicitHeight + errorInner.anchors.margins * 2);
|
||||||
|
enterAnim.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on implicitHeight {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ParallelAnimation {
|
||||||
|
id: enterAnim
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
target: container
|
||||||
|
property: "y"
|
||||||
|
from: container.implicitHeight
|
||||||
|
to: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
target: container
|
||||||
|
property: "opacity"
|
||||||
|
from: 0
|
||||||
|
to: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ParallelAnimation {
|
||||||
|
id: exitAnim
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
target: root
|
||||||
|
property: "implicitHeight"
|
||||||
|
from: root.implicitHeight
|
||||||
|
to: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
target: container
|
||||||
|
property: "y"
|
||||||
|
to: container.implicitHeight
|
||||||
|
from: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
target: container
|
||||||
|
property: "opacity"
|
||||||
|
to: 0
|
||||||
|
from: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
onFinished: root.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: container
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
|
||||||
|
radius: Tokens.rounding.medium
|
||||||
|
color: Colors.palette.m3errorContainer
|
||||||
|
implicitHeight: errorInner.implicitHeight + errorInner.anchors.margins * 2
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: errorInner
|
||||||
|
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
|
||||||
|
spacing: Tokens.spacing.small
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: header
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
font: Tokens.font.headline.small
|
||||||
|
elide: CustomText.ElideRight
|
||||||
|
color: Colors.palette.m3onErrorContainer
|
||||||
|
|
||||||
|
text: qsTr("An error occured")
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: body
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
wrapMode: CustomText.WordWrap
|
||||||
|
color: Colors.palette.m3onErrorContainer
|
||||||
|
|
||||||
|
text: root.current.errorMessage || LlmClient.serverError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
|
||||||
|
isRound: true
|
||||||
|
icon: "close"
|
||||||
|
type: IconButton.Text
|
||||||
|
font: Tokens.font.icon.medium
|
||||||
|
|
||||||
|
onClicked: exitAnim.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import ZShell.Config
|
||||||
|
import ZShell.Llm
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var blocks
|
||||||
|
|
||||||
|
spacing: Tokens.spacing.small
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
id: blockRep
|
||||||
|
|
||||||
|
model: ScriptModel {
|
||||||
|
values: root.blocks
|
||||||
|
objectProp: "id"
|
||||||
|
}
|
||||||
|
delegate: DelegateChooser {
|
||||||
|
role: "type"
|
||||||
|
|
||||||
|
DelegateChoice {
|
||||||
|
roleValue: MarkdownBlock.Type.Code
|
||||||
|
|
||||||
|
delegate: CodeBlockView {
|
||||||
|
required property MarkdownBlock modelData
|
||||||
|
|
||||||
|
language: modelData.language
|
||||||
|
code: modelData.code
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: Tokens.spacing.small
|
||||||
|
anchors.left: parent.left
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DelegateChoice {
|
||||||
|
roleValue: MarkdownBlock.Type.Math
|
||||||
|
|
||||||
|
delegate: MathBlockView {
|
||||||
|
required property MarkdownBlock modelData
|
||||||
|
|
||||||
|
latex: modelData.latex
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DelegateChoice {
|
||||||
|
roleValue: MarkdownBlock.Type.Heading
|
||||||
|
|
||||||
|
delegate: TextEditBase {
|
||||||
|
required property MarkdownBlock modelData
|
||||||
|
|
||||||
|
color: Colors.palette.m3onSurface
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
text: modelData.text
|
||||||
|
textFormat: Text.MarkdownText
|
||||||
|
readOnly: true
|
||||||
|
font: {
|
||||||
|
if (modelData.level <= 1)
|
||||||
|
return Tokens.font.title.builders.large.weight(Font.Bold).build();
|
||||||
|
if (modelData.level === 2)
|
||||||
|
return Tokens.font.title.builders.medium.weight(Font.Bold).build();
|
||||||
|
return Tokens.font.title.builders.small.weight(Font.Bold).build();
|
||||||
|
}
|
||||||
|
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DelegateChoice {
|
||||||
|
roleValue: MarkdownBlock.Type.Text
|
||||||
|
|
||||||
|
delegate: BubbleEdit {
|
||||||
|
required property MarkdownBlock modelData
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
text: modelData.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import QtQuick
|
||||||
|
import ZShell.Config
|
||||||
|
import ZShell.Llm
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property string latex
|
||||||
|
property color mathColor: Colors.palette.m3onSurface
|
||||||
|
property font font: Tokens.font.headline.medium
|
||||||
|
|
||||||
|
implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2
|
||||||
|
|
||||||
|
LlmMathText {
|
||||||
|
id: math
|
||||||
|
|
||||||
|
latex: root.latex
|
||||||
|
color: root.mathColor
|
||||||
|
fontPointSize: root.font.pointSize
|
||||||
|
devicePixelRatio: Screen.devicePixelRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
Image {
|
||||||
|
id: equationImage
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
source: math.imageId.length ? "image://llmmath/" + math.imageId : ""
|
||||||
|
visible: math.ok
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
smooth: true
|
||||||
|
width: Math.min(math.width, root.width)
|
||||||
|
height: math.width > 0 ? width * (math.height / math.width) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: fallbackText
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
color: root.mathColor
|
||||||
|
font: Tokens.font.mono.medium
|
||||||
|
text: root.latex
|
||||||
|
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||||
|
visible: !math.ok
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import Quickshell
|
||||||
|
import ZShell.Components
|
||||||
|
import ZShell.Config
|
||||||
|
import ZShell.Llm
|
||||||
|
import qs.Modules.Notifications.Sidebar.Chat
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property int index
|
||||||
|
required property Item rootParent
|
||||||
|
required property ChatMessage modelData
|
||||||
|
required property ChatListView chatList
|
||||||
|
|
||||||
|
readonly property bool isUser: modelData.role === ChatMessage.Role.User
|
||||||
|
|
||||||
|
readonly property var blocks: root.isUser ? [
|
||||||
|
{
|
||||||
|
kind: "content",
|
||||||
|
id: 0,
|
||||||
|
text: root.modelData.content,
|
||||||
|
markdown: [],
|
||||||
|
running: false
|
||||||
|
}
|
||||||
|
] : blockify(current.segments, current.error)
|
||||||
|
|
||||||
|
property ChatGeneration current: modelData.activeGeneration
|
||||||
|
property bool reasoningExpanded: false
|
||||||
|
property real savedOffset: NaN
|
||||||
|
property bool anchored: false
|
||||||
|
|
||||||
|
function handleReasoningToggle(expanded: bool, block: Item): void {
|
||||||
|
if (!root.rootParent)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
root.savedOffset = root.rootParent.contentY - root.y;
|
||||||
|
root.anchored = true;
|
||||||
|
chatList.anchorItem(block, ChatListView.TopEdge);
|
||||||
|
settleTimer.start();
|
||||||
|
} else {
|
||||||
|
settleTimer.stop();
|
||||||
|
root.anchored = false;
|
||||||
|
chatList.clearAnchor();
|
||||||
|
if (!isNaN(root.savedOffset)) {
|
||||||
|
chatList.requestRestore(root.y + root.savedOffset);
|
||||||
|
root.savedOffset = NaN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockify(segs, errored) {
|
||||||
|
const blocks = [];
|
||||||
|
for (let i = 0; i < segs.length; i++) {
|
||||||
|
const seg = segs[i];
|
||||||
|
if (seg.type === LlmSegment.Type.Content) {
|
||||||
|
blocks.push({
|
||||||
|
kind: "content",
|
||||||
|
id: i,
|
||||||
|
text: seg.text,
|
||||||
|
markdown: seg.markdown,
|
||||||
|
running: seg.running
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const last = blocks.length ? blocks[blocks.length - 1] : null;
|
||||||
|
if (last && last.kind === "process") {
|
||||||
|
last.segments.push(seg);
|
||||||
|
} else {
|
||||||
|
blocks.push({
|
||||||
|
kind: "process",
|
||||||
|
id: i,
|
||||||
|
segments: [seg]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errored)
|
||||||
|
blocks.push({
|
||||||
|
kind: "error",
|
||||||
|
id: segs.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverEnabled: true
|
||||||
|
preventStealing: false
|
||||||
|
implicitHeight: layout.implicitHeight + Tokens.padding.medium + (attachments.visible ? attachments.implicitHeight : 0)
|
||||||
|
onImplicitHeightChanged: if (root.anchored)
|
||||||
|
settleTimer.restart()
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: settleTimer
|
||||||
|
|
||||||
|
interval: Tokens.anim.durations.expressiveDefaultEffects
|
||||||
|
repeat: false
|
||||||
|
|
||||||
|
onTriggered: {
|
||||||
|
if (root.anchored) {
|
||||||
|
root.anchored = false;
|
||||||
|
chatList.clearAnchor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GridView {
|
||||||
|
id: attachments
|
||||||
|
|
||||||
|
readonly property int columns: ChatState.isWindow ? 5 : 3
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
|
||||||
|
flow: GridView.LeftToRight
|
||||||
|
verticalLayoutDirection: GridView.BottomToTop
|
||||||
|
|
||||||
|
cellWidth: width / columns
|
||||||
|
cellHeight: cellWidth
|
||||||
|
|
||||||
|
implicitHeight: Math.ceil(count / columns) * cellHeight
|
||||||
|
|
||||||
|
displaced: Transition {
|
||||||
|
Anim {
|
||||||
|
property: "x"
|
||||||
|
}
|
||||||
|
Anim {
|
||||||
|
property: "y"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model: ScriptModel {
|
||||||
|
objectProp: "path"
|
||||||
|
values: {
|
||||||
|
const list = [...root.modelData.attachments];
|
||||||
|
|
||||||
|
if (list.length > 0 && list.length < attachments.columns) {
|
||||||
|
while (list.length < attachments.columns)
|
||||||
|
list.unshift(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: Item {
|
||||||
|
id: thumb
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
function fileIcon(mime): string {
|
||||||
|
if (mime.startsWith("archive/") || mime === "application/zip")
|
||||||
|
return "folder_zip";
|
||||||
|
if (mime === "application/pdf")
|
||||||
|
return "picture_as_pdf";
|
||||||
|
if (mime.startsWith("audio/"))
|
||||||
|
return "audio_file";
|
||||||
|
if (mime.startsWith("video/"))
|
||||||
|
return "video_file";
|
||||||
|
if (mime.startsWith("text/"))
|
||||||
|
return "description";
|
||||||
|
return "insert_drive_file";
|
||||||
|
}
|
||||||
|
|
||||||
|
width: attachments.cellWidth
|
||||||
|
height: width
|
||||||
|
opacity: modelData ? 1 : 0
|
||||||
|
|
||||||
|
CustomClippingRect {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Tokens.spacing.extraSmall
|
||||||
|
|
||||||
|
radius: Tokens.rounding.medium
|
||||||
|
color: Colors.tPalette.m3surfaceContainer
|
||||||
|
|
||||||
|
FadeImage {
|
||||||
|
id: attImage
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
fillMode: FadeImage.PreserveAspectCrop
|
||||||
|
asynchronous: true
|
||||||
|
source: visible ? thumb.modelData?.path ?? "" : ""
|
||||||
|
visible: thumb.modelData?.isImage ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
fontStyle: Tokens.font.icon.extraLarge
|
||||||
|
text: thumb.fileIcon(thumb.modelData?.mimeType ?? "")
|
||||||
|
visible: !thumb.modelData?.isImage ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: attIndicatorLoader
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
opacity: 0
|
||||||
|
active: opacity > 0 && (thumb.modelData?.isImage ?? false)
|
||||||
|
|
||||||
|
sourceComponent: CustomRect {
|
||||||
|
implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
|
||||||
|
implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
|
||||||
|
|
||||||
|
color: Colors.palette.m3primaryContainer
|
||||||
|
radius: Tokens.rounding.full
|
||||||
|
|
||||||
|
LoadingIndicator {
|
||||||
|
id: attLoadingIndicator
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
containsIcon: true
|
||||||
|
implicitSize: thumb.implicitWidth * 0.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: attLoadDebounceTimer
|
||||||
|
|
||||||
|
interval: 100
|
||||||
|
onTriggered: {
|
||||||
|
if (attImage.status !== Image.Ready)
|
||||||
|
attIndicatorLoader.opacity = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: layout
|
||||||
|
|
||||||
|
anchors.top: attachments.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
|
||||||
|
spacing: Tokens.spacing.medium
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
id: segmentRep
|
||||||
|
|
||||||
|
model: ScriptModel {
|
||||||
|
values: root.blocks
|
||||||
|
objectProp: "id"
|
||||||
|
}
|
||||||
|
delegate: DelegateChooser {
|
||||||
|
role: "kind"
|
||||||
|
|
||||||
|
DelegateChoice {
|
||||||
|
roleValue: "error"
|
||||||
|
|
||||||
|
delegate: ErrorBubble {
|
||||||
|
width: root.width
|
||||||
|
current: root.current
|
||||||
|
|
||||||
|
onDismiss: root.current.dismissError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DelegateChoice {
|
||||||
|
roleValue: "process"
|
||||||
|
|
||||||
|
delegate: ProcessBlock {
|
||||||
|
width: root.width
|
||||||
|
blocks: root.blocks
|
||||||
|
current: root.current
|
||||||
|
rootParent: root.rootParent
|
||||||
|
|
||||||
|
onExpandedChanged: root.handleReasoningToggle(expanded, this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DelegateChoice {
|
||||||
|
roleValue: "content"
|
||||||
|
|
||||||
|
delegate: ContentBubble {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
isUser: root.isUser
|
||||||
|
text: modelData.text
|
||||||
|
blocks: modelData.markdown
|
||||||
|
running: modelData.running
|
||||||
|
width: root.width
|
||||||
|
repeater: segmentRep
|
||||||
|
message: root.modelData
|
||||||
|
hovered: root.containsMouse
|
||||||
|
|
||||||
|
onEdit: text => {
|
||||||
|
root.modelData.edit(text);
|
||||||
|
|
||||||
|
if (root.isUser)
|
||||||
|
root.modelData.generate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ZShell.Config
|
||||||
|
import ZShell.Components
|
||||||
|
import ZShell.Llm
|
||||||
|
import ZShell.Blobs
|
||||||
|
import qs.Modules.Notifications.Sidebar.Chat
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property Item rootParent
|
||||||
|
required property Item fabRoot
|
||||||
|
|
||||||
|
property bool open: false
|
||||||
|
|
||||||
|
property int maxHeight: root.rootParent.height - Tokens.padding.medium * 2
|
||||||
|
property int openHeight: Math.min(modelsContent.item?.implicitHeight, maxHeight)
|
||||||
|
property int openWidth: root.rootParent.width - Tokens.padding.medium * 2
|
||||||
|
property real openHeightProg: openHeight / maxHeight
|
||||||
|
property int collapsedY: 0
|
||||||
|
|
||||||
|
property int horizontalContentMargin
|
||||||
|
property string header: qsTr("Available models")
|
||||||
|
property string acceptLabel: qsTr("Accept")
|
||||||
|
|
||||||
|
z: open || dialogTransition.running ? 2 : 0
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
implicitHeight: fabRoot.implicitHeight
|
||||||
|
|
||||||
|
function prettyModelName(): string {
|
||||||
|
if (modelsContent.item?.modelsList.count < 1)
|
||||||
|
return qsTr("No models found");
|
||||||
|
|
||||||
|
const pathList = LlmClient.model.split("\/");
|
||||||
|
const name = pathList[pathList.length - 1];
|
||||||
|
|
||||||
|
if (!name)
|
||||||
|
return qsTr("No model selected");
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on implicitHeight {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reparentWrapper(): void {
|
||||||
|
const newParent = open ? root.rootParent : root;
|
||||||
|
const c = modelsWrapper.parent.mapToItem(newParent, modelsWrapper.x + modelsWrapper.width / 2, modelsWrapper.y + modelsWrapper.height / 2);
|
||||||
|
root.collapsedY = modelsWrapper.y;
|
||||||
|
modelsWrapper.parent = newParent;
|
||||||
|
modelsWrapper.x = c.x - modelsWrapper.width / 2;
|
||||||
|
modelsWrapper.y = c.y - modelsWrapper.height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlobGroup {
|
||||||
|
id: blobGroup
|
||||||
|
|
||||||
|
color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer
|
||||||
|
|
||||||
|
Behavior on color {
|
||||||
|
CAnim {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: backdrop
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: false
|
||||||
|
hoverEnabled: enabled
|
||||||
|
preventStealing: true
|
||||||
|
parent: root.open ? root.rootParent : root
|
||||||
|
|
||||||
|
onClicked: root.open = false
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: modelsWrapper
|
||||||
|
|
||||||
|
width: root.width
|
||||||
|
height: modelBox.implicitHeight
|
||||||
|
z: 1
|
||||||
|
|
||||||
|
states: State {
|
||||||
|
name: "open"
|
||||||
|
when: root.open
|
||||||
|
|
||||||
|
PropertyChanges {
|
||||||
|
backdrop.enabled: true
|
||||||
|
modelsBackground.radius: Tokens.rounding.largeIncreased
|
||||||
|
modelsContent.opacity: 1
|
||||||
|
modelsWrapper.height: root.openHeight
|
||||||
|
modelsWrapper.width: root.openWidth
|
||||||
|
modelsWrapper.x: (root.rootParent.width - root.openWidth) / 2
|
||||||
|
modelsWrapper.y: root.rootParent.height - root.openHeight - Tokens.spacing.medium - Tokens.padding.medium - root.fabRoot.implicitHeight
|
||||||
|
elevation.opacity: 1
|
||||||
|
modelBox.opacity: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transitions: Transition {
|
||||||
|
id: dialogTransition
|
||||||
|
|
||||||
|
SequentialAnimation {
|
||||||
|
ScriptAction {
|
||||||
|
script: root.reparentWrapper()
|
||||||
|
}
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
properties: "x,y"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PropertyAction {
|
||||||
|
property: "enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
properties: "opacity,radius"
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
properties: "width,height"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Elevation {
|
||||||
|
id: elevation
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
level: 4
|
||||||
|
opacity: 0
|
||||||
|
radius: modelsBackground.radius
|
||||||
|
|
||||||
|
transform: Matrix4x4 {
|
||||||
|
matrix: modelsBackground.deformMatrix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BlobRect {
|
||||||
|
id: modelsBackground
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: modelLayer.pressed ? Tokens.rounding.small : Tokens.rounding.extraLarge
|
||||||
|
deformScale: 0
|
||||||
|
group: blobGroup
|
||||||
|
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
|
||||||
|
|
||||||
|
Behavior on radius {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: modelBox
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
implicitHeight: root.fabRoot.implicitHeight
|
||||||
|
radius: modelsBackground.radius
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: label
|
||||||
|
text: root.prettyModelName()
|
||||||
|
color: Colors.palette.m3onSurfaceVariant
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Tokens.padding.large
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
color: Colors.palette.m3onSurfaceVariant
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Tokens.padding.large
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "unfold_more"
|
||||||
|
fontStyle: Tokens.font.icon.large
|
||||||
|
}
|
||||||
|
|
||||||
|
StateLayer {
|
||||||
|
id: modelLayer
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
ChatState.fabExpanded = false;
|
||||||
|
root.open = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: modelsContent
|
||||||
|
|
||||||
|
active: opacity > 0
|
||||||
|
anchors.fill: parent
|
||||||
|
asynchronous: false
|
||||||
|
opacity: 0
|
||||||
|
|
||||||
|
sourceComponent: MouseArea {
|
||||||
|
readonly property alias modelsList: modelsList
|
||||||
|
|
||||||
|
onWheel: event => event.accepted = true
|
||||||
|
preventStealing: true
|
||||||
|
clip: true
|
||||||
|
implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins * 2
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: innerLayout
|
||||||
|
anchors.bottomMargin: Tokens.padding.largeIncreased
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Tokens.padding.extraLarge
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
font: Tokens.font.title.builders.large.weight(Font.Normal).build()
|
||||||
|
text: root.header
|
||||||
|
}
|
||||||
|
|
||||||
|
VerticalFadeListView {
|
||||||
|
id: modelsList
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: root.horizontalContentMargin
|
||||||
|
Layout.topMargin: Tokens.spacing.medium
|
||||||
|
Layout.bottomMargin: Tokens.spacing.medium
|
||||||
|
Layout.rightMargin: root.horizontalContentMargin
|
||||||
|
Layout.preferredHeight: 400
|
||||||
|
Layout.maximumHeight: 400
|
||||||
|
model: LlmClient.availableModels
|
||||||
|
currentIndex: LlmClient.availableModels.indexOf(LlmClient.model)
|
||||||
|
visible: opacity > 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomScrollBar.vertical: CustomScrollBar {
|
||||||
|
flickable: modelsList
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: CustomRect {
|
||||||
|
id: model
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
readonly property bool selected: ListView.view.currentIndex === model.index
|
||||||
|
|
||||||
|
implicitWidth: ListView.view.width
|
||||||
|
implicitHeight: name.implicitHeight + Tokens.padding.small * 2
|
||||||
|
radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium
|
||||||
|
color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0)
|
||||||
|
|
||||||
|
Behavior on radius {
|
||||||
|
Anim {
|
||||||
|
type: Anim.SlowEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: layout
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: Tokens.padding.small
|
||||||
|
anchors.leftMargin: Tokens.padding.large
|
||||||
|
anchors.rightMargin: Tokens.padding.large
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: name
|
||||||
|
|
||||||
|
text: model.modelData
|
||||||
|
elide: CustomText.ElideRight
|
||||||
|
color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
id: check
|
||||||
|
|
||||||
|
color: Colors.palette.m3onTertiaryContainer
|
||||||
|
fontStyle: Tokens.font.icon.medium
|
||||||
|
opacity: model.selected ? 1 : 0
|
||||||
|
visible: opacity > 0
|
||||||
|
text: "check"
|
||||||
|
|
||||||
|
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.SlowEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StateLayer {
|
||||||
|
id: stateLayer
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
model.ListView.view.currentIndex = model.index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.alignment: Qt.AlignRight
|
||||||
|
spacing: Tokens.spacing.extraSmall
|
||||||
|
|
||||||
|
TextButton {
|
||||||
|
horizontalPadding: Tokens.padding.largeIncreased
|
||||||
|
isRound: true
|
||||||
|
text: qsTr("Cancel")
|
||||||
|
type: TextButton.Text
|
||||||
|
verticalPadding: Tokens.padding.small
|
||||||
|
stateLayer.preventStealing: true
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
root.open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton {
|
||||||
|
horizontalPadding: Tokens.padding.largeIncreased
|
||||||
|
isRound: true
|
||||||
|
text: root.acceptLabel
|
||||||
|
type: TextButton.Text
|
||||||
|
stateLayer.preventStealing: true
|
||||||
|
verticalPadding: Tokens.padding.small
|
||||||
|
|
||||||
|
onClicked: {
|
||||||
|
LlmClient.selectModel(modelsList.currentItem.modelData);
|
||||||
|
root.open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ZShell.Blobs
|
||||||
|
import ZShell.Config
|
||||||
|
import ZShell.Components
|
||||||
|
import ZShell.Llm
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
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 var 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.some(s => s.type === LlmSegment.Type.ToolCall && s.status === LlmToolCall.Status.Pending)
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCurrentChanged: expanded = false
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
implicitSize: collapsedText.implicitHeight
|
||||||
|
anchors.centerIn: parent
|
||||||
|
opacity: root.isActive ? 1 : 0
|
||||||
|
visible: opacity > 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton {
|
||||||
|
id: expandBtn
|
||||||
|
|
||||||
|
font: Tokens.font.icon.medium
|
||||||
|
icon: "keyboard_arrow_down"
|
||||||
|
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
|
||||||
|
anchors.centerIn: parent
|
||||||
|
opacity: root.isActive ? 0 : 1
|
||||||
|
enabled: opacity > 0
|
||||||
|
rotation: root.expanded ? 180 : 0
|
||||||
|
type: IconButton.Text
|
||||||
|
|
||||||
|
Behavior on rotation {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClicked: root.expanded = !root.expanded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: collapsedText
|
||||||
|
|
||||||
|
color: Colors.palette.m3outline
|
||||||
|
font: Tokens.font.label.small
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.leftMargin: Tokens.spacing.medium
|
||||||
|
text: {
|
||||||
|
if (root.isActive) {
|
||||||
|
if (root.pendingApproval)
|
||||||
|
return qsTr("Waiting for approval...");
|
||||||
|
|
||||||
|
if (root.lastSegment.type === LlmSegment.Type.ToolCall)
|
||||||
|
return qsTr("Using %1...").arg(root.lastSegment.name);
|
||||||
|
return qsTr("Thinking...");
|
||||||
|
}
|
||||||
|
return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApprovalPrompt {
|
||||||
|
id: approvalPrompt
|
||||||
|
|
||||||
|
anchors.top: layout.bottom
|
||||||
|
anchors.topMargin: Tokens.spacing.small
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
visible: root.pendingApproval
|
||||||
|
|
||||||
|
rootParent: root.rootParent
|
||||||
|
current: root.current
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomClippingRect {
|
||||||
|
id: expandedRect
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: layout.bottom
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.topMargin: Tokens.spacing.small
|
||||||
|
color: Colors.palette.m3surfaceContainerLow
|
||||||
|
implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2
|
||||||
|
opacity: root.expanded ? 1 : 0
|
||||||
|
radius: Tokens.rounding.medium
|
||||||
|
visible: opacity > 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: expandedContent
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Tokens.padding.medium
|
||||||
|
model: root.segments
|
||||||
|
spacing: Tokens.spacing.medium
|
||||||
|
|
||||||
|
delegate: Item {
|
||||||
|
id: segment
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
implicitWidth: ListView.view.width
|
||||||
|
implicitHeight: header.implicitHeight + content.implicitHeight + content.anchors.topMargin
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: header
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
spacing: Tokens.spacing.small
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
text: segment.modelData.type === LlmSegment.Type.Reasoning ? "cognition" : "build"
|
||||||
|
fill: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: segment.modelData.type === LlmSegment.Type.Reasoning ? qsTr("Thought for %1s").arg((segment.modelData.elapsedMs / 1000).toFixed(1)) : qsTr("Used %1 for %2s").arg(segment.modelData.name).arg((segment.modelData.elapsedMs / 1000).toFixed(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: content
|
||||||
|
|
||||||
|
function toolTarget(seg: var): string {
|
||||||
|
var a = null;
|
||||||
|
try {
|
||||||
|
a = JSON.parse(seg.arguments);
|
||||||
|
} catch (e) {}
|
||||||
|
if (a && typeof a === "object") {
|
||||||
|
const v = a.url ?? a.path;
|
||||||
|
if (v !== undefined && v !== null)
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
return String(seg.arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyTool(seg: var): string {
|
||||||
|
if (seg.name === "webfetch") {
|
||||||
|
switch (seg.status) {
|
||||||
|
case LlmToolCall.Status.Running:
|
||||||
|
return qsTr("Fetching");
|
||||||
|
case LlmToolCall.Status.Pending:
|
||||||
|
return qsTr("Pending fetch");
|
||||||
|
case LlmToolCall.Status.Success:
|
||||||
|
return qsTr("Fetched");
|
||||||
|
case LlmToolCall.Status.Error:
|
||||||
|
return qsTr("Failed to fetch");
|
||||||
|
default:
|
||||||
|
return qsTr("Fetched website");
|
||||||
|
}
|
||||||
|
} else if (seg.name === "readfile") {
|
||||||
|
switch (seg.status) {
|
||||||
|
case LlmToolCall.Status.Running:
|
||||||
|
return qsTr("Reading");
|
||||||
|
case LlmToolCall.Status.Pending:
|
||||||
|
return qsTr("Pending read");
|
||||||
|
case LlmToolCall.Status.Success:
|
||||||
|
return qsTr("Read");
|
||||||
|
case LlmToolCall.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
|
||||||
|
anchors.topMargin: Tokens.spacing.small
|
||||||
|
anchors.right: parent.right
|
||||||
|
color: Colors.palette.m3outline
|
||||||
|
font: Tokens.font.body.small
|
||||||
|
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
|
||||||
|
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("%1 %2").arg(content.prettyTool(segment.modelData)).arg(content.toolTarget(segment.modelData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import QtQuick
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Helpers
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
function create(parent: Item, props: var): void {
|
||||||
|
chatComp.createObject(parent ?? dummy, props);
|
||||||
|
ShellState.forActive().sidebar = false;
|
||||||
|
ChatState.isWindow = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
id: dummy
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: chatComp
|
||||||
|
|
||||||
|
FloatingWindow {
|
||||||
|
id: win
|
||||||
|
|
||||||
|
property var props
|
||||||
|
|
||||||
|
color: Colors.tPalette.m3surface
|
||||||
|
implicitHeight: chat.implicitHeight
|
||||||
|
implicitWidth: chat.implicitWidth
|
||||||
|
minimumSize.height: Config.sidebar.sizes.width
|
||||||
|
minimumSize.width: Config.sidebar.sizes.width
|
||||||
|
surfaceFormat.opaque: false
|
||||||
|
title: qsTr("ZShell - %1").arg(ChatState.title)
|
||||||
|
|
||||||
|
Component.onCompleted: ChatState.screen = screen
|
||||||
|
onVisibleChanged: {
|
||||||
|
if (!visible) {
|
||||||
|
destroy();
|
||||||
|
ChatState.isWindow = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SidebarView {
|
||||||
|
id: chat
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
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: 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.icon.extraLarge.pointSize * (96 / 72)) + Tokens.padding.large * 2
|
||||||
|
property alias conversationModel: sidebar.model
|
||||||
|
property bool chatOpen: ChatState.chatSession !== null
|
||||||
|
|
||||||
|
readonly property bool isWide: root.width >= root.breakpoint
|
||||||
|
|
||||||
|
readonly property bool showList: isWide || !chatOpen
|
||||||
|
readonly property bool showContent: !isWide && chatOpen
|
||||||
|
|
||||||
|
function openConversation(conv: ChatSession, index: int): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
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.top: parent.top
|
||||||
|
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 implicitWidth {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model: LlmClient.chats
|
||||||
|
|
||||||
|
onLoadChatRequest: (chat, index) => {
|
||||||
|
ChatState.currentIdx = index;
|
||||||
|
ChatState.chatSession = chat;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeleteChatRequest: chat => {
|
||||||
|
const wasCurrent = chat === ChatState.chatSession;
|
||||||
|
LlmClient.chats.remove(chat);
|
||||||
|
|
||||||
|
if (wasCurrent)
|
||||||
|
ChatState.openDraft();
|
||||||
|
}
|
||||||
|
onNewChatRequest: ChatState.openDraft()
|
||||||
|
|
||||||
|
onRequestExpand: {
|
||||||
|
ChatState.narrowSidebarExpanded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.right: parent.right
|
||||||
|
implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2
|
||||||
|
|
||||||
|
states: State {
|
||||||
|
name: "wide"
|
||||||
|
when: !root.isWide
|
||||||
|
|
||||||
|
PropertyChanges {
|
||||||
|
contentArea.leftMargin: root.iconSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transitions: Transition {
|
||||||
|
to: "wide"
|
||||||
|
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
property: "leftMargin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatHost {
|
||||||
|
id: convHost
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
onLoadingChanged: if (!loading)
|
||||||
|
root.chatOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
ChatState.fabExpanded = 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
|
||||||
|
|
||||||
|
ChatContent {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import qs.Components
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
import ZShell.Config
|
import ZShell.Config
|
||||||
|
import ZShell.Llm
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
import qs.Components
|
||||||
|
import qs.Modules.Notifications.Sidebar.Chat
|
||||||
import qs.Services
|
import qs.Services
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
@@ -9,20 +13,150 @@ Item {
|
|||||||
|
|
||||||
required property ScreenState screenState
|
required property ScreenState screenState
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
function onIsWindowChanged(): void {
|
||||||
|
if (ChatState.isWindow)
|
||||||
|
root.screenState.sidebarTab = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
target: ChatState
|
||||||
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: layout
|
id: layout
|
||||||
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: Tokens.spacing.small
|
spacing: Tokens.spacing.small
|
||||||
|
|
||||||
CustomRect {
|
Tabs {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: ChatState.isWindow || !Config.llm.enabled ? 0 : implicitHeight
|
||||||
|
screenState: root.screenState
|
||||||
|
nonAnimWidth: layout.width
|
||||||
|
visible: height > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
color: Colors.tPalette.m3surfaceContainerLow
|
clip: true
|
||||||
radius: Tokens.rounding.small
|
|
||||||
|
|
||||||
NotifDock {
|
Item {
|
||||||
screenState: root.screenState
|
id: pages
|
||||||
|
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.top: parent.top
|
||||||
|
implicitWidth: parent.width
|
||||||
|
opacity: root.screenState.sidebarTab === 0 ? 1 : 0
|
||||||
|
visible: opacity > 0
|
||||||
|
x: root.screenState.sidebarTab === 0 ? 0 : -root.width
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
Behavior on x {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: bg
|
||||||
|
anchors.fill: parent
|
||||||
|
color: Colors.tPalette.m3surfaceContainerLow
|
||||||
|
radius: Tokens.rounding.small
|
||||||
|
|
||||||
|
NotifDock {
|
||||||
|
screenState: root.screenState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: chatLoader
|
||||||
|
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.top: parent.top
|
||||||
|
active: Config.llm.enabled
|
||||||
|
width: parent.width
|
||||||
|
property bool tre: true
|
||||||
|
|
||||||
|
sourceComponent: Item {
|
||||||
|
id: chatPage
|
||||||
|
|
||||||
|
opacity: root.screenState.sidebarTab === 1 ? 1 : 0
|
||||||
|
visible: opacity > 0
|
||||||
|
x: root.screenState.sidebarTab === 0 ? root.width : 0
|
||||||
|
z: 1
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on x {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
anchors.fill: parent
|
||||||
|
color: Colors.tPalette.m3surfaceContainerLow
|
||||||
|
radius: Tokens.rounding.large
|
||||||
|
|
||||||
|
ChatPanel {
|
||||||
|
anchors.fill: parent
|
||||||
|
}
|
||||||
|
|
||||||
|
GeminiGlow {
|
||||||
|
innerGlow: 50
|
||||||
|
z: 5
|
||||||
|
opacity: LlmClient.busy ? 1.0 : 0.0
|
||||||
|
glowSize: 0
|
||||||
|
visible: opacity > 0 && !ChatState.isWindow
|
||||||
|
|
||||||
|
layer.enabled: true
|
||||||
|
layer.effect: OpacityMask {
|
||||||
|
maskSource: fadeMask
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
Anim {
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: fadeMask
|
||||||
|
|
||||||
|
readonly property real fadeStartPos: {
|
||||||
|
const h = Math.max(1, height);
|
||||||
|
const nh = (h - h / 3) / h;
|
||||||
|
return nh;
|
||||||
|
}
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
layer.enabled: true
|
||||||
|
visible: false
|
||||||
|
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Vertical
|
||||||
|
|
||||||
|
GradientStop {
|
||||||
|
color: Qt.rgba(0, 0, 0, 0)
|
||||||
|
position: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
GradientStop {
|
||||||
|
color: Qt.rgba(0, 0, 0, 0)
|
||||||
|
position: fadeMask.fadeStartPos
|
||||||
|
}
|
||||||
|
|
||||||
|
GradientStop {
|
||||||
|
color: Qt.rgba(0, 0, 0, 1.0)
|
||||||
|
position: 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Templates
|
||||||
|
import Quickshell
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Components
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property alias count: bar.count
|
||||||
|
required property ScreenState screenState
|
||||||
|
required property real nonAnimWidth
|
||||||
|
|
||||||
|
implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight
|
||||||
|
|
||||||
|
TabBar {
|
||||||
|
id: bar
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
background: null
|
||||||
|
currentIndex: root.screenState.sidebarTab
|
||||||
|
implicitHeight: contentHeight
|
||||||
|
|
||||||
|
contentItem: RowLayout {
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: bar.contentModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCurrentIndexChanged: root.state.currentTab = currentIndex
|
||||||
|
|
||||||
|
Tab {
|
||||||
|
iconName: "notifications"
|
||||||
|
text: qsTr("Notifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
Tab {
|
||||||
|
iconName: "chat"
|
||||||
|
text: qsTr("Chat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: indicator
|
||||||
|
|
||||||
|
anchors.top: bar.bottom
|
||||||
|
clip: true
|
||||||
|
implicitHeight: 3
|
||||||
|
implicitWidth: bar.currentItem.implicitWidth
|
||||||
|
x: {
|
||||||
|
const tab = bar.currentItem;
|
||||||
|
const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count;
|
||||||
|
return width * tab.TabBar.index + (width - tab.implicitWidth) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on implicitWidth {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
Behavior on x {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
color: Colors.palette.m3primary
|
||||||
|
implicitHeight: parent.implicitHeight * 2
|
||||||
|
radius: Tokens.rounding.full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomRect {
|
||||||
|
id: separator
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: indicator.bottom
|
||||||
|
color: Colors.palette.m3outlineVariant
|
||||||
|
implicitHeight: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
component Tab: TabButton {
|
||||||
|
id: tab
|
||||||
|
|
||||||
|
readonly property bool current: TabBar.tabBar.currentItem === this
|
||||||
|
required property string iconName
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredWidth: 1
|
||||||
|
background: null
|
||||||
|
implicitHeight: implicitContentHeight
|
||||||
|
implicitWidth: implicitContentWidth
|
||||||
|
|
||||||
|
contentItem: Item {
|
||||||
|
implicitHeight: icon.height + label.height
|
||||||
|
implicitWidth: Math.max(icon.width, label.width)
|
||||||
|
|
||||||
|
StateLayer {
|
||||||
|
color: tab.current ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface
|
||||||
|
radius: Tokens.rounding.small
|
||||||
|
|
||||||
|
onClicked: root.screenState.sidebarTab = tab.TabBar.index
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
id: icon
|
||||||
|
|
||||||
|
anchors.bottom: label.top
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant
|
||||||
|
fill: tab.current ? 1 : 0
|
||||||
|
fontStyle: Tokens.font.icon.large
|
||||||
|
text: tab.iconName
|
||||||
|
|
||||||
|
Behavior on fill {
|
||||||
|
Anim {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomText {
|
||||||
|
id: label
|
||||||
|
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant
|
||||||
|
text: tab.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,8 +140,7 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Behavior on implicitHeight {
|
Behavior on implicitHeight {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
model: FileSystemModel {
|
model: FileSystemModel {
|
||||||
nameFilters: ["recording_*.mp4"]
|
nameFilters: ["recording_*.mp4"]
|
||||||
@@ -167,8 +166,7 @@ ColumnLayout {
|
|||||||
opacity: list.count === 0 ? 1 : 0
|
opacity: list.count === 0 ? 1 : 0
|
||||||
|
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
sourceComponent: ColumnLayout {
|
sourceComponent: ColumnLayout {
|
||||||
spacing: Tokens.spacing.small
|
spacing: Tokens.spacing.small
|
||||||
@@ -183,16 +181,13 @@ ColumnLayout {
|
|||||||
text: "scan_delete"
|
text: "scan_delete"
|
||||||
|
|
||||||
Behavior on Layout.preferredHeight {
|
Behavior on Layout.preferredHeight {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Behavior on scale {
|
Behavior on scale {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,16 +203,13 @@ ColumnLayout {
|
|||||||
text: "scan_delete"
|
text: "scan_delete"
|
||||||
|
|
||||||
Behavior on Layout.preferredWidth {
|
Behavior on Layout.preferredWidth {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Behavior on scale {
|
Behavior on scale {
|
||||||
Anim {
|
Anim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Quickshell
|
import Quickshell
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
import ZShell.Config
|
||||||
import qs.Components
|
import qs.Components
|
||||||
import qs.Modules.Notifications.Sidebar.Utils.Cards
|
import qs.Modules.Notifications.Sidebar.Utils.Cards
|
||||||
import ZShell.Config
|
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -19,8 +19,7 @@ Item {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: 8
|
spacing: 8
|
||||||
|
|
||||||
IdleInhibit {
|
IdleInhibit {}
|
||||||
}
|
|
||||||
|
|
||||||
Record {
|
Record {
|
||||||
screenState: root.screenState
|
screenState: root.screenState
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import QtQuick
|
|||||||
import Quickshell
|
import Quickshell
|
||||||
import ZShell.Config
|
import ZShell.Config
|
||||||
import qs.Components
|
import qs.Components
|
||||||
import qs.Modules.Bar.Popouts as BarPopouts
|
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -15,7 +14,7 @@ Item {
|
|||||||
required property Item sidebar
|
required property Item sidebar
|
||||||
|
|
||||||
anchors.bottomMargin: (-implicitHeight - 5) * offsetScale
|
anchors.bottomMargin: (-implicitHeight - 5) * offsetScale
|
||||||
implicitHeight: content.implicitHeight + 8 * 2
|
implicitHeight: content.implicitHeight + Tokens.padding.small * 2
|
||||||
implicitWidth: sidebar.width * (1 - sidebar.offsetScale)
|
implicitWidth: sidebar.width * (1 - sidebar.offsetScale)
|
||||||
opacity: 1 - offsetScale
|
opacity: 1 - offsetScale
|
||||||
visible: offsetScale < 1
|
visible: offsetScale < 1
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
pragma ComponentBehavior: Bound
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
import qs.Components
|
import qs.Components
|
||||||
|
import qs.Components.Toast
|
||||||
|
import ZShell
|
||||||
import ZShell.Config
|
import ZShell.Config
|
||||||
|
import ZShell.Llm
|
||||||
|
import Quickshell
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
@@ -25,6 +29,17 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: LlmClient
|
||||||
|
|
||||||
|
function onGenerationFailed(session: ChatSession, message: string): void {
|
||||||
|
if (root.shouldBeActive && root.chatActive)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Toaster.toast(qsTr("Chat"), message, "error_outline", Toast.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loader {
|
Loader {
|
||||||
id: content
|
id: content
|
||||||
|
|
||||||
|
|||||||
@@ -10,24 +10,25 @@ import qs.Services
|
|||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property bool acceptAllowed: true
|
|
||||||
required property string acceptLabel
|
required property string acceptLabel
|
||||||
required property Component content
|
required property Component content
|
||||||
property bool first: false
|
|
||||||
required property string header
|
required property string header
|
||||||
|
required property string label
|
||||||
|
required property Item rootParent
|
||||||
|
required property string settingAnchor
|
||||||
|
|
||||||
|
property bool acceptAllowed: true
|
||||||
|
property bool first: false
|
||||||
property int horizontalContentMargin
|
property int horizontalContentMargin
|
||||||
property string icon
|
property string icon
|
||||||
property alias iconLabel: openButton.iconLabel
|
property alias iconLabel: openButton.iconLabel
|
||||||
required property string label
|
property string subtext: ""
|
||||||
property bool last: false
|
property bool last: false
|
||||||
property bool open
|
property bool open
|
||||||
property real openHeight: Math.min(rootParent.height * 0.8, 600)
|
property real openHeight: Math.min(rootParent.height * 0.8, 600)
|
||||||
property real openWidth: Math.min(rootParent.width * 0.8, 400)
|
property real openWidth: Math.min(rootParent.width * 0.8, 400)
|
||||||
required property Item rootParent
|
|
||||||
property alias rowButton: openButton
|
property alias rowButton: openButton
|
||||||
property bool separateContent
|
property bool separateContent
|
||||||
required property string settingAnchor
|
|
||||||
property string subtext: ""
|
|
||||||
|
|
||||||
signal accepted
|
signal accepted
|
||||||
signal cancelled
|
signal cancelled
|
||||||
@@ -50,8 +51,7 @@ Item {
|
|||||||
color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer
|
color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
CAnim {
|
CAnim {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import qs.Services
|
|||||||
DialogRowButton {
|
DialogRowButton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property var initialSelect
|
|
||||||
required property var model
|
required property var model
|
||||||
|
|
||||||
|
property var initialSelect
|
||||||
property var selectedItem
|
property var selectedItem
|
||||||
|
|
||||||
function keyFor(item: var): string {
|
function keyFor(item: var): string {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ ColumnLayout {
|
|||||||
function findAnchor(item: Item, anchor: string): Item {
|
function findAnchor(item: Item, anchor: string): Item {
|
||||||
if (!item)
|
if (!item)
|
||||||
return null;
|
return null;
|
||||||
if (item.settingAnchor !== undefined && item.settingAnchor === anchor)
|
if (item.settingAnchor !== undefined && item.settingAnchor === anchor) // qmllint disable missing-property
|
||||||
return item;
|
return item;
|
||||||
const kids = item.children;
|
const kids = item.children;
|
||||||
for (let i = 0; i < kids.length; i++) {
|
for (let i = 0; i < kids.length; i++) {
|
||||||
@@ -43,8 +43,8 @@ ColumnLayout {
|
|||||||
|
|
||||||
function highlightAnchor(anchor: string): void {
|
function highlightAnchor(anchor: string): void {
|
||||||
const row = findAnchor(contentChild, anchor);
|
const row = findAnchor(contentChild, anchor);
|
||||||
if (row && row.flashHighlight !== undefined)
|
if (row && row.flashHighlight !== undefined) // qmllint disable missing-property
|
||||||
row.flashHighlight();
|
row.flashHighlight(); // qmllint disable missing-property
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToAnchor(anchor: string): bool {
|
function scrollToAnchor(anchor: string): bool {
|
||||||
@@ -61,8 +61,8 @@ ColumnLayout {
|
|||||||
root.animateScroll = true;
|
root.animateScroll = true;
|
||||||
flickable.contentY = target;
|
flickable.contentY = target;
|
||||||
Qt.callLater(() => root.animateScroll = false);
|
Qt.callLater(() => root.animateScroll = false);
|
||||||
if (row.flashHighlight !== undefined)
|
if (row.flashHighlight !== undefined) // qmllint disable missing-property
|
||||||
row.flashHighlight();
|
row.flashHighlight(); // qmllint disable missing-property
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,11 +155,20 @@ ColumnLayout {
|
|||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.topMargin: -topMargin
|
Layout.topMargin: -topMargin
|
||||||
|
|
||||||
bottomMargin: Tokens.padding.extraLarge
|
bottomMargin: Tokens.padding.extraLarge
|
||||||
|
topMargin: Tokens.padding.large
|
||||||
|
fadeAmount: 0.1
|
||||||
|
|
||||||
contentHeight: root.contentChild?.implicitHeight ?? 0
|
contentHeight: root.contentChild?.implicitHeight ?? 0
|
||||||
contentItem.children: [root.contentChild]
|
contentItem.children: [root.contentChild]
|
||||||
fadeAmount: 0.1
|
|
||||||
topMargin: Tokens.padding.large
|
rebound: Transition {
|
||||||
|
Anim {
|
||||||
|
properties: "x,y"
|
||||||
|
type: Anim.DefaultEffects
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Behavior on contentY {
|
Behavior on contentY {
|
||||||
enabled: root.animateScroll
|
enabled: root.animateScroll
|
||||||
|
|||||||
@@ -81,14 +81,10 @@ VerticalFadeFlickable {
|
|||||||
topLeftRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
topLeftRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||||
topRightRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
topRightRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||||
|
|
||||||
RadiusBehavior on bottomLeftRadius {
|
RadiusBehavior on bottomLeftRadius {}
|
||||||
}
|
RadiusBehavior on bottomRightRadius {}
|
||||||
RadiusBehavior on bottomRightRadius {
|
RadiusBehavior on topLeftRadius {}
|
||||||
}
|
RadiusBehavior on topRightRadius {}
|
||||||
RadiusBehavior on topLeftRadius {
|
|
||||||
}
|
|
||||||
RadiusBehavior on topRightRadius {
|
|
||||||
}
|
|
||||||
|
|
||||||
StateLayer {
|
StateLayer {
|
||||||
id: stateLayer
|
id: stateLayer
|
||||||
@@ -264,14 +260,10 @@ VerticalFadeFlickable {
|
|||||||
topRightRadius: layer.pressed ? Tokens.rounding.largeIncreased : isFirst ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
topRightRadius: layer.pressed ? Tokens.rounding.largeIncreased : isFirst ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||||
width: cardList.width
|
width: cardList.width
|
||||||
|
|
||||||
RadiusBehavior on bottomLeftRadius {
|
RadiusBehavior on bottomLeftRadius {}
|
||||||
}
|
RadiusBehavior on bottomRightRadius {}
|
||||||
RadiusBehavior on bottomRightRadius {
|
RadiusBehavior on topLeftRadius {}
|
||||||
}
|
RadiusBehavior on topRightRadius {}
|
||||||
RadiusBehavior on topLeftRadius {
|
|
||||||
}
|
|
||||||
RadiusBehavior on topRightRadius {
|
|
||||||
}
|
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: resultLayout
|
id: resultLayout
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import qs.Modules.Settings.Pages.Audio
|
|||||||
import qs.Modules.Settings.Pages.Apps
|
import qs.Modules.Settings.Pages.Apps
|
||||||
import qs.Modules.Settings.Pages.Panels
|
import qs.Modules.Settings.Pages.Panels
|
||||||
import qs.Modules.Settings.Pages.Panels.Bar
|
import qs.Modules.Settings.Pages.Panels.Bar
|
||||||
|
import qs.Modules.Settings.Pages.Panels.Sidebar
|
||||||
import qs.Modules.Settings.Pages.Services
|
import qs.Modules.Settings.Pages.Services
|
||||||
import qs.Services
|
import qs.Services
|
||||||
|
|
||||||
@@ -23,13 +24,11 @@ QtObject {
|
|||||||
// Wallpaper & style
|
// Wallpaper & style
|
||||||
StackPage {
|
StackPage {
|
||||||
Component {
|
Component {
|
||||||
WallpaperPage {
|
WallpaperPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
WallpaperSelect {
|
WallpaperSelect {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
@@ -41,32 +40,27 @@ QtObject {
|
|||||||
// Screenshot
|
// Screenshot
|
||||||
StackPage {
|
StackPage {
|
||||||
Component {
|
Component {
|
||||||
ScreenshotPage {
|
ScreenshotPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Connectivity
|
// Connectivity
|
||||||
Component {
|
Component {
|
||||||
PlaceholderComp {
|
PlaceholderComp {}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Component {
|
Component {
|
||||||
PlaceholderComp {
|
PlaceholderComp {}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Component {
|
Component {
|
||||||
// Audio
|
// Audio
|
||||||
StackPage {
|
StackPage {
|
||||||
Component {
|
Component {
|
||||||
AudioPage {
|
AudioPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
AppVolumes {
|
AppVolumes {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -75,49 +69,45 @@ QtObject {
|
|||||||
Component {
|
Component {
|
||||||
StackPage {
|
StackPage {
|
||||||
Component {
|
Component {
|
||||||
PanelsPage {
|
PanelsPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
BarPanel {
|
BarPanel {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
DashboardPanel {
|
DashboardPanel {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
ResourcesPanel {
|
ResourcesPanel {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
LauncherPanel {
|
LauncherPanel {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
SidebarPanel {
|
SidebarPanel {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bar sub pages
|
// Bar sub pages
|
||||||
Component {
|
Component {
|
||||||
BarTray {
|
BarTray {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
BarStatusIcons {
|
BarStatusIcons {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
BarClock {
|
BarClock {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar sub pages
|
||||||
|
Component {
|
||||||
|
SidebarLlm {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -126,18 +116,15 @@ QtObject {
|
|||||||
Component {
|
Component {
|
||||||
StackPage {
|
StackPage {
|
||||||
Component {
|
Component {
|
||||||
AppsPage {
|
AppsPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
AllApps {
|
AllApps {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
AppInfo {
|
AppInfo {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -146,13 +133,11 @@ QtObject {
|
|||||||
Component {
|
Component {
|
||||||
StackPage {
|
StackPage {
|
||||||
Component {
|
Component {
|
||||||
ServicesPage {
|
ServicesPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
NotificationsPage {
|
NotificationsPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -161,15 +146,13 @@ QtObject {
|
|||||||
Component {
|
Component {
|
||||||
StackPage {
|
StackPage {
|
||||||
Component {
|
Component {
|
||||||
AboutPage {
|
AboutPage {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
readonly property Component placeholderComp: Component {
|
readonly property Component placeholderComp: Component {
|
||||||
PlaceholderComp {
|
PlaceholderComp {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
component PlaceholderComp: Item {
|
component PlaceholderComp: Item {
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ PageBase {
|
|||||||
enabled: Object.keys(model).length > 0
|
enabled: Object.keys(model).length > 0
|
||||||
header: qsTr("Add new entry")
|
header: qsTr("Add new entry")
|
||||||
icon: "add"
|
icon: "add"
|
||||||
label: qsTr("Add entry")
|
|
||||||
last: true
|
last: true
|
||||||
|
label: qsTr("Add entry")
|
||||||
model: {
|
model: {
|
||||||
const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id));
|
const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id));
|
||||||
return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({
|
return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import ZShell.Config
|
||||||
|
import qs.Components
|
||||||
|
import qs.Modules.Settings.Common
|
||||||
|
|
||||||
|
PageBase {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property var schemes: [
|
||||||
|
{
|
||||||
|
id: "oneDark",
|
||||||
|
label: qsTr("One Dark")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nord",
|
||||||
|
label: qsTr("Nord")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dracula",
|
||||||
|
label: qsTr("Dracula")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "githubDark",
|
||||||
|
label: qsTr("Github Dark")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "solarizedDark",
|
||||||
|
label: qsTr("Solarized Dark")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "monokai",
|
||||||
|
label: qsTr("Monokai")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gruvboxDark",
|
||||||
|
label: qsTr("Gruvbox Dark")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "catppuccinMocha",
|
||||||
|
label: qsTr("Catppuccin Mocha")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tokyoNight",
|
||||||
|
label: qsTr("Tokyo Night")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ayuDark",
|
||||||
|
label: qsTr("Ayu Dark")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "palenight",
|
||||||
|
label: qsTr("Pale Night")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
isSubPage: true
|
||||||
|
title: qsTr("Sidebar")
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.top: parent.top
|
||||||
|
spacing: Tokens.spacing.extraSmall / 2
|
||||||
|
width: root.cappedWidth
|
||||||
|
|
||||||
|
SectionHeader {
|
||||||
|
first: true
|
||||||
|
text: qsTr("General")
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleRow {
|
||||||
|
settingAnchor: "panels-sidebar-llm-enabled"
|
||||||
|
checked: Config.llm.enabled
|
||||||
|
text: qsTr("Enabled")
|
||||||
|
first: true
|
||||||
|
last: true
|
||||||
|
|
||||||
|
onToggled: Config.llm.enabled = checked
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionHeader {
|
||||||
|
text: qsTr("Appearance")
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogSelectButton {
|
||||||
|
id: selectScheme
|
||||||
|
|
||||||
|
acceptLabel: qsTr("Confirm")
|
||||||
|
settingAnchor: "panels-sidebar-llm-scheme"
|
||||||
|
enabled: Object.keys(model).length > 0
|
||||||
|
header: qsTr("Select scheme")
|
||||||
|
first: true
|
||||||
|
last: true
|
||||||
|
icon: "add"
|
||||||
|
label: qsTr("Color scheme")
|
||||||
|
subtext: qsTr("Select the color scheme used for code blocks")
|
||||||
|
model: root.schemes
|
||||||
|
rootParent: root.flickable
|
||||||
|
|
||||||
|
initialSelect: Config.llm.appearance.scheme
|
||||||
|
|
||||||
|
onAccepted: {
|
||||||
|
if (!selectedItem)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Config.llm.appearance.scheme = selectedItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ PageBase {
|
|||||||
}
|
}
|
||||||
settingAnchor: "panels-sidebar-quick-toggles-add-item"
|
settingAnchor: "panels-sidebar-quick-toggles-add-item"
|
||||||
label: qsTr("Add entry")
|
label: qsTr("Add entry")
|
||||||
|
last: true
|
||||||
model: {
|
model: {
|
||||||
const present = new Set(Config.utilities.quickToggles.values.map(item => item.id));
|
const present = new Set(Config.utilities.quickToggles.values.map(item => item.id));
|
||||||
return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({
|
return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({
|
||||||
@@ -112,5 +113,19 @@ PageBase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SectionHeader {
|
||||||
|
text: qsTr("AI chat")
|
||||||
|
}
|
||||||
|
|
||||||
|
NavRow {
|
||||||
|
text: qsTr("AI chat")
|
||||||
|
settingAnchor: "panels-sidebar-llm"
|
||||||
|
icon: "robot_2"
|
||||||
|
first: true
|
||||||
|
last: true
|
||||||
|
|
||||||
|
onClicked: root.sState.openSubPage(9)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,24 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
|
|
||||||
import "../../scripts/fzf.js" as Fzf
|
import "../../scripts/fzf.js" as Fzf
|
||||||
|
import "../../scripts/settings-indexer.js" as SettingsIndexer
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import ZShell
|
import ZShell
|
||||||
import ZShell.Config
|
import ZShell.Config
|
||||||
|
import qs.Paths
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property var fzfFinder: null
|
property var inverted: ({})
|
||||||
|
property var ranking: ({})
|
||||||
readonly property var highlightCache: ({
|
readonly property var highlightCache: ({
|
||||||
"search": "",
|
"search": "",
|
||||||
"pattern": null
|
"pattern": null
|
||||||
})
|
})
|
||||||
property var inverted: ({})
|
property var fzfFinder: null
|
||||||
property var ranking: ({})
|
readonly property string cachePath: Paths.cache + "/settings-index.json"
|
||||||
|
|
||||||
function highlight(text: string, search: string, colour: color): string {
|
|
||||||
const escaped = text.replace(/&/g, "&").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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function query(search: string): list<QtObject> {
|
function query(search: string): list<QtObject> {
|
||||||
const tokens = root.tokenize(search);
|
const tokens = root.tokenize(search);
|
||||||
@@ -70,7 +28,7 @@ Singleton {
|
|||||||
const scores = ({});
|
const scores = ({});
|
||||||
const hitCounts = ({});
|
const hitCounts = ({});
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
const matches = root.lookup(token);
|
const matches = root.lookup(token); // { id: weight }
|
||||||
for (const id in matches) {
|
for (const id in matches) {
|
||||||
scores[id] = (scores[id] ?? 0) + matches[id];
|
scores[id] = (scores[id] ?? 0) + matches[id];
|
||||||
hitCounts[id] = (hitCounts[id] ?? 0) + 1;
|
hitCounts[id] = (hitCounts[id] ?? 0) + 1;
|
||||||
@@ -103,13 +61,76 @@ Singleton {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lookup(token: string): var {
|
||||||
|
const result = ({});
|
||||||
|
const exact = root.inverted[token] !== undefined;
|
||||||
|
const keys = exact ? [token] : Object.keys(root.inverted).filter(k => k.startsWith(token));
|
||||||
|
for (const key of keys) {
|
||||||
|
const rank = root.ranking[key] ?? ({});
|
||||||
|
for (const id of root.inverted[key]) {
|
||||||
|
const w = rank[id] ?? 0.1;
|
||||||
|
if (result[id] === undefined || w > result[id])
|
||||||
|
result[id] = w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function tokenize(text: string): var {
|
function tokenize(text: string): var {
|
||||||
return text.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length > 0);
|
return text.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function highlight(text: string, search: string, colour: color): string {
|
||||||
|
const escaped = text.replace(/&/g, "&").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: {
|
Component.onCompleted: {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(ZUtils.settingsIndex());
|
const data = root.loadIndex();
|
||||||
entries.model = data.entries;
|
entries.model = data.entries;
|
||||||
root.inverted = data.inverted ?? {};
|
root.inverted = data.inverted ?? {};
|
||||||
root.ranking = data.ranking ?? {};
|
root.ranking = data.ranking ?? {};
|
||||||
@@ -122,6 +143,7 @@ Singleton {
|
|||||||
limit: 25
|
limit: 25
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.warn("SettingsSearcher: failed to build settings index:", e);
|
||||||
entries.model = [];
|
entries.model = [];
|
||||||
root.inverted = {};
|
root.inverted = {};
|
||||||
root.ranking = {};
|
root.ranking = {};
|
||||||
@@ -132,22 +154,25 @@ Singleton {
|
|||||||
Variants {
|
Variants {
|
||||||
id: entries
|
id: entries
|
||||||
|
|
||||||
SettingEntry {
|
SettingEntry {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
component SettingEntry: QtObject {
|
component SettingEntry: QtObject {
|
||||||
readonly property string anchor: modelData.anchor ?? ""
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property int pageIdx: modelData.pageIdx
|
||||||
|
readonly property var subPath: modelData.subPath
|
||||||
|
readonly property var targetSubPath: modelData.targetSubPath ?? []
|
||||||
readonly property var crumbIcons: modelData.crumbIcons
|
readonly property var crumbIcons: modelData.crumbIcons
|
||||||
readonly property var crumbLabels: modelData.crumbLabels
|
readonly property var crumbLabels: modelData.crumbLabels
|
||||||
readonly property bool isToggle: togglePath.length > 0
|
|
||||||
required property var modelData
|
|
||||||
readonly property int pageIdx: modelData.pageIdx
|
|
||||||
readonly property string section: modelData.section ?? ""
|
|
||||||
readonly property var subPath: modelData.subPath
|
|
||||||
readonly property string subtext: modelData.subtext ?? ""
|
|
||||||
readonly property string title: modelData.title
|
readonly property string title: modelData.title
|
||||||
|
readonly property string section: modelData.section ?? ""
|
||||||
|
readonly property string subtext: modelData.subtext ?? ""
|
||||||
|
readonly property string anchor: modelData.anchor ?? ""
|
||||||
|
readonly property string icon: modelData.icon ?? ""
|
||||||
|
|
||||||
readonly property string togglePath: modelData.togglePath ?? ""
|
readonly property string togglePath: modelData.togglePath ?? ""
|
||||||
|
readonly property bool isToggle: togglePath.length > 0
|
||||||
readonly property bool toggleValue: {
|
readonly property bool toggleValue: {
|
||||||
if (!isToggle)
|
if (!isToggle)
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ class BlobRect : public BlobShape {
|
|||||||
|
|
||||||
[[nodiscard]] qreal damping() const { return m_damping; }
|
[[nodiscard]] qreal damping() const { return m_damping; }
|
||||||
|
|
||||||
void setDamping(qreal d) {
|
void setDamping(qreal value) {
|
||||||
if (!qFuzzyCompare(m_damping, d)) {
|
if (!qFuzzyCompare(m_damping, value)) {
|
||||||
m_damping = d;
|
m_damping = value;
|
||||||
emit dampingChanged();
|
emit dampingChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,6 @@ class BlobRect : public BlobShape {
|
|||||||
void checkAtRest(float speed);
|
void checkAtRest(float speed);
|
||||||
void beginRetire();
|
void beginRetire();
|
||||||
|
|
||||||
// Physics state
|
|
||||||
QPointF m_prevScenePos;
|
QPointF m_prevScenePos;
|
||||||
QElapsedTimer m_elapsed;
|
QElapsedTimer m_elapsed;
|
||||||
bool m_physicsActive = false;
|
bool m_physicsActive = false;
|
||||||
@@ -122,13 +121,10 @@ class BlobRect : public BlobShape {
|
|||||||
float m_retire01 = 0.0f;
|
float m_retire01 = 0.0f;
|
||||||
float m_retire11 = 1.0f;
|
float m_retire11 = 1.0f;
|
||||||
|
|
||||||
// Symmetric 2x2 deformation matrix components (3 independent: m00, m01,
|
|
||||||
// m11) Rest state is identity: m00=1, m01=0, m11=1
|
|
||||||
float m_dm00 = 1.0f;
|
float m_dm00 = 1.0f;
|
||||||
float m_dm01 = 0.0f;
|
float m_dm01 = 0.0f;
|
||||||
float m_dm11 = 1.0f;
|
float m_dm11 = 1.0f;
|
||||||
|
|
||||||
// Spring velocities for each component
|
|
||||||
float m_dmVel00 = 0.0f;
|
float m_dmVel00 = 0.0f;
|
||||||
float m_dmVel01 = 0.0f;
|
float m_dmVel01 = 0.0f;
|
||||||
float m_dmVel11 = 0.0f;
|
float m_dmVel11 = 0.0f;
|
||||||
|
|||||||
@@ -168,10 +168,6 @@ void BlobShape::updatePolish() {
|
|||||||
|
|
||||||
if (other->width() <= 0 || other->height() <= 0) continue;
|
if (other->width() <= 0 || other->height() <= 0) continue;
|
||||||
|
|
||||||
// Excluded shapes stay in the rect data. Exclusion only gates the smooth
|
|
||||||
// blend (via excludeMask below); ownership must still see every shape,
|
|
||||||
// otherwise two nodes can both believe they own a pixel and the pixel is
|
|
||||||
// blended twice.
|
|
||||||
const QPointF otherScene = other->mapToScene(QPointF(0, 0));
|
const QPointF otherScene = other->mapToScene(QPointF(0, 0));
|
||||||
|
|
||||||
bool include = false;
|
bool include = false;
|
||||||
@@ -196,7 +192,7 @@ void BlobShape::updatePolish() {
|
|||||||
|
|
||||||
const QMatrix4x4& dm = other->m_deformMatrix;
|
const QMatrix4x4& dm = other->m_deformMatrix;
|
||||||
const float a = dm(0, 0), b = dm(1, 0);
|
const float a = dm(0, 0), b = dm(1, 0);
|
||||||
const float c = dm(0, 1), d = dm(1, 1);
|
const float c = dm(0, 1), e = dm(1, 1);
|
||||||
|
|
||||||
BlobRectData r;
|
BlobRectData r;
|
||||||
r.cx = static_cast<float>(otherScene.x() + other->width() / 2.0);
|
r.cx = static_cast<float>(otherScene.x() + other->width() / 2.0);
|
||||||
@@ -207,19 +203,19 @@ void BlobShape::updatePolish() {
|
|||||||
r.offsetX = dm(0, 3);
|
r.offsetX = dm(0, 3);
|
||||||
r.offsetY = dm(1, 3);
|
r.offsetY = dm(1, 3);
|
||||||
|
|
||||||
const float det = a * d - c * b;
|
const float det = a * e - c * b;
|
||||||
const float invDet = std::abs(det) > 1e-6f ? 1.0f / det : 1.0f;
|
const float invDet = std::abs(det) > 1e-6f ? 1.0f / det : 1.0f;
|
||||||
r.invDeform[0] = d * invDet;
|
r.invDeform[0] = e * invDet;
|
||||||
r.invDeform[1] = -b * invDet;
|
r.invDeform[1] = -b * invDet;
|
||||||
r.invDeform[2] = -c * invDet;
|
r.invDeform[2] = -c * invDet;
|
||||||
r.invDeform[3] = a * invDet;
|
r.invDeform[3] = a * invDet;
|
||||||
|
|
||||||
const float halfTr = 0.5f * (a + d);
|
const float halfTr = 0.5f * (a + e);
|
||||||
const float halfDiff = 0.5f * (a - d);
|
const float halfDiff = 0.5f * (a - e);
|
||||||
r.minEig = halfTr - std::sqrt(halfDiff * halfDiff + c * c);
|
r.minEig = halfTr - std::sqrt(halfDiff * halfDiff + c * c);
|
||||||
|
|
||||||
r.screenHalfX = std::abs(a) * r.hw + std::abs(c) * r.hh;
|
r.screenHalfX = std::abs(a) * r.hw + std::abs(c) * r.hh;
|
||||||
r.screenHalfY = std::abs(b) * r.hw + std::abs(d) * r.hh;
|
r.screenHalfY = std::abs(b) * r.hw + std::abs(e) * r.hh;
|
||||||
|
|
||||||
m_cachedRects.append(r);
|
m_cachedRects.append(r);
|
||||||
rectShapes.append(other);
|
rectShapes.append(other);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
qml_module(ZShell
|
qml_module(ZShell
|
||||||
URI ZShell
|
URI ZShell
|
||||||
|
INCLUDE_PREFIX core
|
||||||
SOURCES
|
SOURCES
|
||||||
writefile.hpp writefile.cpp
|
writefile.hpp writefile.cpp
|
||||||
appdb.hpp appdb.cpp
|
appdb.hpp appdb.cpp
|
||||||
@@ -18,7 +19,10 @@ qml_module(ZShell
|
|||||||
zshell-util
|
zshell-util
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_definitions(ZShell PRIVATE ZSHELL_VERSION="${VERSION}")
|
target_compile_definitions(ZShell PRIVATE
|
||||||
|
ZSHELL_VERSION="${VERSION}"
|
||||||
|
GIT_REVISION="${GIT_REVISION}"
|
||||||
|
)
|
||||||
|
|
||||||
# Node macros expand to Q_OBJECT, automoc can't see it otherwise
|
# Node macros expand to Q_OBJECT, automoc can't see it otherwise
|
||||||
list(APPEND CMAKE_AUTOMOC_MACRO_NAMES
|
list(APPEND CMAKE_AUTOMOC_MACRO_NAMES
|
||||||
@@ -37,6 +41,7 @@ add_subdirectory(Services)
|
|||||||
add_subdirectory(Components)
|
add_subdirectory(Components)
|
||||||
add_subdirectory(Blobs)
|
add_subdirectory(Blobs)
|
||||||
add_subdirectory(Config)
|
add_subdirectory(Config)
|
||||||
|
add_subdirectory(Llm)
|
||||||
|
|
||||||
pkg_check_modules(HYPRLAND_PROBE hyprland)
|
pkg_check_modules(HYPRLAND_PROBE hyprland)
|
||||||
if(HYPRLAND_PROBE_FOUND)
|
if(HYPRLAND_PROBE_FOUND)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ qml_module(ZShell-components
|
|||||||
URI ZShell.Components
|
URI ZShell.Components
|
||||||
SOURCES
|
SOURCES
|
||||||
lazylistview.hpp lazylistview.cpp
|
lazylistview.hpp lazylistview.cpp
|
||||||
|
chatlistview.hpp chatlistview.cpp
|
||||||
wavyline.hpp wavyline.cpp
|
wavyline.hpp wavyline.cpp
|
||||||
wavylinerenderer.hpp wavylinerenderer.cpp
|
wavylinerenderer.hpp wavylinerenderer.cpp
|
||||||
buttonrow.hpp buttonrow.cpp
|
buttonrow.hpp buttonrow.cpp
|
||||||
@@ -9,6 +10,7 @@ qml_module(ZShell-components
|
|||||||
sparklinecontent.hpp sparklinecontent.cpp
|
sparklinecontent.hpp sparklinecontent.cpp
|
||||||
sparklineitem.hpp sparklineitem.cpp
|
sparklineitem.hpp sparklineitem.cpp
|
||||||
sparklinesceneitem.hpp sparklinesceneitem.cpp
|
sparklinesceneitem.hpp sparklinesceneitem.cpp
|
||||||
|
wheelinverter.hpp
|
||||||
LIBRARIES
|
LIBRARIES
|
||||||
Qt::Quick
|
Qt::Quick
|
||||||
Qt::QuickPrivate
|
Qt::QuickPrivate
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ void CarouselView::setModel(const QVariantList& m) {
|
|||||||
|
|
||||||
emit modelChanged();
|
emit modelChanged();
|
||||||
|
|
||||||
const int newCount = m_model.size();
|
const int newCount = static_cast<int>(m_model.size());
|
||||||
|
|
||||||
if (newCount == 0) {
|
if (newCount == 0) {
|
||||||
m_currentRealIndex = -1;
|
m_currentRealIndex = -1;
|
||||||
@@ -647,7 +647,7 @@ qreal CarouselView::sampleSize(qreal childLoc) const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int CarouselView::realIndexOf(int virtualIndex) const {
|
int CarouselView::realIndexOf(int virtualIndex) const {
|
||||||
const int count = m_model.size();
|
const int count = static_cast<int>(m_model.size());
|
||||||
|
|
||||||
if (count == 0) return -1;
|
if (count == 0) return -1;
|
||||||
|
|
||||||
@@ -668,7 +668,7 @@ qreal CarouselView::snapTargetX(qreal x) const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void CarouselView::wrapContentIfNeeded() {
|
void CarouselView::wrapContentIfNeeded() {
|
||||||
const int count = m_model.size();
|
const int count = static_cast<int>(m_model.size());
|
||||||
|
|
||||||
if (count == 0) return;
|
if (count == 0) return;
|
||||||
|
|
||||||
@@ -707,7 +707,7 @@ void CarouselView::wrapContentIfNeeded() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void CarouselView::updateCurrentIndexFromContentX(bool emitPreview) {
|
void CarouselView::updateCurrentIndexFromContentX(bool emitPreview) {
|
||||||
const int count = m_model.size();
|
const int count = static_cast<int>(m_model.size());
|
||||||
|
|
||||||
if (count == 0) return;
|
if (count == 0) return;
|
||||||
|
|
||||||
@@ -856,8 +856,8 @@ void CarouselView::positionSlot(const Slot& slot) const {
|
|||||||
return rightIndex == n ? m_arrangement.outerSpacing : m_itemSpacing;
|
return rightIndex == n ? m_arrangement.outerSpacing : m_itemSpacing;
|
||||||
};
|
};
|
||||||
|
|
||||||
const auto leftGap = [this, n](int leftIndex) {
|
const auto leftGap = [this, n](int gapIndex) {
|
||||||
return leftIndex == -n ? m_arrangement.outerSpacing : m_itemSpacing;
|
return gapIndex == -n ? m_arrangement.outerSpacing : m_itemSpacing;
|
||||||
};
|
};
|
||||||
|
|
||||||
qreal center = 0;
|
qreal center = 0;
|
||||||
@@ -974,7 +974,7 @@ void CarouselView::releaseAllItems() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void CarouselView::relayout() {
|
void CarouselView::relayout() {
|
||||||
const int count = m_model.size();
|
const int count = static_cast<int>(m_model.size());
|
||||||
|
|
||||||
if (count == 0 || width() <= 0 || m_focalWidth <= 0) {
|
if (count == 0 || width() <= 0 || m_focalWidth <= 0) {
|
||||||
for (Slot& s : m_slots) {
|
for (Slot& s : m_slots) {
|
||||||
@@ -1038,7 +1038,7 @@ void CarouselView::relayout() {
|
|||||||
m_slots.append(Slot{});
|
m_slots.append(Slot{});
|
||||||
slotUsedThisPass.append(false);
|
slotUsedThisPass.append(false);
|
||||||
|
|
||||||
freeSlot = m_slots.size() - 1;
|
freeSlot = static_cast<int>(m_slots.size()) - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSlotContent(m_slots[freeSlot], v);
|
updateSlotContent(m_slots[freeSlot], v);
|
||||||
@@ -1092,7 +1092,7 @@ void CarouselView::snapToNearest() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void CarouselView::goToIndex(int realIndex) {
|
void CarouselView::goToIndex(int realIndex) {
|
||||||
const int count = m_model.size();
|
const int count = static_cast<int>(m_model.size());
|
||||||
|
|
||||||
if (count == 0) return;
|
if (count == 0) return;
|
||||||
|
|
||||||
@@ -1126,7 +1126,7 @@ void CarouselView::decrementCurrentIndex() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void CarouselView::centerInstantlyOnReal(int realIndex) {
|
void CarouselView::centerInstantlyOnReal(int realIndex) {
|
||||||
const int count = m_model.size();
|
const int count = static_cast<int>(m_model.size());
|
||||||
|
|
||||||
if (count == 0) return;
|
if (count == 0) return;
|
||||||
|
|
||||||
@@ -1220,7 +1220,7 @@ qreal CarouselView::sampledVelocity() const {
|
|||||||
const VelocitySample& oldest = m_velocitySamples.first();
|
const VelocitySample& oldest = m_velocitySamples.first();
|
||||||
const VelocitySample& newest = m_velocitySamples.last();
|
const VelocitySample& newest = m_velocitySamples.last();
|
||||||
|
|
||||||
const qreal dt = newest.t - oldest.t;
|
const qreal dt = static_cast<qreal>(newest.t - oldest.t);
|
||||||
|
|
||||||
if (dt < 8) return 0;
|
if (dt < 8) return 0;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QAbstractItemModel>
|
||||||
|
#include <QElapsedTimer>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QList>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QQmlComponent>
|
||||||
|
#include <QQuickItem>
|
||||||
|
#include <QRect>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
namespace ZShell::components {
|
||||||
|
|
||||||
|
class ChatListViewAttached : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
Q_PROPERTY(
|
||||||
|
qreal preferredHeight READ preferredHeight WRITE setPreferredHeight
|
||||||
|
NOTIFY preferredHeightChanged)
|
||||||
|
Q_PROPERTY(
|
||||||
|
qreal visibleHeight READ visibleHeight WRITE setVisibleHeight NOTIFY
|
||||||
|
visibleHeightChanged)
|
||||||
|
Q_PROPERTY(bool ready READ ready NOTIFY readyChanged)
|
||||||
|
Q_PROPERTY(bool adding READ adding NOTIFY addingChanged)
|
||||||
|
Q_PROPERTY(bool removing READ removing NOTIFY removingChanged)
|
||||||
|
Q_PROPERTY(
|
||||||
|
bool trackViewport READ trackViewport WRITE setTrackViewport NOTIFY
|
||||||
|
trackViewportChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ChatListViewAttached(QObject* parent = nullptr);
|
||||||
|
|
||||||
|
[[nodiscard]] qreal preferredHeight() const;
|
||||||
|
void setPreferredHeight(qreal height);
|
||||||
|
|
||||||
|
[[nodiscard]] qreal visibleHeight() const;
|
||||||
|
void setVisibleHeight(qreal height);
|
||||||
|
|
||||||
|
[[nodiscard]] bool ready() const;
|
||||||
|
void setReady(bool ready);
|
||||||
|
|
||||||
|
[[nodiscard]] bool adding() const;
|
||||||
|
void setAdding(bool adding);
|
||||||
|
|
||||||
|
[[nodiscard]] bool removing() const;
|
||||||
|
void setRemoving(bool removing);
|
||||||
|
|
||||||
|
[[nodiscard]] bool trackViewport() const;
|
||||||
|
void setTrackViewport(bool track);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void preferredHeightChanged();
|
||||||
|
void visibleHeightChanged();
|
||||||
|
void readyChanged();
|
||||||
|
void addingChanged();
|
||||||
|
void removingChanged();
|
||||||
|
void trackViewportChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
qreal m_preferredHeight = -1;
|
||||||
|
qreal m_visibleHeight = -1;
|
||||||
|
bool m_ready = false;
|
||||||
|
bool m_adding = false;
|
||||||
|
bool m_removing = false;
|
||||||
|
bool m_trackViewport = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ChatListView : public QQuickItem {
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_ATTACHED(ChatListViewAttached)
|
||||||
|
|
||||||
|
public:
|
||||||
|
Q_PROPERTY(
|
||||||
|
QAbstractItemModel* model READ model WRITE setModel NOTIFY modelChanged)
|
||||||
|
Q_PROPERTY(
|
||||||
|
QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY
|
||||||
|
delegateChanged)
|
||||||
|
|
||||||
|
Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged)
|
||||||
|
Q_PROPERTY(
|
||||||
|
qreal contentHeight READ contentHeight NOTIFY contentHeightChanged)
|
||||||
|
Q_PROPERTY(qreal layoutHeight READ layoutHeight NOTIFY layoutHeightChanged)
|
||||||
|
Q_PROPERTY(
|
||||||
|
qreal contentY READ contentY WRITE setContentY NOTIFY contentYChanged)
|
||||||
|
|
||||||
|
Q_PROPERTY(
|
||||||
|
QRectF viewport READ viewport WRITE setViewport NOTIFY viewportChanged)
|
||||||
|
Q_PROPERTY(
|
||||||
|
bool useCustomViewport READ useCustomViewport WRITE setUseCustomViewport
|
||||||
|
NOTIFY useCustomViewportChanged)
|
||||||
|
Q_PROPERTY(
|
||||||
|
qreal cacheBuffer READ cacheBuffer WRITE setCacheBuffer NOTIFY
|
||||||
|
cacheBufferChanged)
|
||||||
|
|
||||||
|
Q_PROPERTY(
|
||||||
|
qreal estimatedHeight READ estimatedHeight WRITE setEstimatedHeight
|
||||||
|
NOTIFY estimatedHeightChanged)
|
||||||
|
|
||||||
|
Q_PROPERTY(
|
||||||
|
bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY
|
||||||
|
asynchronousChanged)
|
||||||
|
|
||||||
|
Q_PROPERTY(
|
||||||
|
int removeDuration READ removeDuration WRITE setRemoveDuration NOTIFY
|
||||||
|
removeDurationChanged)
|
||||||
|
Q_PROPERTY(
|
||||||
|
int readyDelay READ readyDelay WRITE setReadyDelay NOTIFY
|
||||||
|
readyDelayChanged)
|
||||||
|
Q_PROPERTY(
|
||||||
|
int yAnimDuration READ yAnimDuration WRITE setYAnimDuration NOTIFY
|
||||||
|
yAnimDurationChanged)
|
||||||
|
|
||||||
|
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||||
|
|
||||||
|
enum Edge {
|
||||||
|
TopEdge,
|
||||||
|
BottomEdge,
|
||||||
|
};
|
||||||
|
Q_ENUM(Edge)
|
||||||
|
|
||||||
|
Q_INVOKABLE void anchorItem(QQuickItem* item, Edge edge);
|
||||||
|
Q_INVOKABLE void clearAnchor();
|
||||||
|
Q_INVOKABLE void requestRestore(qreal targetY);
|
||||||
|
|
||||||
|
explicit ChatListView(QQuickItem* parent = nullptr);
|
||||||
|
~ChatListView() override;
|
||||||
|
|
||||||
|
static ChatListViewAttached* qmlAttachedProperties(QObject* object);
|
||||||
|
|
||||||
|
[[nodiscard]] QAbstractItemModel* model() const;
|
||||||
|
void setModel(QAbstractItemModel* model);
|
||||||
|
|
||||||
|
[[nodiscard]] QQmlComponent* delegate() const;
|
||||||
|
void setDelegate(QQmlComponent* delegate);
|
||||||
|
|
||||||
|
[[nodiscard]] qreal spacing() const;
|
||||||
|
void setSpacing(qreal spacing);
|
||||||
|
|
||||||
|
[[nodiscard]] qreal contentHeight() const;
|
||||||
|
[[nodiscard]] qreal layoutHeight() const;
|
||||||
|
|
||||||
|
[[nodiscard]] qreal contentY() const;
|
||||||
|
void setContentY(qreal contentY);
|
||||||
|
|
||||||
|
[[nodiscard]] QRectF viewport() const;
|
||||||
|
void setViewport(const QRectF& viewport);
|
||||||
|
|
||||||
|
[[nodiscard]] bool useCustomViewport() const;
|
||||||
|
void setUseCustomViewport(bool use);
|
||||||
|
|
||||||
|
[[nodiscard]] qreal cacheBuffer() const;
|
||||||
|
void setCacheBuffer(qreal buffer);
|
||||||
|
|
||||||
|
[[nodiscard]] qreal estimatedHeight() const;
|
||||||
|
void setEstimatedHeight(qreal height);
|
||||||
|
|
||||||
|
[[nodiscard]] bool asynchronous() const;
|
||||||
|
void setAsynchronous(bool async);
|
||||||
|
|
||||||
|
[[nodiscard]] int removeDuration() const;
|
||||||
|
void setRemoveDuration(int duration);
|
||||||
|
|
||||||
|
[[nodiscard]] int readyDelay() const;
|
||||||
|
void setReadyDelay(int delay);
|
||||||
|
|
||||||
|
[[nodiscard]] int yAnimDuration() const;
|
||||||
|
void setYAnimDuration(int duration);
|
||||||
|
|
||||||
|
[[nodiscard]] int count() const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void modelChanged();
|
||||||
|
void delegateChanged();
|
||||||
|
void spacingChanged();
|
||||||
|
void contentHeightChanged();
|
||||||
|
void layoutHeightChanged();
|
||||||
|
void contentYChanged();
|
||||||
|
void viewportChanged();
|
||||||
|
void useCustomViewportChanged();
|
||||||
|
void cacheBufferChanged();
|
||||||
|
void estimatedHeightChanged();
|
||||||
|
void asynchronousChanged();
|
||||||
|
void removeDurationChanged();
|
||||||
|
void readyDelayChanged();
|
||||||
|
void yAnimDurationChanged();
|
||||||
|
void countChanged();
|
||||||
|
void viewportAdjustNeeded(qreal delta);
|
||||||
|
void viewportRestoreRequested(qreal targetY);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void componentComplete() override;
|
||||||
|
void geometryChange(
|
||||||
|
const QRectF& newGeometry, const QRectF& oldGeometry) override;
|
||||||
|
void updatePolish() override;
|
||||||
|
bool event(QEvent* e) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct ItemRecord {
|
||||||
|
qreal targetY = 0;
|
||||||
|
qreal height = 0;
|
||||||
|
bool heightKnown = false;
|
||||||
|
bool isNew = false;
|
||||||
|
qreal measuredWidth = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DelegateEntry {
|
||||||
|
int modelIndex = -1;
|
||||||
|
QQuickItem* item = nullptr;
|
||||||
|
bool pendingRemoval = false;
|
||||||
|
bool pendingInsert = false;
|
||||||
|
bool readyDelayStarted = false;
|
||||||
|
bool measureOnly = false;
|
||||||
|
qreal propY = 0;
|
||||||
|
bool hasPropY = false;
|
||||||
|
qint64 lastPropTime = 0;
|
||||||
|
qint64 lastHeightChange = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
void relayout();
|
||||||
|
[[nodiscard]] std::pair<int, int> computeVisibleRange() const;
|
||||||
|
[[nodiscard]] QRectF effectiveViewport() const;
|
||||||
|
[[nodiscard]] qreal effectiveEstimatedHeight() const;
|
||||||
|
[[nodiscard]] static bool isStaleRecord(
|
||||||
|
const ItemRecord& record, qreal viewWidth);
|
||||||
|
[[nodiscard]] static qreal delegateHeight(QQuickItem* item);
|
||||||
|
[[nodiscard]] static qreal delegateVisibleHeight(QQuickItem* item);
|
||||||
|
[[nodiscard]] static bool isDelegateReady(QQuickItem* item);
|
||||||
|
void trackHeight(qreal height);
|
||||||
|
void untrackHeight(qreal height);
|
||||||
|
|
||||||
|
void syncDelegates();
|
||||||
|
void pumpMeasureQueue();
|
||||||
|
void buildMeasureQueue();
|
||||||
|
DelegateEntry createDelegate(int modelIndex);
|
||||||
|
void destroyDelegate(DelegateEntry& entry);
|
||||||
|
void updateDelegateData(DelegateEntry& entry);
|
||||||
|
|
||||||
|
void connectModel();
|
||||||
|
void disconnectModel();
|
||||||
|
void resetContent();
|
||||||
|
void onRowsInserted(const QModelIndex& parent, int first, int last);
|
||||||
|
void onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last);
|
||||||
|
void onRowsRemoved(const QModelIndex& parent, int first, int last);
|
||||||
|
void onDataChanged(
|
||||||
|
const QModelIndex& topLeft,
|
||||||
|
const QModelIndex& bottomRight,
|
||||||
|
const QList<int>& roles);
|
||||||
|
void onModelReset();
|
||||||
|
|
||||||
|
void applyAnchor();
|
||||||
|
|
||||||
|
QAbstractItemModel* m_model = nullptr;
|
||||||
|
QQmlComponent* m_delegate = nullptr;
|
||||||
|
|
||||||
|
qreal m_spacing = 0;
|
||||||
|
qreal m_contentHeight = 0;
|
||||||
|
qreal m_layoutHeight = 0;
|
||||||
|
qreal m_contentY = 0;
|
||||||
|
|
||||||
|
QRectF m_viewport;
|
||||||
|
bool m_useCustomViewport = false;
|
||||||
|
qreal m_cacheBuffer = 0;
|
||||||
|
|
||||||
|
qreal m_estimatedHeight = -1;
|
||||||
|
qreal m_knownHeightSum = 0;
|
||||||
|
int m_knownHeightCount = 0;
|
||||||
|
bool m_asynchronous = false;
|
||||||
|
|
||||||
|
int m_removeDuration = 300;
|
||||||
|
int m_readyDelay = 0;
|
||||||
|
int m_yAnimDuration = 300;
|
||||||
|
QElapsedTimer m_clock;
|
||||||
|
|
||||||
|
QVector<ItemRecord> m_layout;
|
||||||
|
QHash<int, DelegateEntry> m_delegates;
|
||||||
|
QHash<QQuickItem*, int> m_itemToIndex;
|
||||||
|
QVector<DelegateEntry> m_dyingDelegates;
|
||||||
|
|
||||||
|
QVector<int> m_measureQueue;
|
||||||
|
QTimer* m_measureTimer = nullptr;
|
||||||
|
|
||||||
|
bool m_componentComplete = false;
|
||||||
|
bool m_relayoutPending = false;
|
||||||
|
bool m_animateY = false;
|
||||||
|
|
||||||
|
QPointer<QQuickItem> m_anchorItem;
|
||||||
|
Edge m_anchorEdge = TopEdge;
|
||||||
|
int m_restoreIndex = -1;
|
||||||
|
bool m_restoreActive = false;
|
||||||
|
qint64 m_restoreQuietUntil = 0;
|
||||||
|
|
||||||
|
QList<QMetaObject::Connection> m_modelConnections;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZShell::components
|
||||||
@@ -50,8 +50,6 @@ void SparklineContent::setLine2(internal::CircularBuffer* buffer) {
|
|||||||
|
|
||||||
void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) {
|
void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) {
|
||||||
if (!buffer) return;
|
if (!buffer) return;
|
||||||
// New data, or the buffer's own smoothingRadius changed (it re-emits
|
|
||||||
// valuesChanged in that case too) -> geometry is stale.
|
|
||||||
connect(
|
connect(
|
||||||
buffer,
|
buffer,
|
||||||
&internal::CircularBuffer::valuesChanged,
|
&internal::CircularBuffer::valuesChanged,
|
||||||
@@ -67,7 +65,7 @@ void SparklineContent::connectBuffer(internal::CircularBuffer* buffer) {
|
|||||||
void SparklineContent::setLine1Color(const QColor& color) {
|
void SparklineContent::setLine1Color(const QColor& color) {
|
||||||
if (m_line1Color == color) return;
|
if (m_line1Color == color) return;
|
||||||
m_line1Color = color;
|
m_line1Color = color;
|
||||||
update(); // visual-only, no geometry rebuild needed
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
void SparklineContent::setLine2Color(const QColor& color) {
|
void SparklineContent::setLine2Color(const QColor& color) {
|
||||||
@@ -173,7 +171,7 @@ SparklineContent::LineGeometry SparklineContent::buildGeometry(
|
|||||||
if (m_curveSmoothing && points.size() > 2) {
|
if (m_curveSmoothing && points.size() > 2) {
|
||||||
const qreal tension = 1.0;
|
const qreal tension = 1.0;
|
||||||
const qreal k = tension / 6.0;
|
const qreal k = tension / 6.0;
|
||||||
const int n = points.size();
|
const int n = static_cast<int>(points.size());
|
||||||
|
|
||||||
for (int i = 0; i < n - 1; ++i) {
|
for (int i = 0; i < n - 1; ++i) {
|
||||||
const QPointF& p0 = points[qMax(i - 1, 0)];
|
const QPointF& p0 = points[qMax(i - 1, 0)];
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample(
|
|||||||
points.reserve(len);
|
points.reserve(len);
|
||||||
|
|
||||||
for (int i = 0; i < len; ++i) {
|
for (int i = 0; i < len; ++i) {
|
||||||
// Already smoothed once, incrementally, by the buffer itself.
|
|
||||||
const qreal value =
|
const qreal value =
|
||||||
qBound<qreal>(0.0, buffer->smoothedAt(i), m_maxValue);
|
qBound<qreal>(0.0, buffer->smoothedAt(i), m_maxValue);
|
||||||
|
|
||||||
@@ -99,7 +98,7 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample(
|
|||||||
if (m_curveSmoothing && points.size() > 2) {
|
if (m_curveSmoothing && points.size() > 2) {
|
||||||
const qreal tension = 1.0;
|
const qreal tension = 1.0;
|
||||||
const qreal k = tension / 6.0;
|
const qreal k = tension / 6.0;
|
||||||
const int n = points.size();
|
const int n = static_cast<int>(points.size());
|
||||||
for (int i = 0; i < n - 1; ++i) {
|
for (int i = 0; i < n - 1; ++i) {
|
||||||
const QPointF& p0 = points[qMax(i - 1, 0)];
|
const QPointF& p0 = points[qMax(i - 1, 0)];
|
||||||
const QPointF& p1 = points[i];
|
const QPointF& p1 = points[i];
|
||||||
@@ -125,9 +124,6 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten the curve to a polyline once, here, on data change only.
|
|
||||||
// This is CPU math only -- no QPainter draw calls, no image, no
|
|
||||||
// texture upload happen as a result of this.
|
|
||||||
const auto subpaths = path.toSubpathPolygons();
|
const auto subpaths = path.toSubpathPolygons();
|
||||||
if (subpaths.isEmpty()) return sample;
|
if (subpaths.isEmpty()) return sample;
|
||||||
|
|
||||||
@@ -139,7 +135,7 @@ SparklineSceneItem::LineSample SparklineSceneItem::buildSample(
|
|||||||
|
|
||||||
void SparklineSceneItem::writeFillGeometry(
|
void SparklineSceneItem::writeFillGeometry(
|
||||||
QSGGeometryNode* node, const LineSample& sample) {
|
QSGGeometryNode* node, const LineSample& sample) {
|
||||||
const int n = sample.polyline.size();
|
const int n = static_cast<int>(sample.polyline.size());
|
||||||
auto* geometry = node->geometry();
|
auto* geometry = node->geometry();
|
||||||
geometry->allocate(n * 2);
|
geometry->allocate(n * 2);
|
||||||
geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip);
|
geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip);
|
||||||
@@ -147,8 +143,10 @@ void SparklineSceneItem::writeFillGeometry(
|
|||||||
auto* vertices = geometry->vertexDataAsPoint2D();
|
auto* vertices = geometry->vertexDataAsPoint2D();
|
||||||
for (int i = 0; i < n; ++i) {
|
for (int i = 0; i < n; ++i) {
|
||||||
const QPointF& p = sample.polyline[i];
|
const QPointF& p = sample.polyline[i];
|
||||||
vertices[i * 2].set(p.x(), p.y());
|
vertices[i * 2].set(
|
||||||
vertices[i * 2 + 1].set(p.x(), sample.fillBottom);
|
static_cast<float>(p.x()), static_cast<float>(p.y()));
|
||||||
|
vertices[i * 2 + 1].set(
|
||||||
|
static_cast<float>(p.x()), static_cast<float>(sample.fillBottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
node->markDirty(QSGNode::DirtyGeometry);
|
node->markDirty(QSGNode::DirtyGeometry);
|
||||||
@@ -156,7 +154,7 @@ void SparklineSceneItem::writeFillGeometry(
|
|||||||
|
|
||||||
void SparklineSceneItem::writeStrokeGeometry(
|
void SparklineSceneItem::writeStrokeGeometry(
|
||||||
QSGGeometryNode* node, const LineSample& sample, qreal lineWidth) {
|
QSGGeometryNode* node, const LineSample& sample, qreal lineWidth) {
|
||||||
const int n = sample.polyline.size();
|
const int n = static_cast<int>(sample.polyline.size());
|
||||||
auto* geometry = node->geometry();
|
auto* geometry = node->geometry();
|
||||||
geometry->allocate(n * 2);
|
geometry->allocate(n * 2);
|
||||||
geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip);
|
geometry->setDrawingMode(QSGGeometry::DrawTriangleStrip);
|
||||||
@@ -179,8 +177,12 @@ void SparklineSceneItem::writeStrokeGeometry(
|
|||||||
|
|
||||||
const QPointF& p = sample.polyline[i];
|
const QPointF& p = sample.polyline[i];
|
||||||
const QPointF offset = normal * half;
|
const QPointF offset = normal * half;
|
||||||
vertices[i * 2].set(p.x() + offset.x(), p.y() + offset.y());
|
vertices[i * 2].set(
|
||||||
vertices[i * 2 + 1].set(p.x() - offset.x(), p.y() - offset.y());
|
static_cast<float>(p.x() + offset.x()),
|
||||||
|
static_cast<float>(p.y() + offset.y()));
|
||||||
|
vertices[i * 2 + 1].set(
|
||||||
|
static_cast<float>(p.x() - offset.x()),
|
||||||
|
static_cast<float>(p.y() - offset.y()));
|
||||||
}
|
}
|
||||||
|
|
||||||
node->markDirty(QSGNode::DirtyGeometry);
|
node->markDirty(QSGNode::DirtyGeometry);
|
||||||
@@ -243,7 +245,7 @@ QSGNode* SparklineSceneItem::updatePaintNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
QMatrix4x4 matrix;
|
QMatrix4x4 matrix;
|
||||||
matrix.translate(-stepX() * m_slideProgress, 0);
|
matrix.translate(static_cast<float>(-stepX() * m_slideProgress), 0.0f);
|
||||||
transformNode->setMatrix(matrix);
|
transformNode->setMatrix(matrix);
|
||||||
|
|
||||||
const bool needsGeometryWrite = samplesRebuilt || justCreated;
|
const bool needsGeometryWrite = samplesRebuilt || justCreated;
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class WavyLine : public QCanvasPainterItem {
|
|||||||
[[nodiscard]] qreal value() const;
|
[[nodiscard]] qreal value() const;
|
||||||
void setValue(qreal value);
|
void setValue(qreal value);
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void lineWidthChanged();
|
void lineWidthChanged();
|
||||||
void amplitudeMultiplierChanged();
|
void amplitudeMultiplierChanged();
|
||||||
void frequencyChanged();
|
void frequencyChanged();
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -20,6 +20,7 @@ qml_module(ZShell-config
|
|||||||
enums.hpp
|
enums.hpp
|
||||||
general.hpp
|
general.hpp
|
||||||
launcher.hpp
|
launcher.hpp
|
||||||
|
llm.hpp
|
||||||
lock.hpp
|
lock.hpp
|
||||||
notifs.hpp
|
notifs.hpp
|
||||||
osd.hpp
|
osd.hpp
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <qhashfunctions.h>
|
||||||
|
#include <qobject.h>
|
||||||
|
#include <qqmlintegration.h>
|
||||||
|
|
||||||
|
#include "common.hpp"
|
||||||
|
|
||||||
|
namespace ZShell::config {
|
||||||
|
|
||||||
|
class LlmAppearance : public settings::ObjectNode {
|
||||||
|
CONFIG_NODE(LlmAppearance, settings::ObjectNode)
|
||||||
|
|
||||||
|
CONFIG_PROPERTY(QString, scheme, QStringLiteral("tokyoNight"))
|
||||||
|
};
|
||||||
|
|
||||||
|
class Llm : public settings::ObjectNode {
|
||||||
|
CONFIG_NODE(Llm, settings::ObjectNode)
|
||||||
|
|
||||||
|
CONFIG_PROPERTY(QString, endpoint, "http://localhost:8080")
|
||||||
|
CONFIG_PROPERTY(QString, model, "")
|
||||||
|
CONFIG_PROPERTY(double, temperature, 0.7)
|
||||||
|
CONFIG_PROPERTY(bool, tools, true)
|
||||||
|
CONFIG_PROPERTY(bool, sandbox, true)
|
||||||
|
CONFIG_PROPERTY(bool, enabled, true)
|
||||||
|
CONFIG_SUBOBJECT(LlmAppearance, appearance)
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZShell::config
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
#include "dock.hpp"
|
#include "dock.hpp"
|
||||||
#include "general.hpp"
|
#include "general.hpp"
|
||||||
#include "launcher.hpp"
|
#include "launcher.hpp"
|
||||||
|
#include "llm.hpp"
|
||||||
#include "lock.hpp"
|
#include "lock.hpp"
|
||||||
#include "notifs.hpp"
|
#include "notifs.hpp"
|
||||||
#include "osd.hpp"
|
#include "osd.hpp"
|
||||||
@@ -42,6 +43,7 @@ class ConfigRoot : public settings::RootNode {
|
|||||||
CONFIG_SUBOBJECT(Dock, dock)
|
CONFIG_SUBOBJECT(Dock, dock)
|
||||||
CONFIG_SUBOBJECT(General, general)
|
CONFIG_SUBOBJECT(General, general)
|
||||||
CONFIG_SUBOBJECT(Launcher, launcher)
|
CONFIG_SUBOBJECT(Launcher, launcher)
|
||||||
|
CONFIG_SUBOBJECT(Llm, llm)
|
||||||
CONFIG_SUBOBJECT(Lock, lock)
|
CONFIG_SUBOBJECT(Lock, lock)
|
||||||
CONFIG_SUBOBJECT(Notifs, notifs)
|
CONFIG_SUBOBJECT(Notifs, notifs)
|
||||||
CONFIG_SUBOBJECT(Osd, osd)
|
CONFIG_SUBOBJECT(Osd, osd)
|
||||||
|
|||||||
@@ -37,12 +37,10 @@ static std::filesystem::path g_runtimeDir{"/tmp"};
|
|||||||
static std::filesystem::path g_cacheBase{"/tmp/.cache"};
|
static std::filesystem::path g_cacheBase{"/tmp/.cache"};
|
||||||
|
|
||||||
static void initEnvPaths() {
|
static void initEnvPaths() {
|
||||||
if (const char* env = getenv("XDG_RUNTIME_DIR"))
|
if (const char* env = getenv("XDG_RUNTIME_DIR")) g_runtimeDir = env;
|
||||||
g_runtimeDir = env;
|
|
||||||
|
|
||||||
if (const char* env = getenv("XDG_CACHE_HOME")) {
|
if (const char* env = getenv("XDG_CACHE_HOME")) {
|
||||||
if (*env)
|
if (*env) g_cacheBase = env;
|
||||||
g_cacheBase = env;
|
|
||||||
} else if (const char* home = getenv("HOME")) {
|
} else if (const char* home = getenv("HOME")) {
|
||||||
g_cacheBase = std::filesystem::path(home) / ".cache";
|
g_cacheBase = std::filesystem::path(home) / ".cache";
|
||||||
}
|
}
|
||||||
@@ -50,8 +48,7 @@ static void initEnvPaths() {
|
|||||||
|
|
||||||
static std::ofstream& debugLog() {
|
static std::ofstream& debugLog() {
|
||||||
static std::ofstream log(
|
static std::ofstream log(
|
||||||
g_runtimeDir / "zshell-nightlight-debug.log",
|
g_runtimeDir / "zshell-nightlight-debug.log", std::ios::app);
|
||||||
std::ios::app);
|
|
||||||
return log;
|
return log;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,15 +170,14 @@ static bool hkSaveBufferForMirror(CHyprOpenGLImpl* thisptr, const CBox& box) {
|
|||||||
|
|
||||||
static void installShader() {
|
static void installShader() {
|
||||||
auto* gl = g_pHyprOpenGL.get();
|
auto* gl = g_pHyprOpenGL.get();
|
||||||
if (!gl)
|
if (!gl) return;
|
||||||
return;
|
|
||||||
|
|
||||||
ensureShaderFile(shaderPath(), defaultShaderSource());
|
ensureShaderFile(shaderPath(), defaultShaderSource());
|
||||||
gl->applyScreenShader(shaderPath().string());
|
gl->applyScreenShader(shaderPath().string());
|
||||||
|
|
||||||
auto* shader = gl->m_finalScreenShader.get();
|
auto* shader = gl->m_finalScreenShader.get();
|
||||||
if (shader && shader->program() >= 1 &&
|
if (shader && shader->program() >= 1 &&
|
||||||
shader->getUniformLocation(SHADER_TINT) != -1) {
|
shader->getUniformLocation(SHADER_TINT) != -1) {
|
||||||
g_uniformMode = true;
|
g_uniformMode = true;
|
||||||
} else {
|
} else {
|
||||||
g_uniformMode = false;
|
g_uniformMode = false;
|
||||||
@@ -196,8 +192,7 @@ static void installShader() {
|
|||||||
static void setUniformGain(const std::array<float, 3>& gain) {
|
static void setUniformGain(const std::array<float, 3>& gain) {
|
||||||
auto* gl = g_pHyprOpenGL.get();
|
auto* gl = g_pHyprOpenGL.get();
|
||||||
auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr;
|
auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr;
|
||||||
if (!gl || !shader)
|
if (!gl || !shader) return;
|
||||||
return;
|
|
||||||
|
|
||||||
gl->useShader(gl->m_finalScreenShader);
|
gl->useShader(gl->m_finalScreenShader);
|
||||||
shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]);
|
shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]);
|
||||||
@@ -214,8 +209,7 @@ static void hkBegin(
|
|||||||
|
|
||||||
if (fb) {
|
if (fb) {
|
||||||
auto* renderer = g_pHyprRenderer.get();
|
auto* renderer = g_pHyprRenderer.get();
|
||||||
if (renderer)
|
if (renderer) renderer->m_renderData.blockScreenShader = true;
|
||||||
renderer->m_renderData.blockScreenShader = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!g_active) return;
|
if (!g_active) return;
|
||||||
@@ -223,17 +217,15 @@ static void hkBegin(
|
|||||||
auto* gl = g_pHyprOpenGL.get();
|
auto* gl = g_pHyprOpenGL.get();
|
||||||
if (!gl) return;
|
if (!gl) return;
|
||||||
|
|
||||||
if (gl->m_finalScreenShader.get() &&
|
if (gl->m_finalScreenShader.get() && gl->m_finalScreenShader->program() < 1)
|
||||||
gl->m_finalScreenShader->program() < 1)
|
|
||||||
installShader();
|
installShader();
|
||||||
if (!gl->m_finalScreenShader.get() ||
|
if (!gl->m_finalScreenShader.get() ||
|
||||||
gl->m_finalScreenShader->program() < 1)
|
gl->m_finalScreenShader->program() < 1)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (g_transition.active) {
|
if (g_transition.active) {
|
||||||
auto* monPtr = mon.get();
|
auto* monPtr = mon.get();
|
||||||
if (!monPtr)
|
if (!monPtr) return;
|
||||||
return;
|
|
||||||
const float elapsed =
|
const float elapsed =
|
||||||
std::chrono::duration<float>(
|
std::chrono::duration<float>(
|
||||||
std::chrono::steady_clock::now() - g_transition.start)
|
std::chrono::steady_clock::now() - g_transition.start)
|
||||||
@@ -255,9 +247,9 @@ static void hkBegin(
|
|||||||
ensureShaderFile(
|
ensureShaderFile(
|
||||||
bakedShaderPath(g_currentGain),
|
bakedShaderPath(g_currentGain),
|
||||||
bakedShaderSource(g_currentGain));
|
bakedShaderSource(g_currentGain));
|
||||||
gl->applyScreenShader(bakedShaderPath(g_currentGain).string());
|
gl->applyScreenShader(bakedShaderPath(g_currentGain).string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (t >= 1.f) {
|
if (t >= 1.f) {
|
||||||
g_transition.active = false;
|
g_transition.active = false;
|
||||||
|
|||||||
@@ -182,10 +182,10 @@ static QString buildHlConfigCall(const QString& key, const QVariant& value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static QColor colorFromInt(quint32 value) {
|
static QColor colorFromInt(quint32 value) {
|
||||||
const int a = (value >> 24) & 0xFF;
|
const int a = static_cast<int>(value >> 24) & 0xFF;
|
||||||
const int r = (value >> 16) & 0xFF;
|
const int r = static_cast<int>(value >> 16) & 0xFF;
|
||||||
const int g = (value >> 8) & 0xFF;
|
const int g = static_cast<int>(value >> 8) & 0xFF;
|
||||||
const int b = value & 0xFF;
|
const int b = static_cast<int>(value) & 0xFF;
|
||||||
|
|
||||||
return QColor(r, g, b, a);
|
return QColor(r, g, b, a);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ static QCanvasPath buildStrokePath(
|
|||||||
void StrokeCanvasItem::undoLastGroup() {
|
void StrokeCanvasItem::undoLastGroup() {
|
||||||
if (m_strokes.isEmpty()) return;
|
if (m_strokes.isEmpty()) return;
|
||||||
const int lastGroup = m_strokes.last().groupId;
|
const int lastGroup = m_strokes.last().groupId;
|
||||||
for (int i = m_strokes.size() - 1;
|
for (int i = static_cast<int>(m_strokes.size()) - 1;
|
||||||
i >= 0 && m_strokes[i].groupId == lastGroup;
|
i >= 0 && m_strokes[i].groupId == lastGroup;
|
||||||
--i)
|
--i)
|
||||||
m_strokes.remove(i);
|
m_strokes.remove(i);
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem* item) {
|
|||||||
for (const auto& stroke : canvas->m_strokes)
|
for (const auto& stroke : canvas->m_strokes)
|
||||||
canvasGroupIds.insert(stroke.groupId);
|
canvasGroupIds.insert(stroke.groupId);
|
||||||
|
|
||||||
for (int i = m_strokes.size() - 1; i >= 0; --i) {
|
for (int i = static_cast<int>(m_strokes.size()) - 1; i >= 0; --i) {
|
||||||
if (!canvasGroupIds.contains(m_strokes[i].groupId)) {
|
if (!canvasGroupIds.contains(m_strokes[i].groupId)) {
|
||||||
if (m_strokes[i].groupId >= 0)
|
if (m_strokes[i].groupId >= 0)
|
||||||
m_pendingGroupRemovals.append(m_strokes[i].groupId);
|
m_pendingGroupRemovals.append(m_strokes[i].groupId);
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
pkg_check_modules(CMARK_GFM REQUIRED IMPORTED_TARGET libcmark-gfm)
|
||||||
|
|
||||||
|
pkg_check_modules(TREE_SITTER REQUIRED IMPORTED_TARGET tree-sitter)
|
||||||
|
|
||||||
|
find_path(JKQTPlotter6_CMAKE_DIR
|
||||||
|
NAMES JKQTMathText6Config.cmake
|
||||||
|
HINTS /usr/lib/cmake/JKQTPlotter6 /usr/local/lib/cmake/JKQTPlotter6
|
||||||
|
DOC "Directory containing the JKQtPlotter cmake package files")
|
||||||
|
find_package(JKQTMathText6 REQUIRED PATHS "${JKQTPlotter6_CMAKE_DIR}")
|
||||||
|
|
||||||
|
function(_ts_entry_point out file)
|
||||||
|
get_filename_component(_ts_nm_base "${file}" NAME)
|
||||||
|
set(_ts_nm_file "${CMAKE_CURRENT_BINARY_DIR}/ts-entry-${_ts_nm_base}")
|
||||||
|
execute_process(
|
||||||
|
COMMAND nm -D --defined-only "${file}"
|
||||||
|
RESULT_VARIABLE _ts_nm_rc
|
||||||
|
OUTPUT_FILE "${_ts_nm_file}"
|
||||||
|
ERROR_FILE "${_ts_nm_file}.err")
|
||||||
|
set(_ts_sym "tree_sitter_missing")
|
||||||
|
if(_ts_nm_rc EQUAL 0 AND EXISTS "${_ts_nm_file}")
|
||||||
|
file(READ "${_ts_nm_file}" _ts_nm_out)
|
||||||
|
file(REMOVE "${_ts_nm_file}" "${_ts_nm_file}.err")
|
||||||
|
string(REGEX MATCHALL " T tree_sitter_[A-Za-z0-9_]+" _ts_syms "${_ts_nm_out}")
|
||||||
|
foreach(_ts_s IN LISTS _ts_syms)
|
||||||
|
string(REPLACE " T " "" _ts_s "${_ts_s}")
|
||||||
|
if(NOT _ts_s MATCHES "_external")
|
||||||
|
set(_ts_sym "${_ts_s}")
|
||||||
|
break()
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
endif()
|
||||||
|
set(${out} "${_ts_sym}" PARENT_SCOPE)
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
function(_ts_expand_inherits query_dir text outVar)
|
||||||
|
set(result "${text}")
|
||||||
|
set(_visited "")
|
||||||
|
set(_depth 0)
|
||||||
|
while(_depth LESS 8)
|
||||||
|
string(REGEX MATCHALL "inherits:[ \t]*[A-Za-z0-9_]+" _inh "${result}")
|
||||||
|
if(NOT _inh)
|
||||||
|
break()
|
||||||
|
endif()
|
||||||
|
set(_added FALSE)
|
||||||
|
foreach(_entry IN LISTS _inh)
|
||||||
|
string(REGEX REPLACE "^inherits:[ \t]*" "" _name "${_entry}")
|
||||||
|
set(_file "${query_dir}/${_name}/highlights.scm")
|
||||||
|
if(NOT EXISTS "${_file}" OR _file IN_LIST _visited)
|
||||||
|
continue()
|
||||||
|
endif()
|
||||||
|
list(APPEND _visited "${_file}")
|
||||||
|
file(READ "${_file}" _inh_text)
|
||||||
|
string(APPEND result "\n${_inh_text}")
|
||||||
|
set(_added TRUE)
|
||||||
|
endforeach()
|
||||||
|
if(NOT _added)
|
||||||
|
break()
|
||||||
|
endif()
|
||||||
|
math(EXPR _depth "${_depth} + 1")
|
||||||
|
endwhile()
|
||||||
|
set(${outVar} "${result}" PARENT_SCOPE)
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
function(_ts_add_query sid text filesVar hashesVar)
|
||||||
|
set(files "${${filesVar}}")
|
||||||
|
set(hashes "${${hashesVar}}")
|
||||||
|
string(SHA256 _ts_qh "${text}")
|
||||||
|
if(_ts_qh IN_LIST hashes)
|
||||||
|
return()
|
||||||
|
endif()
|
||||||
|
list(APPEND hashes "${_ts_qh}")
|
||||||
|
list(LENGTH files _ts_qi)
|
||||||
|
set(_ts_qfile "${CMAKE_CURRENT_BINARY_DIR}/ts-queries/${sid}_${_ts_qi}.scm")
|
||||||
|
file(WRITE "${_ts_qfile}" "${text}")
|
||||||
|
list(APPEND files "${_ts_qfile}")
|
||||||
|
set(${filesVar} "${files}" PARENT_SCOPE)
|
||||||
|
set(${hashesVar} "${hashes}" PARENT_SCOPE)
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
set(_ts_candidates "")
|
||||||
|
file(GLOB _ts_sys_files
|
||||||
|
"/usr/lib/libtree-sitter-*.so" "/usr/local/lib/libtree-sitter-*.so")
|
||||||
|
foreach(_ts_file IN LISTS _ts_sys_files)
|
||||||
|
get_filename_component(_ts_name "${_ts_file}" NAME)
|
||||||
|
string(REGEX REPLACE "^libtree-sitter-(.+)\.so$" "\\1" _ts_id "${_ts_name}")
|
||||||
|
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
|
||||||
|
_ts_entry_point(_ts_sym "${_ts_file}")
|
||||||
|
list(APPEND _ts_candidates
|
||||||
|
"${_ts_id}|${_ts_sid}|libtree-sitter-${_ts_id}.so|${_ts_sym}")
|
||||||
|
endforeach()
|
||||||
|
set(_ts_nvim_parser_dirs
|
||||||
|
"$ENV{HOME}/.local/share/nvim/site/parser"
|
||||||
|
"$ENV{HOME}/.local/share/nvim/runtime/parser"
|
||||||
|
"$ENV{HOME}/.local/share/nvim/lazy/nvim-treesitter/parser")
|
||||||
|
set(_ts_nvim_query_dirs
|
||||||
|
"$ENV{HOME}/.local/share/nvim/site/queries"
|
||||||
|
"$ENV{HOME}/.local/share/nvim/lazy/nvim-treesitter/runtime/queries")
|
||||||
|
foreach(_ts_dir IN LISTS _ts_nvim_parser_dirs)
|
||||||
|
file(GLOB _ts_dir_files "${_ts_dir}/*.so")
|
||||||
|
foreach(_ts_file IN LISTS _ts_dir_files)
|
||||||
|
get_filename_component(_ts_name "${_ts_file}" NAME)
|
||||||
|
string(REGEX REPLACE "\\.so$" "" _ts_id "${_ts_name}")
|
||||||
|
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
|
||||||
|
_ts_entry_point(_ts_sym "${_ts_file}")
|
||||||
|
list(APPEND _ts_candidates
|
||||||
|
"${_ts_id}|${_ts_sid}|${_ts_file}|${_ts_sym}")
|
||||||
|
endforeach()
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
set(_ts_ids "")
|
||||||
|
foreach(_ts_c IN LISTS _ts_candidates)
|
||||||
|
string(REPLACE "|" ";" _ts_parts "${_ts_c}")
|
||||||
|
list(GET _ts_parts 0 _ts_id)
|
||||||
|
if(NOT _ts_id IN_LIST _ts_ids)
|
||||||
|
list(APPEND _ts_ids "${_ts_id}")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
list(SORT _ts_ids)
|
||||||
|
|
||||||
|
set(HIGHLIGHT_QUERIES_HPP "${CMAKE_CURRENT_BINARY_DIR}/highlight-queries.hpp")
|
||||||
|
set(_hl_header
|
||||||
|
"#pragma once\n\n// Generated by CMake. Discoverd tree-sitter grammars and their\n// highlight queries: vendored highlight-queries/*.scm (MIT), Neovim\n// nvim-treesitter queries, and tree-sitter/highlighting (MIT).\nnamespace ZShell::llm::hq {\nstruct Candidate { const char* lib; const char* symbol; };\nstruct Grammar { const char* id; int nCandidates; const Candidate* candidates; int nQueries; const char* const* queries; };\n")
|
||||||
|
set(_ts_grammar_rows "")
|
||||||
|
set(_ts_vendored
|
||||||
|
c cpp python javascript typescript tsx bash json rust go yaml toml sql)
|
||||||
|
foreach(_ts_id IN LISTS _ts_ids)
|
||||||
|
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
|
||||||
|
|
||||||
|
set(_ts_qfiles "")
|
||||||
|
set(_ts_qhashes "")
|
||||||
|
if(_ts_id STREQUAL "cpp")
|
||||||
|
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/c.scm" _ts_qa)
|
||||||
|
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/cpp.scm" _ts_qb)
|
||||||
|
_ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes)
|
||||||
|
elseif(_ts_id STREQUAL "typescript" OR _ts_id STREQUAL "tsx")
|
||||||
|
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/javascript.scm" _ts_qa)
|
||||||
|
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/typescript.scm" _ts_qb)
|
||||||
|
_ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes)
|
||||||
|
_ts_add_query(${_ts_sid} "${_ts_qb}" _ts_qfiles _ts_qhashes)
|
||||||
|
elseif(_ts_id IN_LIST _ts_vendored)
|
||||||
|
file(READ
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/${_ts_id}.scm" _ts_q)
|
||||||
|
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
|
||||||
|
endif()
|
||||||
|
foreach(_ts_qd IN LISTS _ts_nvim_query_dirs)
|
||||||
|
if(EXISTS "${_ts_qd}/${_ts_id}/highlights.scm")
|
||||||
|
file(READ "${_ts_qd}/${_ts_id}/highlights.scm" _ts_q)
|
||||||
|
_ts_expand_inherits("${_ts_qd}" "${_ts_q}" _ts_q)
|
||||||
|
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
if(NOT _ts_qfiles)
|
||||||
|
set(_ts_dl "${CMAKE_CURRENT_BINARY_DIR}/ts-query-downloads/${_ts_sid}.scm")
|
||||||
|
if(NOT EXISTS "${_ts_dl}")
|
||||||
|
file(DOWNLOAD
|
||||||
|
"https://raw.githubusercontent.com/tree-sitter/highlighting/main/queries/${_ts_id}/highlight.scm"
|
||||||
|
"${_ts_dl}" STATUS _ts_dl_status TIMEOUT 30)
|
||||||
|
list(GET _ts_dl_status 0 _ts_dl_rc)
|
||||||
|
if(NOT _ts_dl_rc EQUAL 0)
|
||||||
|
file(REMOVE "${_ts_dl}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
if(EXISTS "${_ts_dl}")
|
||||||
|
file(READ "${_ts_dl}" _ts_q)
|
||||||
|
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
list(LENGTH _ts_qfiles _ts_nq)
|
||||||
|
if(_ts_nq EQUAL 0)
|
||||||
|
continue()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(_ts_qn 0)
|
||||||
|
set(_ts_q_ptrs "")
|
||||||
|
foreach(_ts_qfile IN LISTS _ts_qfiles)
|
||||||
|
file(READ "${_ts_qfile}" _ts_q)
|
||||||
|
string(APPEND _hl_header
|
||||||
|
"inline constexpr const char* q_${_ts_sid}_${_ts_qn} = R\"ZSQUERY(${_ts_q})ZSQUERY\";\n")
|
||||||
|
string(APPEND _ts_q_ptrs "q_${_ts_sid}_${_ts_qn}, ")
|
||||||
|
math(EXPR _ts_qn "${_ts_qn} + 1")
|
||||||
|
endforeach()
|
||||||
|
string(APPEND _hl_header
|
||||||
|
"inline constexpr const char* const q_${_ts_sid}[] = { ${_ts_q_ptrs} };\n")
|
||||||
|
|
||||||
|
string(APPEND _hl_header "inline constexpr Candidate cand_${_ts_sid}[] = {\n")
|
||||||
|
set(_ts_nc 0)
|
||||||
|
foreach(_ts_c IN LISTS _ts_candidates)
|
||||||
|
string(REPLACE "|" ";" _ts_parts "${_ts_c}")
|
||||||
|
list(GET _ts_parts 0 _ts_cid)
|
||||||
|
if(_ts_cid STREQUAL _ts_id)
|
||||||
|
list(GET _ts_parts 2 _ts_lib)
|
||||||
|
list(GET _ts_parts 3 _ts_sym)
|
||||||
|
string(APPEND _hl_header
|
||||||
|
" { R\"ZSLIB(${_ts_lib})ZSLIB\", R\"ZSSYM(${_ts_sym})ZSSYM\" },\n")
|
||||||
|
math(EXPR _ts_nc "${_ts_nc} + 1")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
string(APPEND _hl_header "};\n")
|
||||||
|
if(_ts_nc EQUAL 0)
|
||||||
|
string(APPEND _hl_header "")
|
||||||
|
continue()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
list(APPEND _ts_grammar_rows
|
||||||
|
"{ \"${_ts_id}\", ${_ts_nc}, cand_${_ts_sid}, ${_ts_nq}, q_${_ts_sid} },")
|
||||||
|
endforeach()
|
||||||
|
string(APPEND _hl_header "inline constexpr Grammar grammars[] = {\n")
|
||||||
|
foreach(_ts_row IN LISTS _ts_grammar_rows)
|
||||||
|
string(APPEND _hl_header " ${_ts_row}\n")
|
||||||
|
endforeach()
|
||||||
|
string(APPEND _hl_header "};\n}\n")
|
||||||
|
file(WRITE "${HIGHLIGHT_QUERIES_HPP}" "${_hl_header}")
|
||||||
|
|
||||||
|
set(LM_FONTS
|
||||||
|
lmroman10-regular
|
||||||
|
lmroman10-italic
|
||||||
|
lmroman10-bold
|
||||||
|
lmroman10-bolditalic
|
||||||
|
latinmodern-math)
|
||||||
|
set(LM_FONTS_HPP "${CMAKE_CURRENT_BINARY_DIR}/latinmodern-fonts.hpp")
|
||||||
|
set(_lm_header "#pragma once\n\n// Vendored Latin Modern fonts (GUST Font License; see\n// fonts/latinmodern/GUST-FONT-LICENSE.txt).\nnamespace ZShell::llm::lmfont {\n")
|
||||||
|
foreach(_lm_font IN LISTS LM_FONTS)
|
||||||
|
string(REPLACE "-" "_" _lm_sym "${_lm_font}")
|
||||||
|
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/fonts/latinmodern/${_lm_font}.otf" _lm_hex HEX)
|
||||||
|
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _lm_bytes "${_lm_hex}")
|
||||||
|
string(APPEND _lm_header "inline const unsigned char ${_lm_sym}[] = { ${_lm_bytes} };\n")
|
||||||
|
endforeach()
|
||||||
|
string(APPEND _lm_header "}\n")
|
||||||
|
file(WRITE "${LM_FONTS_HPP}" "${_lm_header}")
|
||||||
|
|
||||||
|
qml_module(ZShell-llm
|
||||||
|
URI ZShell.Llm
|
||||||
|
SOURCES
|
||||||
|
attachment.hpp attachment.cpp
|
||||||
|
chatstore.hpp chatstore.cpp
|
||||||
|
clipboard.hpp clipboard.cpp
|
||||||
|
codehighlighter.hpp codehighlighter.cpp
|
||||||
|
filetool.hpp filetool.cpp
|
||||||
|
generation.hpp generation.cpp
|
||||||
|
llmclient.hpp llmclient.cpp
|
||||||
|
markdownblock.hpp markdownblock.cpp
|
||||||
|
markdownparser.hpp markdownparser.cpp
|
||||||
|
mathtext.hpp mathtext.cpp
|
||||||
|
message.hpp message.cpp
|
||||||
|
messagemodel.hpp messagemodel.cpp
|
||||||
|
prompts.hpp prompts.cpp
|
||||||
|
segment.hpp segment.cpp
|
||||||
|
session.hpp session.cpp
|
||||||
|
shelltool.hpp shelltool.cpp
|
||||||
|
tool.hpp tool.cpp
|
||||||
|
turn.hpp turn.cpp
|
||||||
|
webfetchtool.hpp webfetchtool.cpp
|
||||||
|
workspace.hpp workspace.cpp
|
||||||
|
LIBRARIES
|
||||||
|
Qt::Network
|
||||||
|
Qt::Sql
|
||||||
|
Qt::Gui
|
||||||
|
Qt::Widgets
|
||||||
|
ZShell-config
|
||||||
|
JKQTPlotter::JKQTMathText
|
||||||
|
PkgConfig::CMARK_GFM
|
||||||
|
PkgConfig::TREE_SITTER
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#include "attachment.hpp"
|
||||||
|
|
||||||
|
namespace ZShell::llm {
|
||||||
|
|
||||||
|
Attachment::Attachment(
|
||||||
|
const QString& name,
|
||||||
|
const QString& path,
|
||||||
|
Kind kind,
|
||||||
|
const QString& mimeType,
|
||||||
|
qint64 size,
|
||||||
|
QObject* parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_name(name)
|
||||||
|
, m_path(path)
|
||||||
|
, m_kind(kind)
|
||||||
|
, m_mimeType(mimeType)
|
||||||
|
, m_size(size) {}
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QtQml>
|
||||||
|
|
||||||
|
namespace ZShell::llm {
|
||||||
|
|
||||||
|
class Attachment : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_UNCREATABLE("Attachments are created by ChatSession.addAttachments")
|
||||||
|
|
||||||
|
Q_PROPERTY(QString name READ name CONSTANT)
|
||||||
|
Q_PROPERTY(QString path READ path CONSTANT)
|
||||||
|
Q_PROPERTY(Kind kind READ kind CONSTANT)
|
||||||
|
Q_PROPERTY(bool isImage READ isImage CONSTANT)
|
||||||
|
Q_PROPERTY(QString mimeType READ mimeType CONSTANT)
|
||||||
|
Q_PROPERTY(qint64 size READ size CONSTANT)
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum class Kind : int { Image = 0, File = 1 };
|
||||||
|
Q_ENUM(Kind)
|
||||||
|
|
||||||
|
Attachment(
|
||||||
|
const QString& name,
|
||||||
|
const QString& path,
|
||||||
|
Kind kind,
|
||||||
|
const QString& mimeType,
|
||||||
|
qint64 size,
|
||||||
|
QObject* parent = nullptr);
|
||||||
|
|
||||||
|
[[nodiscard]] QString name() const { return m_name; }
|
||||||
|
[[nodiscard]] QString path() const { return m_path; }
|
||||||
|
[[nodiscard]] Kind kind() const { return m_kind; }
|
||||||
|
[[nodiscard]] bool isImage() const { return m_kind == Kind::Image; }
|
||||||
|
[[nodiscard]] QString mimeType() const { return m_mimeType; }
|
||||||
|
[[nodiscard]] qint64 size() const { return m_size; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString m_name;
|
||||||
|
QString m_path;
|
||||||
|
Kind m_kind;
|
||||||
|
QString m_mimeType;
|
||||||
|
qint64 m_size = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,805 @@
|
|||||||
|
#include "chatstore.hpp"
|
||||||
|
|
||||||
|
#include "attachment.hpp"
|
||||||
|
#include "llmclient.hpp"
|
||||||
|
#include "markdownblock.hpp"
|
||||||
|
#include "message.hpp"
|
||||||
|
#include "segment.hpp"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QSqlError>
|
||||||
|
#include <QSqlQuery>
|
||||||
|
#include <QThreadPool>
|
||||||
|
#include <QVector>
|
||||||
|
#include <QUuid>
|
||||||
|
|
||||||
|
namespace ZShell::llm {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QString segmentTypeName(LlmSegment::Type type) {
|
||||||
|
switch (type) {
|
||||||
|
case LlmSegment::Type::Reasoning:
|
||||||
|
return QStringLiteral("reasoning");
|
||||||
|
case LlmSegment::Type::ToolCall:
|
||||||
|
return QStringLiteral("tool_call");
|
||||||
|
case LlmSegment::Type::Content:
|
||||||
|
return QStringLiteral("content");
|
||||||
|
}
|
||||||
|
return QStringLiteral("reasoning");
|
||||||
|
}
|
||||||
|
|
||||||
|
LlmSegment::Type segmentTypeFromName(const QString& name) {
|
||||||
|
if (name == QLatin1String("tool_call")) return LlmSegment::Type::ToolCall;
|
||||||
|
if (name == QLatin1String("content")) return LlmSegment::Type::Content;
|
||||||
|
return LlmSegment::Type::Reasoning;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString sqlText(const QString& value) {
|
||||||
|
if (value.isNull()) return QStringLiteral("");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SegmentRow {
|
||||||
|
QString type;
|
||||||
|
QString text;
|
||||||
|
QString name;
|
||||||
|
QString toolCallId;
|
||||||
|
QString arguments;
|
||||||
|
QString result;
|
||||||
|
int status = 0;
|
||||||
|
qint64 elapsedMs = 0;
|
||||||
|
qint64 timestamp = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GenerationRow {
|
||||||
|
qint64 timestamp = 0;
|
||||||
|
bool active = false;
|
||||||
|
QVector<SegmentRow> segments;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AttachmentRow {
|
||||||
|
QString name;
|
||||||
|
QString path;
|
||||||
|
int kind = 1;
|
||||||
|
QString mime;
|
||||||
|
qint64 size = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct MessageRow {
|
||||||
|
bool user = false;
|
||||||
|
qint64 timestamp = 0;
|
||||||
|
QString content;
|
||||||
|
QVector<GenerationRow> generations;
|
||||||
|
QVector<AttachmentRow> attachments;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ChatStore::ChatStore(LlmClient* client, QObject* parent)
|
||||||
|
: QAbstractListModel(parent)
|
||||||
|
, m_client(client)
|
||||||
|
, m_connectionName(QUuid::createUuid().toString()) {
|
||||||
|
openDb();
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatStore::~ChatStore() {
|
||||||
|
if (m_connectionName.isEmpty()) return;
|
||||||
|
QSqlDatabase handle = QSqlDatabase::database(m_connectionName, false);
|
||||||
|
handle.close();
|
||||||
|
QSqlDatabase::removeDatabase(m_connectionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
QSqlDatabase ChatStore::db() const {
|
||||||
|
return QSqlDatabase::database(m_connectionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::openDb() {
|
||||||
|
m_dbPath =
|
||||||
|
QStandardPaths::writableLocation(QStandardPaths::GenericStateLocation) +
|
||||||
|
QStringLiteral("/zshell/chats.sqlite");
|
||||||
|
QDir().mkpath(QFileInfo(m_dbPath).absolutePath());
|
||||||
|
|
||||||
|
QSqlDatabase handle =
|
||||||
|
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
|
||||||
|
handle.setDatabaseName(m_dbPath);
|
||||||
|
if (!handle.open()) {
|
||||||
|
qWarning() << "ChatStore: failed to open database" << m_dbPath << ":"
|
||||||
|
<< handle.lastError().text();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
QSqlQuery pragma(handle);
|
||||||
|
pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON"));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
QSqlQuery query(handle);
|
||||||
|
query.exec(QStringLiteral(
|
||||||
|
"CREATE TABLE IF NOT EXISTS sessions (\n"
|
||||||
|
" id TEXT PRIMARY KEY,\n"
|
||||||
|
" title TEXT NOT NULL DEFAULT '',\n"
|
||||||
|
" icon TEXT NOT NULL DEFAULT '',\n"
|
||||||
|
" created_at INTEGER NOT NULL,\n"
|
||||||
|
" updated_at INTEGER NOT NULL,\n"
|
||||||
|
" pinned INTEGER NOT NULL DEFAULT 0\n"
|
||||||
|
")"));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
QSqlQuery query(handle);
|
||||||
|
query.exec(QStringLiteral(
|
||||||
|
"CREATE TABLE IF NOT EXISTS messages (\n"
|
||||||
|
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
|
||||||
|
" session_id TEXT NOT NULL REFERENCES sessions (id) "
|
||||||
|
"ON DELETE CASCADE,\n"
|
||||||
|
" role TEXT NOT NULL,\n"
|
||||||
|
" content TEXT NOT NULL DEFAULT '',\n"
|
||||||
|
" timestamp INTEGER NOT NULL\n"
|
||||||
|
")"));
|
||||||
|
if (!query.exec(QStringLiteral(
|
||||||
|
"ALTER TABLE messages ADD COLUMN content TEXT "
|
||||||
|
"NOT NULL DEFAULT ''"))) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
QSqlQuery query(handle);
|
||||||
|
query.exec(QStringLiteral(
|
||||||
|
"CREATE TABLE IF NOT EXISTS generations (\n"
|
||||||
|
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
|
||||||
|
" message_id INTEGER NOT NULL REFERENCES messages "
|
||||||
|
"(id) ON DELETE CASCADE,\n"
|
||||||
|
" timestamp INTEGER NOT NULL,\n"
|
||||||
|
" is_active INTEGER NOT NULL DEFAULT 1\n"
|
||||||
|
")"));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
QSqlQuery query(handle);
|
||||||
|
query.exec(QStringLiteral(
|
||||||
|
"CREATE TABLE IF NOT EXISTS segments (\n"
|
||||||
|
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
|
||||||
|
" generation_id INTEGER NOT NULL REFERENCES generations "
|
||||||
|
"(id) ON DELETE CASCADE,\n"
|
||||||
|
" type TEXT NOT NULL,\n"
|
||||||
|
" text TEXT NOT NULL DEFAULT '',\n"
|
||||||
|
" name TEXT NOT NULL DEFAULT '',\n"
|
||||||
|
" tool_call_id TEXT NOT NULL DEFAULT '',\n"
|
||||||
|
" arguments TEXT NOT NULL DEFAULT '',\n"
|
||||||
|
" result TEXT NOT NULL DEFAULT '',\n"
|
||||||
|
" status INTEGER NOT NULL DEFAULT 0,\n"
|
||||||
|
" elapsed_ms INTEGER NOT NULL DEFAULT 0,\n"
|
||||||
|
" timestamp INTEGER NOT NULL\n"
|
||||||
|
")"));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
QSqlQuery query(handle);
|
||||||
|
query.exec(QStringLiteral(
|
||||||
|
"CREATE TABLE IF NOT EXISTS attachments (\n"
|
||||||
|
" id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
|
||||||
|
" message_id INTEGER NOT NULL REFERENCES messages "
|
||||||
|
"(id) ON DELETE CASCADE,\n"
|
||||||
|
" name TEXT NOT NULL,\n"
|
||||||
|
" path TEXT NOT NULL,\n"
|
||||||
|
" kind INTEGER NOT NULL DEFAULT 1,\n"
|
||||||
|
" mime TEXT NOT NULL DEFAULT '',\n"
|
||||||
|
" size INTEGER NOT NULL DEFAULT 0\n"
|
||||||
|
")"));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
QSqlQuery query(handle);
|
||||||
|
query.exec(QStringLiteral(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_messages_session "
|
||||||
|
"ON messages (session_id)"));
|
||||||
|
query.exec(QStringLiteral(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_generations_message "
|
||||||
|
"ON generations (message_id)"));
|
||||||
|
query.exec(QStringLiteral(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_segments_generation "
|
||||||
|
"ON segments (generation_id)"));
|
||||||
|
query.exec(QStringLiteral(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_attachments_message "
|
||||||
|
"ON attachments (message_id)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int ChatStore::rowCount(const QModelIndex& parent) const {
|
||||||
|
if (parent.isValid()) return 0;
|
||||||
|
return static_cast<int>(m_sessions.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant ChatStore::data(const QModelIndex& index, int role) const {
|
||||||
|
if (role != Qt::UserRole || !index.isValid() || index.row() < 0 ||
|
||||||
|
index.row() >= m_sessions.size())
|
||||||
|
return {};
|
||||||
|
return QVariant::fromValue(m_sessions.at(index.row()));
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<int, QByteArray> ChatStore::roleNames() const {
|
||||||
|
return {{Qt::UserRole, "modelData"}};
|
||||||
|
}
|
||||||
|
|
||||||
|
int ChatStore::rowOf(const ChatSession* session) const {
|
||||||
|
for (int i = 0; i < m_sessions.size(); ++i)
|
||||||
|
if (m_sessions.at(i) == session) return i;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatStore::firstAfter(const ChatSession* a, const ChatSession* b) {
|
||||||
|
if (a->pinned() != b->pinned()) return a->pinned();
|
||||||
|
return a->updatedAtMs() > b->updatedAtMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
int ChatStore::sortedRow(const ChatSession* session) const {
|
||||||
|
int row = 0;
|
||||||
|
while (row < m_sessions.size() && !firstAfter(session, m_sessions.at(row)))
|
||||||
|
++row;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::insertRow(ChatSession* session) {
|
||||||
|
const int row = sortedRow(session);
|
||||||
|
beginInsertRows(QModelIndex(), row, row);
|
||||||
|
m_sessions.insert(row, session);
|
||||||
|
endInsertRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::moveRow(int from, int to) {
|
||||||
|
if (from < 0 || from >= m_sessions.size() || to < 0 ||
|
||||||
|
to > m_sessions.size() || from == to || to == from + 1)
|
||||||
|
return;
|
||||||
|
if (!beginMoveRows(QModelIndex(), from, from, QModelIndex(), to)) return;
|
||||||
|
m_sessions.move(from, to > from ? to - 1 : to);
|
||||||
|
endMoveRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::reposition(ChatSession* session) {
|
||||||
|
if (m_loading) return;
|
||||||
|
const int row = rowOf(session);
|
||||||
|
if (row < 0) return;
|
||||||
|
const int target = sortedRow(session);
|
||||||
|
if (target == row) return;
|
||||||
|
moveRow(row, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatSession* ChatStore::draft() {
|
||||||
|
if (m_draft) return m_draft;
|
||||||
|
const qint64 now = QDateTime::currentMSecsSinceEpoch();
|
||||||
|
m_draft = new ChatSession(this, QUuid::createUuid().toString(), this);
|
||||||
|
m_draft->hydrate(QString(), QString(), now, now, 0, false);
|
||||||
|
return m_draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::commitDraft(ChatSession* session) {
|
||||||
|
if (!session || session->isCommitted()) return;
|
||||||
|
if (m_draft == session) m_draft = nullptr;
|
||||||
|
|
||||||
|
if (!writeSessionRow(session)) return;
|
||||||
|
session->setCommitted(true);
|
||||||
|
insertRow(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatStore::writeSessionRow(ChatSession* session) {
|
||||||
|
QSqlQuery query(db());
|
||||||
|
query.prepare(
|
||||||
|
"INSERT INTO sessions (id, title, icon, created_at, updated_at, "
|
||||||
|
"pinned) VALUES (:id, :title, :icon, :created_at, :updated_at, "
|
||||||
|
":pinned)");
|
||||||
|
query.bindValue(":id", session->id());
|
||||||
|
query.bindValue(":title", sqlText(session->title()));
|
||||||
|
query.bindValue(":icon", sqlText(session->icon()));
|
||||||
|
query.bindValue(":created_at", session->createdAtMs());
|
||||||
|
query.bindValue(":updated_at", session->updatedAtMs());
|
||||||
|
query.bindValue(":pinned", session->pinned() ? 1 : 0);
|
||||||
|
if (!query.exec()) {
|
||||||
|
qWarning() << "ChatStore: failed to insert session" << session->id()
|
||||||
|
<< ":" << query.lastError().text();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::removeRow(ChatSession* session) {
|
||||||
|
if (!session) return;
|
||||||
|
const int row = rowOf(session);
|
||||||
|
|
||||||
|
Q_EMIT sessionRemoved(session);
|
||||||
|
|
||||||
|
{
|
||||||
|
QSqlQuery query(db());
|
||||||
|
query.prepare("DELETE FROM sessions WHERE id = :id");
|
||||||
|
query.bindValue(":id", session->id());
|
||||||
|
query.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row >= 0) {
|
||||||
|
beginRemoveRows(QModelIndex(), row, row);
|
||||||
|
m_sessions.removeAt(row);
|
||||||
|
endRemoveRows();
|
||||||
|
}
|
||||||
|
session->deleteLater();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::remove(ChatSession* chat) {
|
||||||
|
if (!chat) return;
|
||||||
|
m_pendingPersists.remove(chat);
|
||||||
|
m_pendingClears.remove(chat);
|
||||||
|
if (m_draft == chat) m_draft = nullptr;
|
||||||
|
|
||||||
|
if (!chat->isCommitted()) {
|
||||||
|
Q_EMIT sessionRemoved(chat);
|
||||||
|
chat->deleteLater();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRow(chat);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::clear() {
|
||||||
|
const QList<ChatSession*> sessions = m_sessions;
|
||||||
|
for (ChatSession* session : sessions)
|
||||||
|
remove(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::requestClear(ChatSession* session) {
|
||||||
|
if (!session) return;
|
||||||
|
m_pendingClears.insert(session);
|
||||||
|
if (session->isLoaded() && m_client->streamingSession() != session)
|
||||||
|
applyPendingClear(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::applyPendingClear(ChatSession* session) {
|
||||||
|
if (!session || !m_pendingClears.remove(session)) return;
|
||||||
|
if (!session->isLoaded()) return;
|
||||||
|
session->clearMessages();
|
||||||
|
persist(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::persist(ChatSession* session) {
|
||||||
|
if (!session || !m_sessions.contains(session)) return;
|
||||||
|
if (!session->isLoaded()) {
|
||||||
|
m_pendingPersists.insert(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session->setUpdatedAt(QDateTime::currentMSecsSinceEpoch());
|
||||||
|
if (!saveSession(session)) return;
|
||||||
|
reposition(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::saveFlags(ChatSession* session) {
|
||||||
|
if (!session || !m_sessions.contains(session)) return;
|
||||||
|
QSqlQuery query(db());
|
||||||
|
query.prepare(
|
||||||
|
"UPDATE sessions SET title = :title, icon = :icon, "
|
||||||
|
"pinned = :pinned WHERE id = :id");
|
||||||
|
query.bindValue(":title", sqlText(session->title()));
|
||||||
|
query.bindValue(":icon", sqlText(session->icon()));
|
||||||
|
query.bindValue(":pinned", session->pinned() ? 1 : 0);
|
||||||
|
query.bindValue(":id", session->id());
|
||||||
|
if (!query.exec())
|
||||||
|
qWarning() << "ChatStore: failed to save flags for" << session->id()
|
||||||
|
<< ":" << query.lastError().text();
|
||||||
|
reposition(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatStore::saveSession(ChatSession* session) {
|
||||||
|
const QString id = session->id();
|
||||||
|
QSqlDatabase handle = db();
|
||||||
|
if (!handle.transaction()) {
|
||||||
|
qWarning() << "ChatStore: failed to begin transaction:"
|
||||||
|
<< handle.lastError().text();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool ok = true;
|
||||||
|
{
|
||||||
|
QSqlQuery query(handle);
|
||||||
|
query.prepare(
|
||||||
|
"UPDATE sessions SET title = :title, icon = :icon, "
|
||||||
|
"updated_at = :updated_at, pinned = :pinned WHERE id = :id");
|
||||||
|
query.bindValue(":title", sqlText(session->title()));
|
||||||
|
query.bindValue(":icon", sqlText(session->icon()));
|
||||||
|
query.bindValue(":updated_at", session->updatedAtMs());
|
||||||
|
query.bindValue(":pinned", session->pinned() ? 1 : 0);
|
||||||
|
query.bindValue(":id", session->id());
|
||||||
|
ok = query.exec();
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
QSqlQuery query(handle);
|
||||||
|
query.prepare("DELETE FROM messages WHERE session_id = :id");
|
||||||
|
query.bindValue(":id", session->id());
|
||||||
|
ok = query.exec();
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
QSqlQuery messageInsert(handle);
|
||||||
|
ok = messageInsert.prepare(
|
||||||
|
"INSERT INTO messages (session_id, role, content, timestamp) "
|
||||||
|
"VALUES (:id, :role, :content, :timestamp)");
|
||||||
|
QSqlQuery generationInsert(handle);
|
||||||
|
ok = ok &&
|
||||||
|
generationInsert.prepare(
|
||||||
|
"INSERT INTO generations (message_id, timestamp, is_active) "
|
||||||
|
"VALUES (:mid, :timestamp, :is_active)");
|
||||||
|
QSqlQuery segmentInsert(handle);
|
||||||
|
ok = ok &&
|
||||||
|
segmentInsert.prepare(
|
||||||
|
"INSERT INTO segments (generation_id, type, text, name, "
|
||||||
|
"tool_call_id, arguments, result, status, elapsed_ms, "
|
||||||
|
"timestamp) VALUES (:gid, :type, :text, :name, "
|
||||||
|
":tool_call_id, :arguments, :result, :status, :elapsed_ms, "
|
||||||
|
":timestamp)");
|
||||||
|
QSqlQuery attachmentInsert(handle);
|
||||||
|
ok = ok && attachmentInsert.prepare(
|
||||||
|
"INSERT INTO attachments (message_id, name, path, kind, "
|
||||||
|
"mime, size) VALUES (:mid, :name, :path, :kind, :mime, "
|
||||||
|
":size)");
|
||||||
|
const auto* model = session->messagesModel();
|
||||||
|
for (int row = model->rowCount() - 1; ok && row >= 0; --row) {
|
||||||
|
const auto* message = model->at(row);
|
||||||
|
messageInsert.bindValue(":id", session->id());
|
||||||
|
messageInsert.bindValue(
|
||||||
|
":role",
|
||||||
|
message->role() == ChatMessage::Role::User
|
||||||
|
? QStringLiteral("user")
|
||||||
|
: QStringLiteral("assistant"));
|
||||||
|
messageInsert.bindValue(":content", sqlText(message->content()));
|
||||||
|
messageInsert.bindValue(":timestamp", message->timestamp());
|
||||||
|
if (!messageInsert.exec()) {
|
||||||
|
ok = false;
|
||||||
|
qWarning() << "ChatStore: saveSession" << id
|
||||||
|
<< "message insert failed:"
|
||||||
|
<< messageInsert.lastError().text();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const int messageId = messageInsert.lastInsertId().toInt();
|
||||||
|
for (int i = 0; ok && i < message->generationCount(); ++i) {
|
||||||
|
const auto* generation = message->generation(i);
|
||||||
|
generationInsert.bindValue(":mid", messageId);
|
||||||
|
generationInsert.bindValue(
|
||||||
|
":timestamp", generation->timestamp());
|
||||||
|
generationInsert.bindValue(
|
||||||
|
":is_active",
|
||||||
|
i == message->activeGenerationIndex() ? 1 : 0);
|
||||||
|
if (!generationInsert.exec()) {
|
||||||
|
ok = false;
|
||||||
|
qWarning() << "ChatStore: saveSession" << id
|
||||||
|
<< "generation insert failed:"
|
||||||
|
<< generationInsert.lastError().text();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const int generationId =
|
||||||
|
generationInsert.lastInsertId().toInt();
|
||||||
|
for (const auto* segment : generation->segments()) {
|
||||||
|
segmentInsert.bindValue(":gid", generationId);
|
||||||
|
segmentInsert.bindValue(
|
||||||
|
":type", segmentTypeName(segment->type()));
|
||||||
|
const auto* text =
|
||||||
|
qobject_cast<const LlmTextSegment*>(segment);
|
||||||
|
segmentInsert.bindValue(
|
||||||
|
":text", sqlText(text ? text->text() : QString()));
|
||||||
|
const auto* call =
|
||||||
|
qobject_cast<const LlmToolCall*>(segment);
|
||||||
|
segmentInsert.bindValue(
|
||||||
|
":name", sqlText(call ? call->name() : QString()));
|
||||||
|
segmentInsert.bindValue(
|
||||||
|
":tool_call_id",
|
||||||
|
sqlText(call ? call->toolCallId() : QString()));
|
||||||
|
segmentInsert.bindValue(
|
||||||
|
":arguments",
|
||||||
|
sqlText(call ? call->arguments() : QString()));
|
||||||
|
segmentInsert.bindValue(
|
||||||
|
":result", sqlText(call ? call->result() : QString()));
|
||||||
|
segmentInsert.bindValue(
|
||||||
|
":status", call ? static_cast<int>(call->status()) : 0);
|
||||||
|
segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs());
|
||||||
|
segmentInsert.bindValue(":timestamp", segment->timestamp());
|
||||||
|
if (!segmentInsert.exec()) {
|
||||||
|
ok = false;
|
||||||
|
qWarning() << "ChatStore: saveSession" << id
|
||||||
|
<< "segment insert failed:"
|
||||||
|
<< segmentInsert.lastError().text();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ok)
|
||||||
|
for (const auto* attachment : message->attachments()) {
|
||||||
|
attachmentInsert.bindValue(":mid", messageId);
|
||||||
|
attachmentInsert.bindValue(
|
||||||
|
":name", sqlText(attachment->name()));
|
||||||
|
attachmentInsert.bindValue(
|
||||||
|
":path", sqlText(attachment->path()));
|
||||||
|
attachmentInsert.bindValue(
|
||||||
|
":kind", static_cast<int>(attachment->kind()));
|
||||||
|
attachmentInsert.bindValue(
|
||||||
|
":mime", sqlText(attachment->mimeType()));
|
||||||
|
attachmentInsert.bindValue(":size", attachment->size());
|
||||||
|
if (!attachmentInsert.exec()) {
|
||||||
|
ok = false;
|
||||||
|
qWarning() << "ChatStore: saveSession" << id
|
||||||
|
<< "attachment insert failed:"
|
||||||
|
<< attachmentInsert.lastError().text();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ok || !handle.commit()) {
|
||||||
|
qWarning() << "ChatStore: saveSession" << id
|
||||||
|
<< "commit failed, rolling back";
|
||||||
|
handle.rollback();
|
||||||
|
qWarning() << "ChatStore: failed to save session" << session->id()
|
||||||
|
<< ":" << handle.lastError().text();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::loadMessagesInto(ChatSession* session) {
|
||||||
|
if (!session) return;
|
||||||
|
const QString sessionId = session->id();
|
||||||
|
const QString path = m_dbPath;
|
||||||
|
|
||||||
|
QThreadPool::globalInstance()->start([store = QPointer<ChatStore>(this),
|
||||||
|
session =
|
||||||
|
QPointer<ChatSession>(session),
|
||||||
|
sessionId,
|
||||||
|
path]() {
|
||||||
|
QList<MessageRow> rows;
|
||||||
|
const QString connName = QUuid::createUuid().toString();
|
||||||
|
{
|
||||||
|
QSqlDatabase handle =
|
||||||
|
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName);
|
||||||
|
handle.setDatabaseName(path);
|
||||||
|
if (handle.open()) {
|
||||||
|
QSqlQuery busy(handle);
|
||||||
|
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
|
||||||
|
QSqlQuery query(handle);
|
||||||
|
query.prepare(
|
||||||
|
"SELECT id, role, content, timestamp FROM messages "
|
||||||
|
"WHERE session_id = :id ORDER BY rowid DESC");
|
||||||
|
query.bindValue(":id", sessionId);
|
||||||
|
if (!query.exec()) {
|
||||||
|
qWarning() << "ChatStore: failed to load messages for"
|
||||||
|
<< sessionId << ":" << query.lastError().text();
|
||||||
|
} else {
|
||||||
|
while (query.next()) {
|
||||||
|
const int messageId = query.value(0).toInt();
|
||||||
|
MessageRow message;
|
||||||
|
message.user = query.value(1).toString() ==
|
||||||
|
QLatin1String("user");
|
||||||
|
message.content = query.value(2).toString();
|
||||||
|
message.timestamp = query.value(3).toLongLong();
|
||||||
|
QSqlQuery generationQuery(handle);
|
||||||
|
generationQuery.prepare(
|
||||||
|
"SELECT id, timestamp, is_active FROM "
|
||||||
|
"generations WHERE message_id = :mid "
|
||||||
|
"ORDER BY rowid");
|
||||||
|
generationQuery.bindValue(":mid", messageId);
|
||||||
|
if (generationQuery.exec()) {
|
||||||
|
while (generationQuery.next()) {
|
||||||
|
GenerationRow generation;
|
||||||
|
generation.timestamp =
|
||||||
|
generationQuery.value(1).toLongLong();
|
||||||
|
generation.active =
|
||||||
|
generationQuery.value(2).toInt() != 0;
|
||||||
|
QSqlQuery segmentQuery(handle);
|
||||||
|
segmentQuery.prepare(
|
||||||
|
"SELECT type, text, name, tool_call_id, "
|
||||||
|
"arguments, result, status, elapsed_ms, "
|
||||||
|
"timestamp FROM segments WHERE "
|
||||||
|
"generation_id = :gid ORDER BY rowid");
|
||||||
|
segmentQuery.bindValue(
|
||||||
|
":gid", generationQuery.value(0).toInt());
|
||||||
|
if (segmentQuery.exec()) {
|
||||||
|
while (segmentQuery.next()) {
|
||||||
|
SegmentRow segment;
|
||||||
|
segment.type =
|
||||||
|
segmentQuery.value(0).toString();
|
||||||
|
segment.text =
|
||||||
|
segmentQuery.value(1).toString();
|
||||||
|
segment.name =
|
||||||
|
segmentQuery.value(2).toString();
|
||||||
|
segment.toolCallId =
|
||||||
|
segmentQuery.value(3).toString();
|
||||||
|
segment.arguments =
|
||||||
|
segmentQuery.value(4).toString();
|
||||||
|
segment.result =
|
||||||
|
segmentQuery.value(5).toString();
|
||||||
|
segment.status =
|
||||||
|
segmentQuery.value(6).toInt();
|
||||||
|
segment.elapsedMs =
|
||||||
|
segmentQuery.value(7).toLongLong();
|
||||||
|
segment.timestamp =
|
||||||
|
segmentQuery.value(8).toLongLong();
|
||||||
|
generation.segments.append(segment);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning()
|
||||||
|
<< "ChatStore: failed to load "
|
||||||
|
"segments for generation"
|
||||||
|
<< generationQuery.value(0).toInt()
|
||||||
|
<< ":"
|
||||||
|
<< segmentQuery.lastError().text();
|
||||||
|
}
|
||||||
|
message.generations.append(generation);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning()
|
||||||
|
<< "ChatStore: failed to load generations "
|
||||||
|
"for message"
|
||||||
|
<< messageId << ":"
|
||||||
|
<< generationQuery.lastError().text();
|
||||||
|
}
|
||||||
|
QSqlQuery attachmentQuery(handle);
|
||||||
|
attachmentQuery.prepare(
|
||||||
|
"SELECT name, path, kind, mime, size FROM "
|
||||||
|
"attachments WHERE message_id = :mid "
|
||||||
|
"ORDER BY rowid");
|
||||||
|
attachmentQuery.bindValue(":mid", messageId);
|
||||||
|
if (attachmentQuery.exec()) {
|
||||||
|
while (attachmentQuery.next()) {
|
||||||
|
AttachmentRow attachment;
|
||||||
|
attachment.name =
|
||||||
|
attachmentQuery.value(0).toString();
|
||||||
|
attachment.path =
|
||||||
|
attachmentQuery.value(1).toString();
|
||||||
|
attachment.kind =
|
||||||
|
attachmentQuery.value(2).toInt();
|
||||||
|
attachment.mime =
|
||||||
|
attachmentQuery.value(3).toString();
|
||||||
|
attachment.size =
|
||||||
|
attachmentQuery.value(4).toLongLong();
|
||||||
|
message.attachments.append(attachment);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qWarning()
|
||||||
|
<< "ChatStore: failed to load attachments "
|
||||||
|
"for message"
|
||||||
|
<< messageId << ":"
|
||||||
|
<< attachmentQuery.lastError().text();
|
||||||
|
}
|
||||||
|
rows.append(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handle.close();
|
||||||
|
} else {
|
||||||
|
qWarning() << "ChatStore: failed to open database for load:"
|
||||||
|
<< handle.lastError().text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QSqlDatabase::removeDatabase(connName);
|
||||||
|
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
QCoreApplication::instance(),
|
||||||
|
[store, session, rows = std::move(rows)]() mutable {
|
||||||
|
ChatStore* st = store;
|
||||||
|
ChatSession* s = session;
|
||||||
|
if (!st || !s) return;
|
||||||
|
|
||||||
|
auto* model = s->messagesModel();
|
||||||
|
if (!model) return;
|
||||||
|
QList<ChatMessage*> messages;
|
||||||
|
for (const MessageRow& row : rows) {
|
||||||
|
auto* message = model->createMessage(
|
||||||
|
row.user ? ChatMessage::Role::User
|
||||||
|
: ChatMessage::Role::Assistant,
|
||||||
|
row.timestamp);
|
||||||
|
|
||||||
|
if (!row.attachments.isEmpty()) {
|
||||||
|
QList<Attachment*> attachments;
|
||||||
|
for (const AttachmentRow& attachmentRow :
|
||||||
|
row.attachments) {
|
||||||
|
attachments.append(new Attachment(
|
||||||
|
attachmentRow.name,
|
||||||
|
attachmentRow.path,
|
||||||
|
attachmentRow.kind == 0
|
||||||
|
? Attachment::Kind::Image
|
||||||
|
: Attachment::Kind::File,
|
||||||
|
attachmentRow.mime,
|
||||||
|
attachmentRow.size,
|
||||||
|
message));
|
||||||
|
}
|
||||||
|
message->setAttachments(attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.user) {
|
||||||
|
QString content = row.content;
|
||||||
|
if (content.isEmpty() && !row.generations.isEmpty()) {
|
||||||
|
for (const SegmentRow& segment :
|
||||||
|
row.generations.first().segments)
|
||||||
|
if (segmentTypeFromName(segment.type) ==
|
||||||
|
LlmSegment::Type::Content) {
|
||||||
|
content = segment.text;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!content.isEmpty()) message->setContent(content);
|
||||||
|
messages.append(message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int activeIndex = 0;
|
||||||
|
for (int i = 0; i < row.generations.size(); ++i) {
|
||||||
|
const GenerationRow& generationRow =
|
||||||
|
row.generations.at(i);
|
||||||
|
auto* generation =
|
||||||
|
message->addGeneration(generationRow.timestamp);
|
||||||
|
if (!generation) continue;
|
||||||
|
for (const SegmentRow& segmentRow :
|
||||||
|
generationRow.segments) {
|
||||||
|
const LlmSegment::Type type =
|
||||||
|
segmentTypeFromName(segmentRow.type);
|
||||||
|
LlmSegment* segment = nullptr;
|
||||||
|
if (type == LlmSegment::Type::ToolCall) {
|
||||||
|
auto* call = new LlmToolCall(
|
||||||
|
segmentRow.timestamp, generation);
|
||||||
|
call->setName(segmentRow.name);
|
||||||
|
call->setToolCallId(segmentRow.toolCallId);
|
||||||
|
call->appendArguments(segmentRow.arguments);
|
||||||
|
call->setResult(segmentRow.result);
|
||||||
|
call->setStatus(
|
||||||
|
static_cast<LlmToolCall::Status>(
|
||||||
|
segmentRow.status));
|
||||||
|
call->restore(segmentRow.elapsedMs);
|
||||||
|
segment = call;
|
||||||
|
} else {
|
||||||
|
auto* text = new LlmTextSegment(
|
||||||
|
type, segmentRow.timestamp, generation);
|
||||||
|
text->setText(segmentRow.text);
|
||||||
|
text->restore(segmentRow.elapsedMs);
|
||||||
|
segment = text;
|
||||||
|
}
|
||||||
|
generation->addSegment(segment);
|
||||||
|
}
|
||||||
|
if (generationRow.active) activeIndex = i;
|
||||||
|
}
|
||||||
|
message->setActiveGeneration(activeIndex);
|
||||||
|
messages.append(message);
|
||||||
|
}
|
||||||
|
if (model->rowCount() > 0) {
|
||||||
|
QList<ChatMessage*> live = messages;
|
||||||
|
for (int r = 0; r < model->rowCount(); ++r)
|
||||||
|
live.prepend(model->at(r));
|
||||||
|
messages = live;
|
||||||
|
}
|
||||||
|
if (!messages.isEmpty() || model->rowCount() > 0)
|
||||||
|
s->adoptMessages(messages);
|
||||||
|
|
||||||
|
s->markLoaded();
|
||||||
|
|
||||||
|
st->applyPendingClear(s);
|
||||||
|
if (st->m_pendingPersists.remove(s)) st->persist(s);
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatStore::load() {
|
||||||
|
m_loading = true;
|
||||||
|
QSqlQuery query(db());
|
||||||
|
query.exec(
|
||||||
|
"SELECT s.id, s.title, s.icon, s.created_at, s.updated_at, s.pinned, "
|
||||||
|
"(SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS cnt "
|
||||||
|
"FROM sessions s ORDER BY s.pinned DESC, s.updated_at DESC");
|
||||||
|
while (query.next()) {
|
||||||
|
auto* session = new ChatSession(this, query.value(0).toString(), this);
|
||||||
|
session->hydrate(
|
||||||
|
query.value(1).toString(),
|
||||||
|
query.value(2).toString(),
|
||||||
|
query.value(3).toLongLong(),
|
||||||
|
query.value(4).toLongLong(),
|
||||||
|
query.value(6).toInt(),
|
||||||
|
query.value(5).toInt() != 0);
|
||||||
|
m_sessions.append(session);
|
||||||
|
}
|
||||||
|
m_loading = false;
|
||||||
|
|
||||||
|
beginResetModel();
|
||||||
|
endResetModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "session.hpp"
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QList>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QSqlDatabase>
|
||||||
|
#include <QString>
|
||||||
|
#include <QtQml>
|
||||||
|
|
||||||
|
namespace ZShell::llm {
|
||||||
|
|
||||||
|
class LlmClient;
|
||||||
|
|
||||||
|
class ChatStore : public QAbstractListModel {
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_UNCREATABLE("Chat sessions are owned by the LlmClient singleton")
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ChatStore(LlmClient* client, QObject* parent = nullptr);
|
||||||
|
~ChatStore() override;
|
||||||
|
|
||||||
|
[[nodiscard]] int rowCount(
|
||||||
|
const QModelIndex& parent = QModelIndex()) const override;
|
||||||
|
[[nodiscard]] QVariant data(
|
||||||
|
const QModelIndex& index, int role = Qt::DisplayRole) const override;
|
||||||
|
[[nodiscard]] QHash<int, QByteArray> roleNames() const override;
|
||||||
|
|
||||||
|
[[nodiscard]] LlmClient* client() const { return m_client; }
|
||||||
|
|
||||||
|
[[nodiscard]] int rowOf(const ChatSession* session) const;
|
||||||
|
|
||||||
|
Q_INVOKABLE ZShell::llm::ChatSession* draft();
|
||||||
|
Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat);
|
||||||
|
Q_INVOKABLE void clear();
|
||||||
|
|
||||||
|
void commitDraft(ZShell::llm::ChatSession* session);
|
||||||
|
|
||||||
|
void persist(ZShell::llm::ChatSession* session);
|
||||||
|
void saveFlags(ZShell::llm::ChatSession* session);
|
||||||
|
void loadMessagesInto(ZShell::llm::ChatSession* session);
|
||||||
|
|
||||||
|
void requestClear(ZShell::llm::ChatSession* session);
|
||||||
|
void applyPendingClear(ZShell::llm::ChatSession* session);
|
||||||
|
|
||||||
|
void reposition(ZShell::llm::ChatSession* session);
|
||||||
|
|
||||||
|
Q_SIGNALS:
|
||||||
|
void sessionRemoved(ZShell::llm::ChatSession* session);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void openDb();
|
||||||
|
void load();
|
||||||
|
[[nodiscard]] int sortedRow(const ChatSession* session) const;
|
||||||
|
[[nodiscard]] static bool firstAfter(
|
||||||
|
const ChatSession* a, const ChatSession* b);
|
||||||
|
void insertRow(ChatSession* session);
|
||||||
|
void moveRow(int from, int to);
|
||||||
|
void removeRow(ChatSession* session);
|
||||||
|
[[nodiscard]] bool saveSession(ChatSession* session);
|
||||||
|
[[nodiscard]] bool writeSessionRow(ChatSession* session);
|
||||||
|
[[nodiscard]] QSqlDatabase db() const;
|
||||||
|
|
||||||
|
LlmClient* m_client;
|
||||||
|
QList<ChatSession*> m_sessions;
|
||||||
|
ChatSession* m_draft = nullptr;
|
||||||
|
QString m_connectionName;
|
||||||
|
QString m_dbPath;
|
||||||
|
QSet<ChatSession*> m_pendingPersists;
|
||||||
|
QSet<ChatSession*> m_pendingClears;
|
||||||
|
bool m_loading = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#include "clipboard.hpp"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QClipboard>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QImage>
|
||||||
|
#include <QMimeData>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
namespace ZShell::llm::clipboard {
|
||||||
|
|
||||||
|
bool hasAttachment() {
|
||||||
|
QClipboard* clipboard = QGuiApplication::clipboard();
|
||||||
|
if (!clipboard) return false;
|
||||||
|
const QMimeData* data = clipboard->mimeData();
|
||||||
|
if (!data) return false;
|
||||||
|
if (data->hasImage()) return true;
|
||||||
|
for (const QUrl& url : data->urls())
|
||||||
|
if (url.isLocalFile()) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList attachmentPaths(QString& error) {
|
||||||
|
error.clear();
|
||||||
|
QClipboard* clipboard = QGuiApplication::clipboard();
|
||||||
|
const QMimeData* data = clipboard ? clipboard->mimeData() : nullptr;
|
||||||
|
if (!data) {
|
||||||
|
qWarning() << "clipboard: no clipboard data";
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList paths;
|
||||||
|
if (data->hasImage()) {
|
||||||
|
const QImage image = clipboard->image();
|
||||||
|
if (image.isNull()) {
|
||||||
|
error = QStringLiteral("Clipboard image could not be decoded");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const QString tempPath =
|
||||||
|
QDir::tempPath() + QStringLiteral("/zshell-pasted-image-%1.png")
|
||||||
|
.arg(QDateTime::currentMSecsSinceEpoch());
|
||||||
|
if (!image.save(tempPath)) {
|
||||||
|
error = QStringLiteral("Could not save the pasted image");
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
paths.append(tempPath);
|
||||||
|
} else if (data->hasUrls()) {
|
||||||
|
for (const QUrl& url : data->urls())
|
||||||
|
if (url.isLocalFile()) paths.append(url.toLocalFile());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paths.isEmpty() && (data->hasImage() || data->hasUrls()))
|
||||||
|
error = QStringLiteral(
|
||||||
|
"Nothing usable on the clipboard (expected an image "
|
||||||
|
"or copied files)");
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ZShell::llm::clipboard
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
namespace ZShell::llm::clipboard {
|
||||||
|
|
||||||
|
[[nodiscard]] bool hasAttachment();
|
||||||
|
|
||||||
|
[[nodiscard]] QStringList attachmentPaths(QString& error);
|
||||||
|
|
||||||
|
} // namespace ZShell::llm::clipboard
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
#include "codehighlighter.hpp"
|
||||||
|
|
||||||
|
#include "highlight-queries.hpp"
|
||||||
|
|
||||||
|
#include <tree_sitter/api.h>
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QMap>
|
||||||
|
#include <QMutexLocker>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QThreadPool>
|
||||||
|
#include <QVariantMap>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
|
||||||
|
namespace ZShell::llm {
|
||||||
|
|
||||||
|
namespace hl {
|
||||||
|
|
||||||
|
enum Role : uint8_t {
|
||||||
|
None = 0,
|
||||||
|
Comment,
|
||||||
|
String,
|
||||||
|
StringKey,
|
||||||
|
Number,
|
||||||
|
Constant,
|
||||||
|
Keyword,
|
||||||
|
Type,
|
||||||
|
Function,
|
||||||
|
Method,
|
||||||
|
Macro,
|
||||||
|
Preproc,
|
||||||
|
Operator,
|
||||||
|
Property,
|
||||||
|
Label,
|
||||||
|
Attribute,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using LanguageFn = const TSLanguage* (*)();
|
||||||
|
|
||||||
|
const QHash<QString, CodeHighlighter::Grammar>& grammars() {
|
||||||
|
static const QHash<QString, CodeHighlighter::Grammar> grammars = [] {
|
||||||
|
QHash<QString, CodeHighlighter::Grammar> map;
|
||||||
|
for (const auto& g : hq::grammars) {
|
||||||
|
CodeHighlighter::Grammar grammar;
|
||||||
|
for (int i = 0; i < g.nCandidates; ++i) {
|
||||||
|
grammar.libs.push_back(g.candidates[i].lib);
|
||||||
|
grammar.symbols.push_back(g.candidates[i].symbol);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < g.nQueries; ++i)
|
||||||
|
grammar.queries.push_back(g.queries[i]);
|
||||||
|
map.insert(g.id, std::move(grammar));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}();
|
||||||
|
return grammars;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace hl
|
||||||
|
|
||||||
|
CodeHighlighter* CodeHighlighter::s_instance = nullptr;
|
||||||
|
|
||||||
|
const QHash<QString, QString>& CodeHighlighter::aliases() {
|
||||||
|
static const QHash<QString, QString> aliases = [] {
|
||||||
|
QHash<QString, QString> map;
|
||||||
|
map.insert("c", "c");
|
||||||
|
map.insert("h", "c");
|
||||||
|
map.insert("cpp", "cpp");
|
||||||
|
map.insert("c++", "cpp");
|
||||||
|
map.insert("cc", "cpp");
|
||||||
|
map.insert("cxx", "cpp");
|
||||||
|
map.insert("h++", "cpp");
|
||||||
|
map.insert("hpp", "cpp");
|
||||||
|
map.insert("hh", "cpp");
|
||||||
|
map.insert("python", "python");
|
||||||
|
map.insert("py", "python");
|
||||||
|
map.insert("javascript", "javascript");
|
||||||
|
map.insert("js", "javascript");
|
||||||
|
map.insert("jsx", "javascript");
|
||||||
|
map.insert("mjs", "javascript");
|
||||||
|
map.insert("cjs", "javascript");
|
||||||
|
map.insert("typescript", "typescript");
|
||||||
|
map.insert("ts", "typescript");
|
||||||
|
map.insert("mts", "typescript");
|
||||||
|
map.insert("cts", "typescript");
|
||||||
|
map.insert("tsx", "tsx");
|
||||||
|
map.insert("bash", "bash");
|
||||||
|
map.insert("sh", "bash");
|
||||||
|
map.insert("shell", "bash");
|
||||||
|
map.insert("shellscript", "bash");
|
||||||
|
map.insert("shell-session", "bash");
|
||||||
|
map.insert("zsh", "bash");
|
||||||
|
map.insert("console", "bash");
|
||||||
|
map.insert("qml", "qmljs");
|
||||||
|
map.insert("qmljs", "qmljs");
|
||||||
|
map.insert("json", "json");
|
||||||
|
map.insert("jsonc", "json");
|
||||||
|
map.insert("rust", "rust");
|
||||||
|
map.insert("rs", "rust");
|
||||||
|
map.insert("go", "go");
|
||||||
|
map.insert("golang", "go");
|
||||||
|
map.insert("yaml", "yaml");
|
||||||
|
map.insert("yml", "yaml");
|
||||||
|
map.insert("toml", "toml");
|
||||||
|
map.insert("sql", "sql");
|
||||||
|
map.insert("mysql", "sql");
|
||||||
|
map.insert("postgres", "sql");
|
||||||
|
map.insert("postgresql", "sql");
|
||||||
|
map.insert("sqlite", "sql");
|
||||||
|
map.insert("sqlite3", "sql");
|
||||||
|
return map;
|
||||||
|
}();
|
||||||
|
return 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.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 hl::Role::Constant;
|
||||||
|
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("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"))
|
||||||
|
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.startsWith("attribute") || n == "annotation")
|
||||||
|
return hl::Role::Attribute;
|
||||||
|
if (n == "tag" || (n.startsWith("tag.") && n != "tag.delimiter"))
|
||||||
|
return hl::Role::Keyword;
|
||||||
|
if (n.startsWith("variable")) return hl::Role::Constant;
|
||||||
|
if (n.startsWith("support")) return hl::Role::Function;
|
||||||
|
return hl::Role::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = 64;
|
||||||
|
static constexpr int kMaxBytes = 2 * 1024 * 1024;
|
||||||
|
const QString key = cacheKey(resolveId(language), code);
|
||||||
|
const int bytes = static_cast<int>(code.toUtf8().size());
|
||||||
|
QMutexLocker locker(&m_cacheMutex);
|
||||||
|
auto it = m_spanCache.find(key);
|
||||||
|
if (it != m_spanCache.end()) {
|
||||||
|
m_spanCacheBytes -= static_cast<int>(it->code.toUtf8().size());
|
||||||
|
m_spanCache.erase(it);
|
||||||
|
m_spanCacheOrder.removeAll(key);
|
||||||
|
}
|
||||||
|
while (m_spanCacheOrder.size() >= kMaxEntries ||
|
||||||
|
m_spanCacheBytes + bytes > kMaxBytes) {
|
||||||
|
if (m_spanCacheOrder.isEmpty()) break;
|
||||||
|
const QString oldest = m_spanCacheOrder.takeFirst();
|
||||||
|
m_spanCacheBytes -=
|
||||||
|
static_cast<int>(m_spanCache.value(oldest).code.toUtf8().size());
|
||||||
|
m_spanCache.remove(oldest);
|
||||||
|
}
|
||||||
|
m_spanCache.insert(key, SpanCacheEntry{code, spans});
|
||||||
|
m_spanCacheOrder.append(key);
|
||||||
|
m_spanCacheBytes += bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CodeHighlighter::highlight(
|
||||||
|
const QString& code, const QString& language, QObject* target, int token) {
|
||||||
|
QPointer<QObject> targetGuard(target);
|
||||||
|
const QVariantList cached = lookupSpans(code, language);
|
||||||
|
if (!cached.isEmpty()) {
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
targetGuard,
|
||||||
|
"onHighlightSpans",
|
||||||
|
Qt::DirectConnection,
|
||||||
|
Q_ARG(QVariant, token),
|
||||||
|
Q_ARG(QVariant, cached));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QThreadPool::globalInstance()->start(
|
||||||
|
[this, target, token, code, language]() {
|
||||||
|
const QVariantList spans = doHighlight(code, language);
|
||||||
|
storeSpans(code, language, spans);
|
||||||
|
QPointer<QObject> guard(target);
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
QCoreApplication::instance(),
|
||||||
|
[guard, token, spans]() {
|
||||||
|
if (!guard) return;
|
||||||
|
QMetaObject::invokeMethod(
|
||||||
|
guard,
|
||||||
|
"onHighlightSpans",
|
||||||
|
Q_ARG(QVariant, token),
|
||||||
|
Q_ARG(QVariant, spans));
|
||||||
|
},
|
||||||
|
Qt::QueuedConnection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantList CodeHighlighter::doHighlight(
|
||||||
|
const QString& code, const QString& language) const {
|
||||||
|
QVariantList spans;
|
||||||
|
if (code.isEmpty()) return spans;
|
||||||
|
|
||||||
|
const QString id = resolveId(language);
|
||||||
|
const Grammar& grammar = hl::grammars().value(id);
|
||||||
|
if (grammar.libs.empty()) return spans;
|
||||||
|
|
||||||
|
static constexpr size_t kMaxBytes = 512 * 1024;
|
||||||
|
const QByteArray utf8 = code.toUtf8();
|
||||||
|
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];
|
||||||
|
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;
|
||||||
|
auto* symbol = reinterpret_cast<hl::LanguageFn>(
|
||||||
|
dlsym(lib, grammar.symbols[i].c_str()));
|
||||||
|
if (!symbol) {
|
||||||
|
dlclose(lib);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const TSLanguage* candidate = symbol();
|
||||||
|
const uint32_t version = ts_language_abi_version(candidate);
|
||||||
|
if (version < TREE_SITTER_MIN_COMPATIBLE_LANGUAGE_VERSION ||
|
||||||
|
version > TREE_SITTER_LANGUAGE_VERSION) {
|
||||||
|
dlclose(lib);
|
||||||
|
abiMismatch = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fresh->lib = lib;
|
||||||
|
fresh->lang = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (fresh->lang) {
|
||||||
|
for (const char* source : grammar.queries) {
|
||||||
|
TSQueryError errorType = TSQueryErrorNone;
|
||||||
|
uint32_t errorOffset = 0;
|
||||||
|
TSQuery* candidate = ts_query_new(
|
||||||
|
static_cast<const TSLanguage*>(fresh->lang),
|
||||||
|
source,
|
||||||
|
static_cast<uint32_t>(std::strlen(source)),
|
||||||
|
&errorOffset,
|
||||||
|
&errorType);
|
||||||
|
if (!candidate) continue;
|
||||||
|
fresh->query = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!fresh->query) fresh->bad = true;
|
||||||
|
} else if (abiMismatch) {
|
||||||
|
fresh->bad = true;
|
||||||
|
}
|
||||||
|
if (fresh->lang || fresh->bad) state = std::move(fresh);
|
||||||
|
}
|
||||||
|
if (!state || state->bad || !state->lang) return spans;
|
||||||
|
lang = static_cast<const TSLanguage*>(state->lang);
|
||||||
|
query = static_cast<TSQuery*>(state->query);
|
||||||
|
}
|
||||||
|
|
||||||
|
TSParser* parser = ts_parser_new();
|
||||||
|
ts_parser_set_language(parser, lang);
|
||||||
|
TSTree* tree = ts_parser_parse_string(
|
||||||
|
parser, nullptr, utf8.constData(), static_cast<uint32_t>(utf8.size()));
|
||||||
|
if (!tree) {
|
||||||
|
ts_parser_delete(parser);
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
TSQueryCursor* cursor = ts_query_cursor_new();
|
||||||
|
ts_query_cursor_exec(cursor, query, ts_tree_root_node(tree));
|
||||||
|
|
||||||
|
const uint32_t size = static_cast<uint32_t>(utf8.size());
|
||||||
|
std::vector<uint8_t> kinds(size, 0);
|
||||||
|
|
||||||
|
std::vector<uint32_t> cu(size + 1, 0);
|
||||||
|
for (uint32_t b = 0; b < size; ++b) {
|
||||||
|
cu[b + 1] = cu[b];
|
||||||
|
const unsigned char c = static_cast<unsigned char>(utf8[b]);
|
||||||
|
if (c < 0x80)
|
||||||
|
cu[b + 1] += 1;
|
||||||
|
else if (c < 0xC0)
|
||||||
|
;
|
||||||
|
else if (c < 0xF0)
|
||||||
|
cu[b + 1] += 1;
|
||||||
|
else
|
||||||
|
cu[b + 1] += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
TSQueryMatch match;
|
||||||
|
uint32_t captureIndex = 0;
|
||||||
|
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 uint8_t role = roleFor(name, nameLength);
|
||||||
|
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;
|
||||||
|
std::fill(kinds.begin() + start, kinds.begin() + end, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t position = 0;
|
||||||
|
while (position < size) {
|
||||||
|
if (kinds[position] == 0) {
|
||||||
|
++position;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const uint8_t role = kinds[position];
|
||||||
|
const uint32_t start = position;
|
||||||
|
while (position < size && kinds[position] == role)
|
||||||
|
++position;
|
||||||
|
QVariantMap span;
|
||||||
|
span.insert("start", static_cast<int>(cu[start]));
|
||||||
|
span.insert("length", static_cast<int>(cu[position] - cu[start]));
|
||||||
|
span.insert("kind", roleName(role));
|
||||||
|
spans.append(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
ts_query_cursor_delete(cursor);
|
||||||
|
ts_tree_delete(tree);
|
||||||
|
ts_parser_delete(parser);
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
CodeHighlighter* CodeHighlighter::create(QQmlEngine*, QJSEngine*) {
|
||||||
|
if (!s_instance) s_instance = new CodeHighlighter();
|
||||||
|
return s_instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QMutex>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QVariantList>
|
||||||
|
#include <QtQml>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
class QQmlEngine;
|
||||||
|
class QJSEngine;
|
||||||
|
|
||||||
|
namespace ZShell::llm {
|
||||||
|
|
||||||
|
class CodeHighlighter : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
QML_ELEMENT
|
||||||
|
QML_SINGLETON
|
||||||
|
|
||||||
|
public:
|
||||||
|
Q_INVOKABLE void highlight(
|
||||||
|
const QString& code,
|
||||||
|
const QString& language,
|
||||||
|
QObject* target,
|
||||||
|
int token);
|
||||||
|
|
||||||
|
static CodeHighlighter* create(QQmlEngine*, QJSEngine*);
|
||||||
|
|
||||||
|
struct Grammar {
|
||||||
|
std::vector<std::string> libs;
|
||||||
|
std::vector<std::string> symbols;
|
||||||
|
std::vector<const char*> queries;
|
||||||
|
};
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct State {
|
||||||
|
bool bad = false;
|
||||||
|
void* lib = nullptr;
|
||||||
|
const void* lang = nullptr;
|
||||||
|
void* query = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] static const QHash<QString, QString>& aliases();
|
||||||
|
[[nodiscard]] static uint8_t roleFor(const char* name, uint32_t length);
|
||||||
|
[[nodiscard]] static const char* roleName(uint8_t role);
|
||||||
|
[[nodiscard]] static QString resolveId(const QString& language);
|
||||||
|
[[nodiscard]] static QString cacheKey(
|
||||||
|
const QString& id, const QString& code);
|
||||||
|
[[nodiscard]] QVariantList doHighlight(
|
||||||
|
const QString& code, const QString& language) const;
|
||||||
|
[[nodiscard]] QVariantList lookupSpans(
|
||||||
|
const QString& code, const QString& language) const;
|
||||||
|
void storeSpans(
|
||||||
|
const QString& code,
|
||||||
|
const QString& language,
|
||||||
|
const QVariantList& spans) const;
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#include "filetool.hpp"
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonArray>
|
||||||
|
|
||||||
|
namespace ZShell::llm {
|
||||||
|
|
||||||
|
FileReadTool::FileReadTool(ToolRegistry* registry, QObject* parent)
|
||||||
|
: LlmTool(registry, 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 saved in the sandbox directory 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 ToolContext& context, const QJsonObject& args, ToolDone done) {
|
||||||
|
const QString path = args[QStringLiteral("path")].toString().trimmed();
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
done({false, QStringLiteral("Missing required argument 'path'")});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0));
|
||||||
|
const int budget = context.outputBudgetChars > 0
|
||||||
|
? context.outputBudgetChars
|
||||||
|
: registry()->outputBudgetChars(1);
|
||||||
|
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(
|
||||||
|
{false,
|
||||||
|
QStringLiteral("Path is a directory, not a file: %1")
|
||||||
|
.arg(path)});
|
||||||
|
} else {
|
||||||
|
done({false, QStringLiteral("Cannot open file: %1").arg(path)});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const qint64 fileSize = file.size();
|
||||||
|
if (fileSize == 0) {
|
||||||
|
file.close();
|
||||||
|
done({true, QString()});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (offset >= fileSize) {
|
||||||
|
file.close();
|
||||||
|
done(
|
||||||
|
{false,
|
||||||
|
QStringLiteral("Offset %1 is beyond the end of the file (%2 bytes)")
|
||||||
|
.arg(offset)
|
||||||
|
.arg(fileSize)});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!file.seek(offset)) {
|
||||||
|
file.close();
|
||||||
|
done({false, 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(
|
||||||
|
{false,
|
||||||
|
QStringLiteral("Binary files are not supported: %1").arg(path)});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString content = QString::fromUtf8(chunk);
|
||||||
|
if (content.size() <= limit) {
|
||||||
|
done({true, content});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString head = content.left(limit);
|
||||||
|
const int nextOffset = offset + static_cast<int>(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({true, output});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "tool.hpp"
|
||||||
|
|
||||||
|
namespace ZShell::llm {
|
||||||
|
|
||||||
|
class FileReadTool : public LlmTool {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit FileReadTool(ToolRegistry* registry, QObject* parent = nullptr);
|
||||||
|
|
||||||
|
QString name() const override;
|
||||||
|
QString description() const override;
|
||||||
|
QJsonObject parameters() const override;
|
||||||
|
void execute(
|
||||||
|
const ToolContext& context,
|
||||||
|
const QJsonObject& args,
|
||||||
|
ToolDone done) override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ZShell::llm
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
% This is a preliminary version (2006-09-30), barring acceptance from
|
||||||
|
% the LaTeX Project Team and other feedback, of the GUST Font License.
|
||||||
|
% (GUST is the Polish TeX Users Group, http://www.gust.org.pl)
|
||||||
|
%
|
||||||
|
% For the most recent version of this license see
|
||||||
|
% http://www.gust.org.pl/fonts/licenses/GUST-FONT-LICENSE.txt
|
||||||
|
% or
|
||||||
|
% http://tug.org/fonts/licenses/GUST-FONT-LICENSE.txt
|
||||||
|
%
|
||||||
|
% This work may be distributed and/or modified under the conditions
|
||||||
|
% of the LaTeX Project Public License, either version 1.3c of this
|
||||||
|
% license or (at your option) any later version.
|
||||||
|
%
|
||||||
|
% Please also observe the following clause:
|
||||||
|
% 1) it is requested, but not legally required, that derived works be
|
||||||
|
% distributed only after changing the names of the fonts comprising this
|
||||||
|
% work and given in an accompanying "manifest", and that the
|
||||||
|
% files comprising the Work, as listed in the manifest, also be given
|
||||||
|
% new names. Any exceptions to this request are also given in the
|
||||||
|
% manifest.
|
||||||
|
%
|
||||||
|
% We recommend the manifest be given in a separate file named
|
||||||
|
% MANIFEST-<fontid>.txt, where <fontid> is some unique identification
|
||||||
|
% of the font family. If a separate "readme" file accompanies the Work,
|
||||||
|
% we recommend a name of the form README-<fontid>.txt.
|
||||||
|
%
|
||||||
|
% The latest version of the LaTeX Project Public License is in
|
||||||
|
% http://www.latex-project.org/lppl.txt and version 1.3c or later
|
||||||
|
% is part of all distributions of LaTeX version 2006/05/20 or later.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Provenance:
|
||||||
|
lmroman10-*.otf: Latin Modern v2.007 (GUST, 31-03-2026)
|
||||||
|
https://www.gust.org.pl/projects/e-foundry/latin-modern/download
|
||||||
|
(Latin_Modern-otf-2_007-31_03_2026.zip)
|
||||||
|
latinmodern-math.otf: Latin Modern Math v1.959 (GUST)
|
||||||
|
https://www.gust.org.pl/projects/e-foundry/lm-math/download
|
||||||
|
(latinmodern-math-1959.zip; same release as CTAN fonts/lm-math)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user