Compare commits
20
Commits
main
..
c9f7856982
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9f7856982
|
||
|
|
51dc0fc026 | ||
|
|
cef91caebc | ||
|
|
c5a089adaf | ||
|
|
52feb6006a | ||
|
|
dbb51ceadd | ||
|
|
487615f482 | ||
|
|
d4e936a7e2 | ||
|
|
703d804746 | ||
|
|
327270f3fa | ||
|
|
8a66c3b064 | ||
|
|
293bf67e09 | ||
|
|
c33dbc147f | ||
|
|
78988700be | ||
|
|
3946541f87 | ||
|
|
e18875a752 | ||
|
|
aae3757daf | ||
|
|
37c6a9986e | ||
|
|
9657e5092a | ||
|
|
47bab21e22 |
+2
-1
@@ -3,8 +3,9 @@ FunctionsSpacing=true
|
||||
IndentWidth=4
|
||||
MaxColumnWidth=-1
|
||||
NewlineType=native
|
||||
NormalizeOrder=true
|
||||
GroupAttributesTogether=false
|
||||
ObjectsSpacing=true
|
||||
SemicolonRule=always
|
||||
SingleLineEmptyObjects=true
|
||||
SortImports=false
|
||||
UseTabs=true
|
||||
|
||||
@@ -28,6 +28,8 @@ Flickable {
|
||||
interval: 10
|
||||
running: root.doneFakeFlick
|
||||
|
||||
onTriggered: root.doneFakeFlick = false
|
||||
onTriggered: {
|
||||
root.doneFakeFlick = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,24 +10,27 @@ ListView {
|
||||
maximumFlickVelocity: 3000
|
||||
|
||||
rebound: Transition {
|
||||
onRunningChanged: {
|
||||
if (!running && !root.doneFakeFlick) {
|
||||
root.doneFakeFlick = true;
|
||||
root.flick(1, 1);
|
||||
root.flick(-1, -1);
|
||||
Qt.callLater(() => root.cancelFlick());
|
||||
}
|
||||
}
|
||||
// onRunningChanged: {
|
||||
// if (!running && !root.doneFakeFlick) {
|
||||
// root.doneFakeFlick = true;
|
||||
// root.flick(1, 1);
|
||||
// root.flick(-1, -1);
|
||||
// Qt.callLater(() => root.cancelFlick());
|
||||
// }
|
||||
// }
|
||||
|
||||
Anim {
|
||||
properties: "x,y"
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 10
|
||||
running: root.doneFakeFlick
|
||||
|
||||
onTriggered: root.doneFakeFlick = false
|
||||
}
|
||||
// Timer {
|
||||
// interval: 10
|
||||
// running: root.doneFakeFlick
|
||||
//
|
||||
// onTriggered: {
|
||||
// root.doneFakeFlick = false;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ MouseArea {
|
||||
property int scrollAccumulatedY: 0
|
||||
|
||||
function onWheel(event: WheelEvent): void {
|
||||
event.accepted = false;
|
||||
}
|
||||
|
||||
onWheel: event => {
|
||||
|
||||
@@ -1,32 +1,37 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Templates
|
||||
import qs.Helpers
|
||||
import ZShell.Config
|
||||
import qs.Services
|
||||
|
||||
ScrollBar {
|
||||
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 ? (flickable.contentX - flickable.originX) : (flickable.contentY - flickable.originY)
|
||||
readonly property real axisLength: isHorizontal ? root.width : root.height
|
||||
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
|
||||
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
|
||||
required property Flickable flickable
|
||||
readonly property real nonAnimHeight: flickable.height / flickable.contentHeight
|
||||
readonly property real nonAnimY: flickable.contentY / flickable.contentHeight
|
||||
readonly property real nonAnimHeight: root.axisSize / root.axisContentSize
|
||||
readonly property real nonAnimY: root.axisContentPos / root.axisContentSize
|
||||
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
|
||||
|
||||
enabled: !Visibilities.getForActive().isDrawing
|
||||
implicitWidth: Tokens.padding.extraSmall * 2
|
||||
implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2
|
||||
implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0
|
||||
|
||||
contentItem: Item {
|
||||
}
|
||||
contentItem: Item {}
|
||||
Behavior on position {
|
||||
enabled: !fullMouse.pressed
|
||||
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onHoveredChanged: {
|
||||
@@ -47,54 +52,27 @@ ScrollBar {
|
||||
target: root.flickable
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
implicitWidth: handle.implicitWidth
|
||||
radius: Tokens.rounding.full
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: root.size < 1
|
||||
sourceComponent: root.isHorizontal ? horizontalTrack : verticalTrack
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: handle
|
||||
Component {
|
||||
id: verticalTrack
|
||||
|
||||
anchors.right: parent.right
|
||||
color: Colors.palette.m3secondary
|
||||
implicitHeight: root.height * root.effectiveSize
|
||||
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
|
||||
VerticalScrollBarTrack {
|
||||
scrollBar: root
|
||||
mouseArea: fullMouse
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on implicitWidth {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: horizontalTrack
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
|
||||
acceptedButtons: Qt.NoButton
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
}
|
||||
HorizontalScrollBarTrack {
|
||||
scrollBar: root
|
||||
mouseArea: fullMouse
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,25 +89,40 @@ ScrollBar {
|
||||
|
||||
property real pressOffset: 0
|
||||
|
||||
function contentYFromThumbTop(thumbTop) {
|
||||
var visualPos = root.effectiveTravel > 0 ? thumbTop / root.travelScale : 0;
|
||||
|
||||
return root.reversed ? (visualPos - 1) * root.flickable.contentHeight : visualPos * root.flickable.contentHeight;
|
||||
function contentPosFromThumbStart(thumbStart) {
|
||||
var visualPos = root.effectiveTravel > 0 ? thumbStart / root.travelScale : 0;
|
||||
return root.reversed ? (visualPos - 1) * root.axisContentSize : visualPos * root.axisContentSize;
|
||||
}
|
||||
|
||||
function updateFromEvent(event) {
|
||||
var posInTrack = event.y / root.height;
|
||||
var thumbTop = posInTrack - pressOffset;
|
||||
thumbTop = Math.max(0, Math.min(root.effectiveTravel, thumbTop));
|
||||
var eventPos = root.isHorizontal ? event.x : event.y;
|
||||
var posInTrack = eventPos / root.axisLength;
|
||||
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;
|
||||
else
|
||||
root.flickable.contentY = newPos + root.flickable.originY;
|
||||
}
|
||||
|
||||
function visualThumbTop() {
|
||||
function visualThumbStart() {
|
||||
const visualPos = root.reversed ? (1 + root.nonAnimY) : root.nonAnimY;
|
||||
return visualPos * root.travelScale;
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent): void {
|
||||
if (root.horizontal) {
|
||||
event.accepted = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var delta = event.angleDelta.y > 0 ? -0.1 : 0.1;
|
||||
var newPos = Math.max(0, Math.min(1 - root.size, root.position + delta));
|
||||
root.position = newPos;
|
||||
}
|
||||
|
||||
anchors.fill: parent
|
||||
cursorShape: undefined
|
||||
hoverEnabled: true
|
||||
@@ -140,24 +133,15 @@ ScrollBar {
|
||||
updateFromEvent(event);
|
||||
}
|
||||
onPressed: event => {
|
||||
var currentTop = visualThumbTop();
|
||||
var currentBottom = currentTop + root.effectiveSize;
|
||||
var clickPos = event.y / root.height;
|
||||
var currentStart = visualThumbStart();
|
||||
var currentEnd = currentStart + root.effectiveSize;
|
||||
var eventPos = root.isHorizontal ? event.x : event.y;
|
||||
var clickPos = eventPos / root.axisLength;
|
||||
|
||||
var clickedInsideThumb = clickPos >= currentTop && clickPos <= currentBottom;
|
||||
|
||||
if (clickedInsideThumb) {
|
||||
pressOffset = clickPos - currentTop;
|
||||
} else {
|
||||
pressOffset = root.effectiveSize / 2;
|
||||
}
|
||||
var clickedInsideThumb = clickPos >= currentStart && clickPos <= currentEnd;
|
||||
pressOffset = clickedInsideThumb ? (clickPos - currentStart) : root.effectiveSize / 2;
|
||||
|
||||
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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import QtQuick
|
||||
import QtQuick.Templates
|
||||
import ZShell.Config
|
||||
import qs.Services
|
||||
|
||||
TextArea {
|
||||
id: root
|
||||
|
||||
color: Colors.palette.m3onSurface
|
||||
cursorVisible: !readOnly
|
||||
font.pointSize: Tokens.font.size.small
|
||||
implicitHeight: contentHeight + topPadding + bottomPadding
|
||||
implicitWidth: contentWidth + leftPadding + rightPadding
|
||||
placeholderTextColor: Colors.palette.m3onSurfaceVariant // No anim cause placeholder is custom
|
||||
renderType: TextArea.NativeRendering
|
||||
selectedTextColor: color
|
||||
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
Behavior on color {
|
||||
CAnim {}
|
||||
}
|
||||
Behavior on selectionColor {
|
||||
CAnim {}
|
||||
}
|
||||
cursorDelegate: Item {}
|
||||
|
||||
CustomRect {
|
||||
id: cursor
|
||||
|
||||
property bool disableBlink
|
||||
|
||||
color: Colors.palette.m3primary
|
||||
implicitHeight: root.cursorRectangle.height
|
||||
implicitWidth: 1.5
|
||||
radius: Tokens.rounding.largeIncreased
|
||||
x: root.cursorRectangle.x
|
||||
y: root.cursorRectangle.y
|
||||
|
||||
Behavior on x {
|
||||
Anim {
|
||||
duration: Tokens.anim.durations.expressiveFastEffects
|
||||
easing.bezierCurve: [0.2, 1, 0.21, 1, 1, 1]
|
||||
}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.StandardSmall
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onCursorPositionChanged(): void {
|
||||
if (root.activeFocus && root.cursorVisible) {
|
||||
cursor.opacity = 1;
|
||||
cursor.disableBlink = true;
|
||||
enableBlink.restart();
|
||||
}
|
||||
}
|
||||
|
||||
target: root
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: enableBlink
|
||||
|
||||
interval: 500
|
||||
|
||||
onTriggered: cursor.disableBlink = false
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 500
|
||||
repeat: true
|
||||
running: root.activeFocus && root.cursorVisible && !cursor.disableBlink
|
||||
triggeredOnStart: true
|
||||
|
||||
onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1
|
||||
}
|
||||
|
||||
Binding {
|
||||
cursor.opacity: 0
|
||||
when: !root.activeFocus || !root.cursorVisible
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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.pointSize: Tokens.font.size.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ CustomListView {
|
||||
|
||||
property real endFadeOpacity: fadeShouldBeActive(false) ? 0 : 1
|
||||
property real fadeAmount: 0.1
|
||||
property real fadeThreshold: 0.0
|
||||
readonly property bool horizontal: orientation === ListView.Horizontal
|
||||
property real startFadeOpacity: fadeShouldBeActive(true) ? 0 : 1
|
||||
|
||||
@@ -28,11 +29,11 @@ CustomListView {
|
||||
}
|
||||
|
||||
function marginEnd(): real {
|
||||
return horizontal ? rightMargin : bottomMargin;
|
||||
return horizontal ? rightMargin - fadeThreshold : bottomMargin - fadeThreshold;
|
||||
}
|
||||
|
||||
function marginStart(): real {
|
||||
return horizontal ? leftMargin : topMargin;
|
||||
return horizontal ? leftMargin - fadeThreshold : topMargin - fadeThreshold;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,6 @@ Item {
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
popouts: popouts
|
||||
sidebar: sidebar
|
||||
visibilities: root.visibilities
|
||||
}
|
||||
|
||||
+4
-7
@@ -55,7 +55,7 @@ CustomWindow {
|
||||
property color surfaceColor: Colors.tPalette.m3surface
|
||||
|
||||
WlrLayershell.exclusionMode: ExclusionMode.Ignore
|
||||
WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
|
||||
WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings || visibilities.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
|
||||
WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top
|
||||
color: "transparent"
|
||||
contentItem.focus: true
|
||||
@@ -63,12 +63,10 @@ CustomWindow {
|
||||
name: "Bar"
|
||||
|
||||
Behavior on fsTransitionProg {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
Behavior on surfaceColor {
|
||||
CAnim {
|
||||
}
|
||||
CAnim {}
|
||||
}
|
||||
|
||||
contentItem.Keys.onEscapePressed: {
|
||||
@@ -293,8 +291,7 @@ CustomWindow {
|
||||
y: panels.popoutsWrapper.y + panels.popouts.y + geometry.insetTop(root.borderThickness) - (geometry.barOnTop ? panels.popouts.height * extraExtent : 0)
|
||||
|
||||
Behavior on extraExtent {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,21 +13,17 @@ import qs.Paths
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property M3Palette current: M3Palette {
|
||||
}
|
||||
readonly property M3Palette current: M3Palette {}
|
||||
property bool currentLight
|
||||
property string flavour
|
||||
readonly property bool light: showPreview ? previewLight : currentLight
|
||||
readonly property M3Palette palette: showPreview ? preview : current
|
||||
readonly property M3Palette preview: M3Palette {
|
||||
}
|
||||
readonly property M3Palette preview: M3Palette {}
|
||||
property bool previewLight
|
||||
property string scheme
|
||||
property bool showPreview
|
||||
readonly property M3TPalette tPalette: M3TPalette {
|
||||
}
|
||||
readonly property Transparency transparency: Transparency {
|
||||
}
|
||||
readonly property M3TPalette tPalette: M3TPalette {}
|
||||
readonly property Transparency transparency: Transparency {}
|
||||
readonly property alias wallLuminance: analyser.luminance
|
||||
|
||||
function alterColor(c: color, a: real, layer: int): color {
|
||||
@@ -112,8 +108,7 @@ Singleton {
|
||||
|
||||
function setMode(mode: string): void {
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]);
|
||||
Config.general.color.mode = mode;
|
||||
Config.save();
|
||||
Config.colors.mode = mode;
|
||||
}
|
||||
|
||||
function swapRG(c: color): color {
|
||||
|
||||
@@ -9,13 +9,13 @@ Singleton {
|
||||
id: root
|
||||
|
||||
readonly property bool enabled: service.enabled
|
||||
readonly property int end: Config.general.color.scheduleHyprsunsetEnd
|
||||
readonly property int end: Config.display.nightlight.scheduleEnd
|
||||
property bool manualToggle: false
|
||||
readonly property int start: Config.general.color.scheduleHyprsunsetStart
|
||||
readonly property int temp: Config.general.color.hyprsunsetTemp
|
||||
readonly property int start: Config.display.nightlight.scheduleStart
|
||||
readonly property int temp: Config.display.nightlight.temp
|
||||
|
||||
function checkStartup(): void {
|
||||
if (!Config.general.color.scheduleHyprsunset)
|
||||
if (!Config.display.nightlight.schedule)
|
||||
return;
|
||||
|
||||
service.apply();
|
||||
@@ -26,12 +26,14 @@ Singleton {
|
||||
service.toggle();
|
||||
}
|
||||
|
||||
HyprsunsetManager {
|
||||
NightlightManager {
|
||||
id: service
|
||||
|
||||
activeAuto: Config.general.color.scheduleHyprsunset
|
||||
activeAuto: Config.display.nightlight.schedule
|
||||
endTime: root.end
|
||||
startTime: root.start
|
||||
temp: root.temp
|
||||
fadeDuration: Config.display.nightlight.fadeDuration / 1000
|
||||
useNativeNightlight: Config.display.nightlight.useNative
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,14 @@ import qs.Services
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property int darkEnd: Config.general.color.scheduleDarkEnd
|
||||
readonly property int darkStart: Config.general.color.scheduleDarkStart
|
||||
readonly property bool enabled: Config.general.color.scheduleDark && Config.general.color.schemeGeneration
|
||||
readonly property int darkEnd: Config.colors.scheduleDarkEnd
|
||||
readonly property int darkStart: Config.colors.scheduleDarkStart
|
||||
readonly property bool enabled: Config.colors.scheduleDark && Config.colors.schemeGen
|
||||
|
||||
function applyDarkMode() {
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "dark"]);
|
||||
|
||||
Config.general.color.mode = "dark";
|
||||
Config.colors.mode = "dark";
|
||||
|
||||
Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-dark'"]);
|
||||
|
||||
@@ -30,13 +30,13 @@ Singleton {
|
||||
function applyLightMode() {
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", "light"]);
|
||||
|
||||
Config.general.color.mode = "light";
|
||||
Config.colors.mode = "light";
|
||||
|
||||
Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", "'prefer-light'"]);
|
||||
|
||||
Quickshell.execDetached(["sh", "-c", `sed -i 's/color_scheme_path=\\(.*\\)Dark.colors/color_scheme_path=\\1Light.colors/' ${Paths.home}/.config/qt6ct/qt6ct.conf`]);
|
||||
|
||||
if (Config.general.color.neovimColors)
|
||||
if (Config.colors.neovimColors)
|
||||
Quickshell.execDetached(["sed", "-i", "'s/\\(vim.cmd.colorscheme \\).*/\\1\"onelight\"/'", "~/.config/nvim/lua/config/load-colorscheme.lua"]);
|
||||
}
|
||||
|
||||
|
||||
+10
-16
@@ -126,33 +126,29 @@ Item {
|
||||
focus: true
|
||||
opacity: 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
duration: 300
|
||||
}
|
||||
}
|
||||
Behavior on rsx {
|
||||
enabled: !selectArea.pressed && root.mode === "select"
|
||||
|
||||
ExAnim {
|
||||
}
|
||||
ExAnim {}
|
||||
}
|
||||
Behavior on rsy {
|
||||
enabled: !selectArea.pressed && root.mode === "select"
|
||||
|
||||
ExAnim {
|
||||
}
|
||||
ExAnim {}
|
||||
}
|
||||
Behavior on sh {
|
||||
enabled: !selectArea.pressed && root.mode === "select"
|
||||
|
||||
ExAnim {
|
||||
}
|
||||
ExAnim {}
|
||||
}
|
||||
Behavior on sw {
|
||||
enabled: !selectArea.pressed && root.mode === "select"
|
||||
|
||||
ExAnim {
|
||||
ExAnim {}
|
||||
}
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
duration: 300
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,8 +348,7 @@ Item {
|
||||
y: selectionRect.y - root.realBorderWidth
|
||||
|
||||
Behavior on border.color {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +397,5 @@ Item {
|
||||
onUndoRequested: annotations.undo()
|
||||
}
|
||||
|
||||
component ExAnim: Anim {
|
||||
}
|
||||
component ExAnim: Anim {}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ Searcher {
|
||||
previewPath = path;
|
||||
showPreview = true;
|
||||
|
||||
if (Config.general.color.schemeGeneration)
|
||||
if (Config.colors.schemeGen)
|
||||
previewColorsProc.running = true;
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ Searcher {
|
||||
WallpaperPath.currentWallpaperPath = path;
|
||||
Quickshell.screens.forEach(n => setCrop(n.name, Qt.rect(0, 0, 1, 1), 1.0));
|
||||
Quickshell.execDetached(["zshell-cli", "wallpaper", "lockscreen", "--input-image", `${root.actualCurrent}`, "--output-path", `${Paths.state}/lockscreen_bg.png`, "--blur-amount", `${Config.lock.blurAmount}`]);
|
||||
if (Config.general.color.schemeGeneration)
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.general.color.mode}`]);
|
||||
if (Config.colors.schemeGen)
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--image-path", `${root.actualCurrent}`, "--scheme", `${Config.colors.schemeType}`, "--mode", `${Config.colors.mode}`]);
|
||||
}
|
||||
|
||||
function stopPreview(): void {
|
||||
|
||||
@@ -44,7 +44,7 @@ WidgetBase {
|
||||
}
|
||||
required property GridLayout loader
|
||||
required property Wrapper popouts
|
||||
readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.small * 2 : verticalColumn.implicitHeight + Tokens.padding.small * 2
|
||||
readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2
|
||||
required property PersistentProperties visibilities
|
||||
|
||||
color: visibilities.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer
|
||||
@@ -57,14 +57,13 @@ WidgetBase {
|
||||
|
||||
anchors.centerIn: parent
|
||||
color: root.contentColor
|
||||
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type
|
||||
font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
|
||||
height: implicitHeight
|
||||
text: Time.dateStr
|
||||
visible: root.horizontal
|
||||
|
||||
Behavior on color {
|
||||
CAnim {
|
||||
}
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,12 +83,11 @@ WidgetBase {
|
||||
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
color: root.contentColor
|
||||
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type
|
||||
font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
|
||||
text: Qt.formatDateTime(Time.date, modelData)
|
||||
|
||||
Behavior on color {
|
||||
CAnim {
|
||||
}
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +112,7 @@ WidgetBase {
|
||||
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
color: root.contentColor
|
||||
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type
|
||||
font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
|
||||
text: {
|
||||
if (modelData.includes("h"))
|
||||
return Time.hourStr;
|
||||
@@ -126,8 +124,7 @@ WidgetBase {
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
CAnim {
|
||||
}
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,13 +132,12 @@ WidgetBase {
|
||||
CustomText {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
color: root.contentColor
|
||||
font.family: Config.appearance.font.family.mono // qmllint disable incompatible-type
|
||||
font.family: Config.appearance.font.family.clock // qmllint disable incompatible-type
|
||||
text: Qt.formatDateTime(Time.date, "AP")
|
||||
visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0
|
||||
|
||||
Behavior on color {
|
||||
CAnim {
|
||||
}
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ Item {
|
||||
anchors.centerIn: parent
|
||||
|
||||
sourceComponent: BatteryIcon {
|
||||
devState: Battery.deviceStateString.toLowerCase()
|
||||
devState: Battery.deviceStateString
|
||||
percentage: Battery.currentPerc
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ ColumnLayout {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
color: Colors.palette.m3secondary
|
||||
font.bold: true
|
||||
font.family: Config.appearance.font.family.clock
|
||||
font.family: Appearance.font.family.clock
|
||||
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
|
||||
text: Time.hourStr
|
||||
}
|
||||
@@ -38,7 +38,7 @@ ColumnLayout {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
color: Colors.palette.m3primary
|
||||
font.bold: true
|
||||
font.family: Config.appearance.font.family.clock
|
||||
font.family: Appearance.font.family.clock
|
||||
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
|
||||
text: ":"
|
||||
}
|
||||
@@ -47,7 +47,7 @@ ColumnLayout {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
color: Colors.palette.m3secondary
|
||||
font.bold: true
|
||||
font.family: Config.appearance.font.family.clock
|
||||
font.family: Appearance.font.family.clock
|
||||
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
|
||||
text: Time.minuteStr
|
||||
}
|
||||
@@ -58,7 +58,7 @@ ColumnLayout {
|
||||
Layout.topMargin: -Tokens.padding.large * 2
|
||||
color: Colors.palette.m3tertiary
|
||||
font.bold: true
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.family: Appearance.font.family.mono
|
||||
font.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale)
|
||||
text: Time.format("dddd, d MMMM yyyy")
|
||||
}
|
||||
@@ -226,7 +226,7 @@ ColumnLayout {
|
||||
anchors.right: parent.right
|
||||
animateProp: "opacity"
|
||||
color: Colors.palette.m3onSurfaceVariant
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.family: Appearance.font.family.mono
|
||||
horizontalAlignment: Qt.AlignHCenter
|
||||
lineHeight: 1.2
|
||||
opacity: shouldBeVisible && !message.msg ? 1 : 0
|
||||
@@ -295,7 +295,7 @@ ColumnLayout {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
color: Colors.palette.m3error
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.family: Appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
horizontalAlignment: Qt.AlignHCenter
|
||||
opacity: 0
|
||||
|
||||
@@ -2,7 +2,6 @@ pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import ZShell.Internal
|
||||
import ZShell.Config
|
||||
import qs.Helpers
|
||||
@@ -31,12 +30,12 @@ Scope {
|
||||
Quickshell.execDetached(action);
|
||||
}
|
||||
|
||||
LidWatcher {
|
||||
onAboutToSleep: root.lock.lock.locked = true
|
||||
}
|
||||
LidWatcher {
|
||||
onAboutToSleep: root.lock.lock.locked = true
|
||||
}
|
||||
|
||||
Variants {
|
||||
model: Config.general.idle.timeouts.values
|
||||
model: Config.general.idle.timeouts
|
||||
|
||||
IdleMonitor {
|
||||
required property var modelData
|
||||
|
||||
@@ -41,7 +41,7 @@ Item {
|
||||
anchors.centerIn: parent
|
||||
animate: true
|
||||
color: root.pam.passwd.active ? Colors.palette.m3secondary : Colors.palette.m3outline
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.family: Appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
opacity: root.buffer ? 0 : 1
|
||||
text: {
|
||||
|
||||
@@ -24,7 +24,7 @@ ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
color: Colors.palette.m3outline
|
||||
elide: Text.ElideRight
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.family: Appearance.font.family.mono
|
||||
font.weight: 500
|
||||
text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications")
|
||||
}
|
||||
@@ -66,7 +66,7 @@ ColumnLayout {
|
||||
CustomText {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
color: Colors.palette.m3outlineVariant
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.family: Appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.large
|
||||
font.weight: 500
|
||||
text: qsTr("No Notifications")
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Components
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Modules.Notifications.Sidebar.Chat.Content
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property ChatSession chatData
|
||||
property bool following: true
|
||||
// qmlformat off
|
||||
readonly property var keys: [
|
||||
Qt.Key_A,
|
||||
Qt.Key_B,
|
||||
Qt.Key_C,
|
||||
Qt.Key_D,
|
||||
Qt.Key_E,
|
||||
Qt.Key_F,
|
||||
Qt.Key_G,
|
||||
Qt.Key_H,
|
||||
Qt.Key_I,
|
||||
Qt.Key_J,
|
||||
Qt.Key_K,
|
||||
Qt.Key_L,
|
||||
Qt.Key_M,
|
||||
Qt.Key_N,
|
||||
Qt.Key_O,
|
||||
Qt.Key_P,
|
||||
Qt.Key_Q,
|
||||
Qt.Key_R,
|
||||
Qt.Key_S,
|
||||
Qt.Key_T,
|
||||
Qt.Key_U,
|
||||
Qt.Key_V,
|
||||
Qt.Key_W,
|
||||
Qt.Key_X,
|
||||
Qt.Key_Y,
|
||||
Qt.Key_Z,
|
||||
|
||||
Qt.Key_Agrave,
|
||||
Qt.Key_Aacute,
|
||||
Qt.Key_Acircumflex,
|
||||
Qt.Key_Atilde,
|
||||
Qt.Key_Adiaeresis,
|
||||
Qt.Key_Aring,
|
||||
Qt.Key_AE,
|
||||
|
||||
Qt.Key_Ccedilla,
|
||||
|
||||
Qt.Key_Egrave,
|
||||
Qt.Key_Eacute,
|
||||
Qt.Key_Ecircumflex,
|
||||
Qt.Key_Ediaeresis,
|
||||
|
||||
Qt.Key_Igrave,
|
||||
Qt.Key_Iacute,
|
||||
Qt.Key_Icircumflex,
|
||||
Qt.Key_Idiaeresis,
|
||||
|
||||
Qt.Key_ETH,
|
||||
|
||||
Qt.Key_Ntilde,
|
||||
|
||||
Qt.Key_Ograve,
|
||||
Qt.Key_Oacute,
|
||||
Qt.Key_Ocircumflex,
|
||||
Qt.Key_Otilde,
|
||||
Qt.Key_Odiaeresis,
|
||||
Qt.Key_Ooblique,
|
||||
|
||||
Qt.Key_Ugrave,
|
||||
Qt.Key_Uacute,
|
||||
Qt.Key_Ucircumflex,
|
||||
Qt.Key_Udiaeresis,
|
||||
|
||||
Qt.Key_Yacute,
|
||||
Qt.Key_ydiaeresis,
|
||||
|
||||
Qt.Key_THORN,
|
||||
Qt.Key_ssharp
|
||||
]
|
||||
// qmlformat on
|
||||
|
||||
signal requestClose
|
||||
|
||||
function send(text: string): void {
|
||||
if (text.trim() === "")
|
||||
return;
|
||||
following = true;
|
||||
chatData.sendMessage(text);
|
||||
input.text = "";
|
||||
}
|
||||
|
||||
function focusInput(): void {
|
||||
Qt.callLater(() => input.forceActiveFocus());
|
||||
}
|
||||
|
||||
onChatDataChanged: {
|
||||
if (chatData)
|
||||
focusInput();
|
||||
}
|
||||
Keys.onPressed: e => {
|
||||
if (root.keys.includes(e.key)) {
|
||||
input.insert(input.length, e.text);
|
||||
focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: header
|
||||
|
||||
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.pointSize: Tokens.font.size.larger
|
||||
text: qsTr(root.chatData.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: VerticalFadeListView {
|
||||
id: list
|
||||
|
||||
property bool userScrolledUp: false
|
||||
property real lastCHeight: 0.0
|
||||
|
||||
function scrollToBottom(): void {
|
||||
scrollAnim.start();
|
||||
}
|
||||
|
||||
cacheBuffer: Math.max(height * 20, 0)
|
||||
clip: true
|
||||
fadeAmount: 0.05
|
||||
fadeThreshold: Tokens.padding.medium
|
||||
model: root.chatData.messagesModel
|
||||
spacing: 0
|
||||
rotation: 180
|
||||
add: Transition {
|
||||
Anim {
|
||||
from: -100
|
||||
property: "y"
|
||||
}
|
||||
}
|
||||
|
||||
CustomScrollBar.vertical: CustomScrollBar {
|
||||
id: scrollBar
|
||||
|
||||
flickable: list
|
||||
parent: list.parent
|
||||
anchors.top: list.top
|
||||
anchors.right: list.right
|
||||
anchors.bottom: list.bottom
|
||||
|
||||
transform: Rotation {
|
||||
origin.y: list.height / 2
|
||||
origin.x: scrollBar.width / 2
|
||||
angle: 180
|
||||
|
||||
axis {
|
||||
y: 0
|
||||
x: 1
|
||||
z: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
delegate: MessageDelegate {
|
||||
rotation: 180
|
||||
}
|
||||
|
||||
displaced: Transition {
|
||||
Anim {
|
||||
property: "y"
|
||||
}
|
||||
}
|
||||
move: Transition {
|
||||
Anim {
|
||||
property: "y"
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
positionViewAtBeginning();
|
||||
}
|
||||
onAtYBeginningChanged: {
|
||||
if (atYBeginning)
|
||||
userScrolledUp = false;
|
||||
}
|
||||
onContentHeightChanged: {
|
||||
if (userScrolledUp && Chat.busy) {
|
||||
const delta = contentHeight - lastCHeight;
|
||||
contentY += delta;
|
||||
}
|
||||
|
||||
lastCHeight = contentHeight;
|
||||
}
|
||||
onMovingChanged: {
|
||||
if (moving)
|
||||
userScrolledUp = !atYBeginning;
|
||||
}
|
||||
|
||||
Anim {
|
||||
id: scrollAnim
|
||||
|
||||
property: "contentY"
|
||||
target: list
|
||||
type: Anim.DefaultEffects
|
||||
to: list.originY
|
||||
}
|
||||
|
||||
WheelInverter {
|
||||
target: list
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger the load even before we're ready to show the list.
|
||||
Component.onCompleted: if (root.chatData)
|
||||
root.chatData.messagesModel
|
||||
}
|
||||
|
||||
EmptyBackground {
|
||||
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: !listLoader.item.atYBeginning && listLoader.item.contentHeight > listLoader.item.height ? 1 : 0
|
||||
visible: scale > 0
|
||||
|
||||
Behavior on scale {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
Elevation {
|
||||
anchors.fill: parent
|
||||
level: 2
|
||||
radius: scrollToBottom.radius
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: scrollToBottom
|
||||
|
||||
anchors.centerIn: parent
|
||||
font.pointSize: Tokens.font.size.large
|
||||
icon: "arrow_downward"
|
||||
isRound: true
|
||||
padding: Tokens.padding.extraSmall
|
||||
type: IconButton.Tonal
|
||||
|
||||
onClicked: {
|
||||
listLoader.item.scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ChatInput {
|
||||
id: input
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
bg.border.color: Colors.palette.m3outlineVariant
|
||||
bg.color: Colors.tPalette.m3surfaceContainerLowest
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
|
||||
focus: true
|
||||
placeholderText: qsTr("Send a message")
|
||||
sendIcon.font.pointSize: Tokens.font.size.large
|
||||
sendIcon.icon: "arrow_upward"
|
||||
sendIcon.padding: Tokens.padding.extraSmall
|
||||
|
||||
Component.onCompleted: root.focusInput()
|
||||
Keys.onPressed: e => {
|
||||
if (e.key == Qt.Key_Return) {
|
||||
if (!(e.modifiers & Qt.ShiftModifier)) {
|
||||
root.send(text);
|
||||
e.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
onSendPressed: root.send(text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
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
|
||||
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;
|
||||
|
||||
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
|
||||
font.pointSize: Tokens.font.size.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;
|
||||
}
|
||||
|
||||
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.pointSize: Tokens.font.size.normal
|
||||
text: root.modelData.title || qsTr("New chat")
|
||||
readOnly: true
|
||||
color: Colors.palette.m3onSurface
|
||||
|
||||
Behavior on anchors.leftMargin {
|
||||
Anim {}
|
||||
}
|
||||
Behavior on anchors.topMargin {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onEditingFinished: root.modelData.title = text
|
||||
onReadOnlyChanged: {
|
||||
if (!readOnly) {
|
||||
this.forceActiveFocus();
|
||||
cursorPosition = text.length;
|
||||
} else {
|
||||
root.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: fadeMask
|
||||
|
||||
// anchors.top: titleText.top
|
||||
// anchors.bottom: titleText.bottom
|
||||
// anchors.left: titleText.left
|
||||
// anchors.right: parent.right
|
||||
anchors.fill: titleWrapper
|
||||
|
||||
layer.enabled: true
|
||||
visible: false
|
||||
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
|
||||
GradientStop {
|
||||
color: Qt.rgba(1, 1, 1, 1.0)
|
||||
position: 0.85
|
||||
}
|
||||
|
||||
GradientStop {
|
||||
color: Qt.rgba(1, 1, 1, 0)
|
||||
position: 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: created
|
||||
|
||||
anchors.top: title.bottom
|
||||
anchors.topMargin: Tokens.spacing.extraSmall
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
font.pointSize: Tokens.font.size.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.pointSize: Tokens.font.size.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.pointSize: Tokens.font.size.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.pointSize: Tokens.font.size.small
|
||||
maximumLineCount: 1
|
||||
opacity: !root.expanded ? 1 : 0
|
||||
text: root.modelData.messagesModel.lastMessage?.activeGeneration.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?.activeGeneration.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.pointSize: Tokens.font.size.large
|
||||
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.pointSize: Tokens.font.size.large
|
||||
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)
|
||||
|
||||
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,87 @@
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
TextAreaBase {
|
||||
id: root
|
||||
|
||||
readonly property alias bg: bg
|
||||
readonly property alias sendIcon: sendIcon
|
||||
|
||||
signal sendPressed
|
||||
|
||||
bottomPadding: Tokens.padding.large
|
||||
leftPadding: Tokens.padding.extraLarge
|
||||
rightPadding: sendIcon.width + sendIcon.anchors.rightMargin + Tokens.spacing.small
|
||||
topPadding: Tokens.padding.large
|
||||
wrapMode: TextAreaBase.Wrap
|
||||
|
||||
background: CustomRect {
|
||||
id: bg
|
||||
|
||||
anchors.fill: parent
|
||||
color: Colors.tPalette.m3surfaceContainer
|
||||
radius: Tokens.rounding.extraLarge
|
||||
|
||||
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.sendPressed()
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
id: tapHandler
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Llm
|
||||
import ZShell.Config
|
||||
import qs.Services
|
||||
import qs.Components
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property alias list: list
|
||||
property alias model: list.model
|
||||
property bool highlight: false
|
||||
|
||||
signal deleteChatRequest(content: ChatSession)
|
||||
signal loadChatRequest(content: ChatSession, index: int)
|
||||
signal newChatRequest
|
||||
|
||||
CustomText {
|
||||
id: header
|
||||
|
||||
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: header.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
|
||||
|
||||
onHighlightedChanged: if (highlighted)
|
||||
list.currentIndex = index
|
||||
onClicked: content => root.loadChatRequest(content, index)
|
||||
onRemove: content => root.deleteChatRequest(content)
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: {
|
||||
root.focus = true;
|
||||
ChatState.fabExpanded = false;
|
||||
modelsContainer.expanded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
id: modelsContainer
|
||||
|
||||
property bool expanded: false
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: fabRoot.left
|
||||
anchors.margins: Tokens.padding.small
|
||||
anchors.rightMargin: Tokens.spacing.small
|
||||
|
||||
implicitHeight: expanded ? 400 : fabRoot.implicitHeight
|
||||
radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2
|
||||
|
||||
function prettyModelName(): string {
|
||||
const pathList = Chat.model.split("\/");
|
||||
const name = pathList[pathList.length - 1];
|
||||
return name;
|
||||
}
|
||||
|
||||
Behavior on implicitHeight {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
Behavior on radius {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: modelName
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
implicitHeight: fabRoot.implicitHeight
|
||||
|
||||
CustomText {
|
||||
text: modelsContainer.prettyModelName()
|
||||
color: Colors.palette.m3onSurfaceVariant
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Tokens.padding.large
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
color: Colors.palette.m3onSurfaceVariant
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Tokens.padding.large
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: modelsContainer.expanded ? "unfold_less" : "unfold_more"
|
||||
animate: true
|
||||
font.pointSize: Tokens.font.size.large
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
id: modelLayer
|
||||
onClicked: {
|
||||
ChatState.fabExpanded = false;
|
||||
modelsContainer.expanded = !modelsContainer.expanded;
|
||||
}
|
||||
radius: modelsContainer.radius
|
||||
}
|
||||
}
|
||||
|
||||
VerticalFadeListView {
|
||||
id: modelsList
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.top: modelName.bottom
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
model: Chat.availableModels
|
||||
opacity: modelsContainer.expanded ? 1 : 0
|
||||
currentIndex: Chat.availableModels.indexOf(Chat.model)
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
CustomScrollBar.vertical: CustomScrollBar {
|
||||
flickable: modelsList
|
||||
}
|
||||
|
||||
delegate: CustomRect {
|
||||
id: model
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
readonly property bool selected: model.ListView.view.currentIndex === model.index
|
||||
|
||||
implicitWidth: ListView.view.width
|
||||
implicitHeight: layout.implicitHeight + Tokens.padding.small * 2
|
||||
radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium
|
||||
color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0)
|
||||
|
||||
Behavior on radius {
|
||||
Anim {
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: layout
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.small
|
||||
anchors.leftMargin: Tokens.padding.large
|
||||
anchors.rightMargin: Tokens.padding.large
|
||||
|
||||
CustomText {
|
||||
text: model.modelData
|
||||
color: model.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
Layout.alignment: Qt.AlignRight
|
||||
visible: model.selected
|
||||
color: Colors.palette.m3onTertiaryContainer
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
opacity: model.selected ? 1 : 0
|
||||
text: "check"
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
id: stateLayer
|
||||
|
||||
onClicked: {
|
||||
Chat.selectModel(model.modelData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: fabRoot
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Tokens.padding.small
|
||||
padding: 8
|
||||
font.pointSize: Math.round(18 * 1.2)
|
||||
icon: "add"
|
||||
isRound: ChatState.fabExpanded
|
||||
|
||||
label.transform: Rotation {
|
||||
origin.y: fabRoot.label.height / 2
|
||||
origin.x: fabRoot.label.width / 2
|
||||
angle: ChatState.fabExpanded ? 135 : 0
|
||||
|
||||
Behavior on angle {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
modelsContainer.expanded = false;
|
||||
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
|
||||
font.pointSize: Tokens.font.size.large
|
||||
fill: 1
|
||||
}
|
||||
|
||||
CustomText {
|
||||
animate: true
|
||||
text: fabMenuItem.modelData.name
|
||||
font.capitalization: Font.Capitalize
|
||||
color: Colors.palette.m3onPrimaryContainer
|
||||
Layout.preferredWidth: implicitWidth
|
||||
|
||||
Behavior on Layout.preferredWidth {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
id: sLayer
|
||||
onClicked: {
|
||||
const name = fabMenuItem.modelData.name;
|
||||
|
||||
if (name === "new chat")
|
||||
root.newChatRequest();
|
||||
else if (name === "pop out in window")
|
||||
Detach.create();
|
||||
|
||||
ChatState.fabExpanded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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.inChat) {
|
||||
stack.push(chatList);
|
||||
stack.push(chatContent, {
|
||||
"chatData": ChatState.chatSession
|
||||
});
|
||||
} else
|
||||
stack.push(chatList);
|
||||
}
|
||||
|
||||
StackView {
|
||||
id: stack
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
Component {
|
||||
id: chatList
|
||||
|
||||
ChatList {
|
||||
model: ScriptModel {
|
||||
values: Chat.chats.values
|
||||
}
|
||||
|
||||
onDeleteChatRequest: chat => {
|
||||
Chat.chats.remove(chat);
|
||||
}
|
||||
onLoadChatRequest: (chat, index) => {
|
||||
stack.push(chatContent, {
|
||||
"chatData": chat
|
||||
});
|
||||
ChatState.inChat = true;
|
||||
ChatState.chatSession = chat;
|
||||
}
|
||||
onNewChatRequest: {
|
||||
const data = Chat.chats.insert();
|
||||
stack.push(chatContent, {
|
||||
"chatData": data
|
||||
});
|
||||
ChatState.inChat = true;
|
||||
ChatState.chatSession = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: chatContent
|
||||
|
||||
ChatContent {
|
||||
onRequestClose: {
|
||||
stack.pop();
|
||||
ChatState.inChat = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import ZShell.Llm
|
||||
import qs.Helpers
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property ChatSession chatSession
|
||||
readonly property string title: chatSession ? chatSession.title : "Chat"
|
||||
property int currentIdx: -1
|
||||
property bool inChat: false
|
||||
property bool fabExpanded: false
|
||||
property bool isWindow: false
|
||||
property ShellScreen screen
|
||||
property bool sidebarVisible: Visibilities.getForActive().sidebar
|
||||
|
||||
onSidebarVisibleChanged: if (!sidebarVisible)
|
||||
fabExpanded = false
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import ZShell.Config
|
||||
import ZShell.Components
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property ChatGeneration current
|
||||
required property TextEdit edit
|
||||
required property bool hovered
|
||||
required property bool isUser
|
||||
required property ChatMessage message
|
||||
required property LlmSegment segment
|
||||
|
||||
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
|
||||
|
||||
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.family: Config.appearance.font.family.sans
|
||||
label.font.pointSize: Tokens.font.size.small
|
||||
type: IconButton.Text
|
||||
}
|
||||
|
||||
IconButton {
|
||||
enabled: root.message.generationCount > 1 && root.message.activeGenerationIndex !== root.message.generationCount - 1
|
||||
icon: "chevron_right"
|
||||
type: IconButton.Tonal
|
||||
|
||||
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.current.streaming && 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
|
||||
|
||||
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
|
||||
|
||||
Behavior on scale {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
onClicked: Quickshell.clipboardText = root.current.content
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: editButton
|
||||
|
||||
icon: root.edit.readOnly ? "edit" : "check"
|
||||
inactiveColor: Colors.palette.m3tertiary
|
||||
inactiveOnColor: Colors.palette.m3onTertiary
|
||||
visible: root.isUser
|
||||
|
||||
onClicked: {
|
||||
root.edit.readOnly = !root.edit.readOnly;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
TextEditBase {
|
||||
id: root
|
||||
|
||||
color: Colors.palette.m3onSurface
|
||||
readOnly: true
|
||||
anchors.margins: Tokens.padding.medium
|
||||
textFormat: Text.MarkdownText
|
||||
font.pointSize: Tokens.font.size.smaller
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
|
||||
onLinkActivated: link => {
|
||||
Qt.openUrlExternally(link);
|
||||
}
|
||||
|
||||
CustomMouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.NoButton
|
||||
cursorShape: root.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor
|
||||
preventStealing: false
|
||||
hoverEnabled: true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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 loading: false
|
||||
property int lastIdx: -1
|
||||
|
||||
readonly property Component conversationComp: ChatContent {}
|
||||
|
||||
function loadConversation(chat): void {
|
||||
if (currentItem) {
|
||||
currentItem.destroy();
|
||||
currentItem = null;
|
||||
}
|
||||
|
||||
if (!chat)
|
||||
return;
|
||||
|
||||
root.loading = true;
|
||||
|
||||
const incubator = root.conversationComp.incubateObject(container, {
|
||||
chatData: chat
|
||||
});
|
||||
|
||||
const attach = () => {
|
||||
incubator.object.anchors.fill = container;
|
||||
currentItem = incubator.object;
|
||||
root.loading = false;
|
||||
enterAnim.start();
|
||||
};
|
||||
|
||||
if (incubator.status === Component.Ready)
|
||||
attach();
|
||||
else
|
||||
incubator.onStatusChanged = status => {
|
||||
if (status === Component.Ready)
|
||||
attach();
|
||||
};
|
||||
}
|
||||
|
||||
Item {
|
||||
id: container
|
||||
|
||||
anchors.fill: parent
|
||||
layer.enabled: opacity < 1
|
||||
objectName: "ConversationContainer"
|
||||
|
||||
Component.onCompleted: {
|
||||
if (ChatState.chatSession)
|
||||
root.loadConversation(ChatState.chatSession);
|
||||
}
|
||||
}
|
||||
|
||||
LoadingIndicator {
|
||||
anchors.centerIn: parent
|
||||
implicitSize: Tokens.font.size.extraLarge * 4
|
||||
opacity: root.loading ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onChatSessionChanged(): void {
|
||||
exitAnim.complete();
|
||||
enterAnim.complete();
|
||||
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,276 @@
|
||||
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 var codeSpans: []
|
||||
property int highlightToken: 0
|
||||
|
||||
function refresh() {
|
||||
const token = ++root.highlightToken;
|
||||
CodeHighlighter.highlight(root.code, root.language, root, token);
|
||||
}
|
||||
|
||||
function onHighlightSpans(token, spans) {
|
||||
if (token !== root.highlightToken)
|
||||
return;
|
||||
codeSpans = spans;
|
||||
}
|
||||
|
||||
function roleColor(kind) {
|
||||
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, ">");
|
||||
}
|
||||
|
||||
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: {
|
||||
codeSpans = [];
|
||||
refresh();
|
||||
}
|
||||
onCodeChanged: 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.pointSize: Tokens.font.size.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
|
||||
|
||||
CustomText {
|
||||
id: code
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
x: implicitWidth * (0 - codeFlick.visibleArea.xPosition) + Tokens.padding.small
|
||||
anchors.margins: Tokens.padding.small
|
||||
text: root.highlightedHtml(root.code, root.codeSpans)
|
||||
textFormat: Text.RichText
|
||||
color: CodeColors.active.normal
|
||||
layer.enabled: true
|
||||
clip: false
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: codeFlick
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.small
|
||||
|
||||
CustomScrollBar.horizontal: CustomScrollBar {
|
||||
flickable: codeFlick
|
||||
parent: codeFlick.parent
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
}
|
||||
|
||||
TextAreaBase.flickable: TextAreaBase {
|
||||
id: codeText
|
||||
|
||||
color: CodeColors.active.normal
|
||||
layer.enabled: true
|
||||
leftInset: Tokens.padding.small
|
||||
clip: false
|
||||
font.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
textFormat: Text.RichText
|
||||
text: code.text
|
||||
readOnly: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property JsonObject schemes: JsonObject {
|
||||
readonly property Scheme oneDark: Scheme {
|
||||
comment: "#5c6370"
|
||||
string: "#98c379"
|
||||
stringKey: "#e06c75"
|
||||
number: "#d19a66"
|
||||
keyword: "#c678dd"
|
||||
type: "#e5c07b"
|
||||
functions: "#61afef"
|
||||
method: "#61afef"
|
||||
macro: "#98c379"
|
||||
preproc: "#abb2bf"
|
||||
operator: "#abb2bf"
|
||||
property: "#abb2bf"
|
||||
label: "#e06c75"
|
||||
attribute: "#d19a66"
|
||||
bg: "#282c34"
|
||||
normal: "#abb2bf"
|
||||
}
|
||||
readonly property Scheme nord: Scheme {
|
||||
comment: "#4c566a"
|
||||
string: "#a3be8c"
|
||||
stringKey: "#ebcb8b"
|
||||
number: "#b48ead"
|
||||
keyword: "#81a1c1"
|
||||
type: "#8fbcbb"
|
||||
functions: "#88c0d0"
|
||||
method: "#88c0d0"
|
||||
macro: "#5e81ac"
|
||||
preproc: "#5e81ac"
|
||||
operator: "#81a1c1"
|
||||
property: "#d8dee9"
|
||||
label: "#d08770"
|
||||
attribute: "#d8dee9"
|
||||
bg: "#2e3440"
|
||||
normal: "#d8dee9"
|
||||
}
|
||||
readonly property Scheme dracula: Scheme {
|
||||
comment: "#6272a4"
|
||||
string: "#f1fa8c"
|
||||
stringKey: "#f8f8f2"
|
||||
number: "#ffb86c"
|
||||
keyword: "#ff79c6"
|
||||
type: "#8be9fd"
|
||||
functions: "#50fa7b"
|
||||
method: "#50fa7b"
|
||||
macro: "#ff79c6"
|
||||
preproc: "#ff79c6"
|
||||
operator: "#f8f8f2"
|
||||
property: "#f8f8f2"
|
||||
label: "#6272a4"
|
||||
attribute: "#8be9fd"
|
||||
bg: "#282a36"
|
||||
normal: "#f8f8f2"
|
||||
}
|
||||
readonly property Scheme githubDark: Scheme {
|
||||
comment: "#8b949e"
|
||||
string: "#a5d6ff"
|
||||
stringKey: "#79c0ff"
|
||||
number: "#79c0ff"
|
||||
keyword: "#ff7b72"
|
||||
type: "#ffa657"
|
||||
functions: "#d2a8ff"
|
||||
method: "#d2a8ff"
|
||||
macro: "#ff7b72"
|
||||
preproc: "#79c0ff"
|
||||
operator: "#ff7b72"
|
||||
property: "#79c0ff"
|
||||
label: "#7ee787"
|
||||
attribute: "#7ee787"
|
||||
bg: "#0d1117"
|
||||
normal: "#c9d1d9"
|
||||
}
|
||||
readonly property Scheme solarizedDark: Scheme {
|
||||
comment: "#586e75"
|
||||
string: "#2aa198"
|
||||
stringKey: "#268bd2"
|
||||
number: "#2aa198"
|
||||
keyword: "#859900"
|
||||
type: "#b58900"
|
||||
functions: "#268bd2"
|
||||
method: "#268bd2"
|
||||
macro: "#cb4b16"
|
||||
preproc: "#cb4b16"
|
||||
operator: "#859900"
|
||||
property: "#268bd2"
|
||||
label: "#6c71c4"
|
||||
attribute: "#657b83"
|
||||
bg: "#002b36"
|
||||
normal: "#839496"
|
||||
}
|
||||
readonly property Scheme monokai: Scheme {
|
||||
comment: "#75715e"
|
||||
string: "#e6db74"
|
||||
stringKey: "#f8f8f2"
|
||||
number: "#ae81ff"
|
||||
keyword: "#f92672"
|
||||
type: "#a6e22e"
|
||||
functions: "#a6e22e"
|
||||
method: "#a6e22e"
|
||||
macro: "#a6e22e"
|
||||
preproc: "#f92672"
|
||||
operator: "#f92672"
|
||||
property: "#fda5ff"
|
||||
label: "#f92672"
|
||||
attribute: "#a6e22e"
|
||||
bg: "#272822"
|
||||
normal: "#f8f8f2"
|
||||
}
|
||||
readonly property Scheme gruvboxDark: Scheme {
|
||||
comment: "#928374"
|
||||
string: "#b8bb26"
|
||||
stringKey: "#ebdbb2"
|
||||
number: "#d3869b"
|
||||
keyword: "#fb4934"
|
||||
type: "#fabd2f"
|
||||
functions: "#b8bb26"
|
||||
method: "#b8bb26"
|
||||
macro: "#8ec07c"
|
||||
preproc: "#8ec07c"
|
||||
operator: "#ebdbb2"
|
||||
property: "#83a598"
|
||||
label: "#fb4934"
|
||||
attribute: "#8ec07c"
|
||||
bg: "#1d2021"
|
||||
normal: "#ebdbb2"
|
||||
}
|
||||
readonly property Scheme catppuccinMocha: Scheme {
|
||||
comment: "#9399b2"
|
||||
string: "#a6e3a1"
|
||||
stringKey: "#b4befe"
|
||||
number: "#fab387"
|
||||
keyword: "#cba6f7"
|
||||
type: "#f9e2af"
|
||||
functions: "#89b4fa"
|
||||
method: "#89b4fa"
|
||||
macro: "#cba6f7"
|
||||
preproc: "#f5c2e7"
|
||||
operator: "#89dceb"
|
||||
property: "#b4befe"
|
||||
label: "#74c7ec"
|
||||
attribute: "#f9e2af"
|
||||
bg: "#1e1e2e"
|
||||
normal: "#cdd6f4"
|
||||
}
|
||||
readonly property Scheme tokyoNight: Scheme {
|
||||
comment: "#565f89"
|
||||
string: "#9ece6a"
|
||||
stringKey: "#73daca"
|
||||
number: "#ff9e64"
|
||||
keyword: "#9d7cd8"
|
||||
type: "#2ac3de"
|
||||
functions: "#7aa2f7"
|
||||
method: "#7aa2f7"
|
||||
macro: "#7dcfff"
|
||||
preproc: "#7dcfff"
|
||||
operator: "#89ddff"
|
||||
property: "#73daca"
|
||||
label: "#7aa2f7"
|
||||
attribute: "#73daca"
|
||||
bg: "#1a1b26"
|
||||
normal: "#c0caf5"
|
||||
}
|
||||
readonly property Scheme ayuDark: Scheme {
|
||||
comment: "#5a6673"
|
||||
string: "#aad94c"
|
||||
stringKey: "#aad94c"
|
||||
number: "#d2a6ff"
|
||||
keyword: "#ff8f40"
|
||||
type: "#59c2ff"
|
||||
functions: "#ffb454"
|
||||
method: "#ffb454"
|
||||
macro: "#59c2ff"
|
||||
preproc: "#ff8f40"
|
||||
operator: "#f29668"
|
||||
property: "#f07178"
|
||||
label: "#59c2ff"
|
||||
attribute: "#ffb454"
|
||||
bg: "#0a0e14"
|
||||
normal: "#bfbdb6"
|
||||
}
|
||||
readonly property Scheme palenight: Scheme {
|
||||
comment: "#697098"
|
||||
string: "#c3e88d"
|
||||
stringKey: "#82b1ff"
|
||||
number: "#f78c6c"
|
||||
keyword: "#ff5370"
|
||||
type: "#ffcb6b"
|
||||
functions: "#82b1ff"
|
||||
method: "#82b1ff"
|
||||
macro: "#c792ea"
|
||||
preproc: "#ffcb6b"
|
||||
operator: "#89ddff"
|
||||
property: "#c3e88d"
|
||||
label: "#c792ea"
|
||||
attribute: "#ffcb6b"
|
||||
bg: "#292d3e"
|
||||
normal: "#bfc7d5"
|
||||
}
|
||||
}
|
||||
readonly property var active: schemes[Config.llm.appearance.scheme]
|
||||
|
||||
component Scheme: JsonObject {
|
||||
property color comment: "#697098"
|
||||
property color string: "#c3e88d"
|
||||
property color stringKey: "#82b1ff"
|
||||
property color number: "#f78c6c"
|
||||
property color keyword: "#ff5370"
|
||||
property color type: "#ffcb6b"
|
||||
property color functions: "#82b1ff"
|
||||
property color method: "#82b1ff"
|
||||
property color macro: "#c792ea"
|
||||
property color preproc: "#ffcb6b"
|
||||
property color operator: "#89ddff"
|
||||
property color property: "#c3e88d"
|
||||
property color label: "#c792ea"
|
||||
property color attribute: "#ffcb6b"
|
||||
property color bg: "#292d3e"
|
||||
property color normal: "#bfc7d5"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,138 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Llm
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property bool isUser
|
||||
required property LlmSegment segment
|
||||
required property bool hovered
|
||||
required property Repeater repeater
|
||||
required property int index
|
||||
required property ChatMessage message
|
||||
required property ChatGeneration current
|
||||
|
||||
// Widest the bubble may grow to; assistant bubbles always use it so
|
||||
// the markdown blocks have a deterministic width to wrap against.
|
||||
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
|
||||
|
||||
signal edit(text: string)
|
||||
|
||||
implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
|
||||
|
||||
CustomClippingRect {
|
||||
id: bubble
|
||||
|
||||
radius: Tokens.rounding.medium
|
||||
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
|
||||
implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
|
||||
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2
|
||||
anchors.right: root.isUser ? parent.right : undefined
|
||||
|
||||
// Behavior on implicitHeight {
|
||||
// enabled: !root.segment.running
|
||||
//
|
||||
// Anim {
|
||||
// type: Anim.DefaultEffects
|
||||
// }
|
||||
// }
|
||||
|
||||
// User messages stay a plain editable text field.
|
||||
TextEditBase {
|
||||
id: msgText
|
||||
|
||||
property string cachedText: root.segment.text
|
||||
property bool cancelled: false
|
||||
|
||||
visible: root.isUser
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
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.pointSize: Tokens.font.size.smaller
|
||||
readOnly: true
|
||||
selectionColor: Qt.alpha((root.isUser ? Colors.palette.m3onPrimary : Colors.palette.m3primary), 0.4)
|
||||
text: root.isUser ? root.segment.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assistant content: parsed markdown blocks.
|
||||
MarkdownBlocks {
|
||||
id: blocks
|
||||
|
||||
visible: !root.isUser
|
||||
anchors.topMargin: Tokens.padding.medium
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
blocks: root.segment.markdown
|
||||
}
|
||||
}
|
||||
|
||||
Actions {
|
||||
id: actionsRow
|
||||
|
||||
readonly property bool shouldBeActive: (root.segment.type === LlmSegment.Type.Content) && !root.segment.running && root.repeater.count === (root.index + 1)
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: (implicitWidth > bubble.implicitWidth) ? undefined : bubble.right
|
||||
anchors.top: bubble.bottom
|
||||
opacity: shouldBeActive ? 1 : 0
|
||||
visible: opacity > 0
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
edit: msgText
|
||||
hovered: root.hovered
|
||||
isUser: root.isUser
|
||||
segment: root.segment
|
||||
current: root.current
|
||||
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
|
||||
font.pointSize: 36
|
||||
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,91 @@
|
||||
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: LlmMarkdown.Type.Code
|
||||
|
||||
delegate: CodeBlockView {
|
||||
required property var modelData
|
||||
|
||||
language: modelData.language
|
||||
code: modelData.code
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Tokens.spacing.small
|
||||
anchors.left: parent.left
|
||||
}
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: LlmMarkdown.Type.Math
|
||||
|
||||
delegate: MathBlockView {
|
||||
required property var modelData
|
||||
|
||||
latex: modelData.latex
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
}
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: LlmMarkdown.Type.Heading
|
||||
|
||||
delegate: TextEditBase {
|
||||
required property var modelData
|
||||
|
||||
color: Colors.palette.m3onSurface
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
text: modelData.text
|
||||
textFormat: Text.MarkdownText
|
||||
readOnly: true
|
||||
font.bold: true
|
||||
font.pointSize: {
|
||||
if (modelData.level <= 1)
|
||||
return Tokens.font.size.large;
|
||||
if (modelData.level === 2)
|
||||
return Tokens.font.size.normal;
|
||||
return Tokens.font.size.smaller;
|
||||
}
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
}
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: LlmMarkdown.Type.Text
|
||||
|
||||
delegate: BubbleEdit {
|
||||
required property var modelData
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.medium
|
||||
text: modelData.text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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 real fontSize: Tokens.font.size.large
|
||||
|
||||
implicitHeight: Math.max(equationImage.height, fallbackText.contentHeight) + Tokens.padding.small * 2
|
||||
|
||||
LlmMathText {
|
||||
id: math
|
||||
|
||||
latex: root.latex
|
||||
color: root.mathColor
|
||||
fontPointSize: root.fontSize
|
||||
devicePixelRatio: Screen.devicePixelRatio
|
||||
}
|
||||
|
||||
Image {
|
||||
id: equationImage
|
||||
|
||||
anchors.centerIn: parent
|
||||
source: math.imageUrl
|
||||
asynchronous: true
|
||||
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.family: Config.appearance.font.family.mono
|
||||
font.pointSize: Tokens.font.size.small
|
||||
text: root.latex
|
||||
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
||||
visible: !math.ok
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
MouseArea {
|
||||
id: root
|
||||
|
||||
property ChatGeneration current: modelData.activeGeneration
|
||||
required property int index
|
||||
readonly property bool isUser: modelData.role === ChatMessage.Role.User
|
||||
required property ChatMessage modelData
|
||||
property bool reasoningExpanded: false
|
||||
readonly property var blocks: blockify(current.segments)
|
||||
property real savedOffset: -1
|
||||
property bool stickActive: false
|
||||
|
||||
function handleReasoningToggle(expanded: bool): void {
|
||||
const view = root.ListView.view;
|
||||
if (!view)
|
||||
return;
|
||||
|
||||
if (expanded) {
|
||||
root.savedOffset = view.contentY - root.y;
|
||||
root.reasoningExpanded = true;
|
||||
} else {
|
||||
root.reasoningExpanded = false;
|
||||
root.stickActive = false;
|
||||
settleTimer.stop();
|
||||
if (!isNaN(root.savedOffset)) {
|
||||
restoreAnim.to = root.y + root.savedOffset;
|
||||
restoreAnim.restart();
|
||||
root.savedOffset = NaN;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function blockify(segs) {
|
||||
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,
|
||||
segments: [seg]
|
||||
});
|
||||
} 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]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
implicitWidth: ListView.view.width
|
||||
hoverEnabled: true
|
||||
preventStealing: false
|
||||
implicitHeight: layout.implicitHeight + Tokens.padding.medium
|
||||
|
||||
Timer {
|
||||
id: settleTimer
|
||||
interval: 16
|
||||
|
||||
onTriggered: {
|
||||
root.stickActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
onImplicitHeightChanged: {
|
||||
if (root.reasoningExpanded) {
|
||||
root.stickActive = true;
|
||||
settleTimer.restart();
|
||||
|
||||
const view = root.ListView.view;
|
||||
if (!view)
|
||||
return;
|
||||
const bottomEdge = root.y + root.height + Tokens.padding.large * 2;
|
||||
const viewportBottom = view.contentY + view.height;
|
||||
if (bottomEdge > viewportBottom)
|
||||
view.contentY = bottomEdge - view.height;
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on current {
|
||||
id: changeAnim
|
||||
|
||||
property var object: targetProperty
|
||||
readonly property bool next: {
|
||||
const cond = root.modelData.generations.indexOf(current) < root.modelData.generations.indexOf(changeAnim.targetValue);
|
||||
return cond;
|
||||
}
|
||||
|
||||
animation: SequentialAnimation {
|
||||
ParallelAnimation {
|
||||
Anim {
|
||||
target: root
|
||||
property: "x"
|
||||
to: changeAnim.next ? root.width / 4 : -root.width / 4
|
||||
from: 0
|
||||
type: Anim.FastEffects
|
||||
}
|
||||
|
||||
Anim {
|
||||
target: root
|
||||
property: "opacity"
|
||||
from: 1
|
||||
to: 0
|
||||
type: Anim.FastEffects
|
||||
}
|
||||
}
|
||||
|
||||
PropertyAction {}
|
||||
|
||||
ParallelAnimation {
|
||||
Anim {
|
||||
target: root
|
||||
property: "x"
|
||||
from: changeAnim.next ? -root.width / 4 : root.width / 4
|
||||
type: Anim.FastEffects
|
||||
to: 0
|
||||
}
|
||||
|
||||
Anim {
|
||||
target: root
|
||||
property: "opacity"
|
||||
from: 0
|
||||
type: Anim.FastEffects
|
||||
to: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Anim {
|
||||
id: restoreAnim
|
||||
|
||||
property: "contentY"
|
||||
target: root.ListView.view
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
|
||||
Column {
|
||||
id: layout
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
|
||||
Repeater {
|
||||
id: segmentRep
|
||||
|
||||
model: ScriptModel {
|
||||
values: root.blocks
|
||||
objectProp: "id"
|
||||
}
|
||||
delegate: DelegateChooser {
|
||||
role: "kind"
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: "process"
|
||||
|
||||
delegate: ProcessBlock {
|
||||
width: root.width
|
||||
blocks: root.blocks
|
||||
|
||||
onExpandedChanged: root.handleReasoningToggle(expanded)
|
||||
}
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: "content"
|
||||
|
||||
delegate: ContentBubble {
|
||||
required property var modelData
|
||||
|
||||
isUser: root.isUser
|
||||
segment: modelData.segments[0]
|
||||
width: root.width
|
||||
repeater: segmentRep
|
||||
message: root.modelData
|
||||
current: root.current
|
||||
hovered: root.containsMouse
|
||||
|
||||
onEdit: text => {
|
||||
root.modelData.edit(text);
|
||||
|
||||
if (root.isUser)
|
||||
root.modelData.generate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
CustomClippingRect {
|
||||
id: root
|
||||
|
||||
required property int index
|
||||
required property var modelData
|
||||
required property var blocks
|
||||
readonly property var segments: modelData.segments
|
||||
readonly property bool isActive: index === blocks.length - 1
|
||||
property bool expanded: false
|
||||
readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1]
|
||||
readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
|
||||
|
||||
implicitHeight: expanded ? expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin * 2 : layout.implicitHeight + Tokens.spacing.small
|
||||
|
||||
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.pointSize: Tokens.font.size.large
|
||||
icon: "keyboard_arrow_down"
|
||||
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
|
||||
anchors.centerIn: parent
|
||||
opacity: root.isActive ? 0 : 1
|
||||
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.pointSize: Tokens.font.size.small
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: Tokens.spacing.medium
|
||||
text: {
|
||||
if (root.isActive) {
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: expandedRect
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: layout.bottom
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.topMargin: Tokens.spacing.small
|
||||
anchors.bottomMargin: 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 LlmSegment 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
|
||||
|
||||
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.pointSize: Tokens.font.size.small
|
||||
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
|
||||
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("Fetched %1").arg(JSON.parse(segment.modelData.arguments)?.url ?? "website")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Visibilities.getForActive().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,110 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import qs.Components
|
||||
import qs.Modules.Notifications.Sidebar.Chat.Content
|
||||
import qs.Services
|
||||
|
||||
RowLayout {
|
||||
id: root
|
||||
|
||||
property int breakpoint: 700
|
||||
property alias conversationModel: sidebar.model
|
||||
|
||||
readonly property bool isWide: root.width >= root.breakpoint
|
||||
property bool narrowShowsSidebar: true
|
||||
|
||||
function openConversation(conv: ChatSession, index: int): void {
|
||||
}
|
||||
|
||||
ChatList {
|
||||
id: sidebar
|
||||
|
||||
Layout.fillHeight: true
|
||||
Layout.margins: Tokens.padding.medium
|
||||
Layout.rightMargin: Tokens.spacing.medium
|
||||
Layout.maximumWidth: Config.sidebar.sizes.width
|
||||
Layout.preferredWidth: root.width / 4
|
||||
Layout.minimumWidth: Config.sidebar.sizes.width / 2
|
||||
highlight: true
|
||||
|
||||
Behavior on implicitWidth {
|
||||
Anim {}
|
||||
}
|
||||
model: ScriptModel {
|
||||
values: Chat.chats.values
|
||||
}
|
||||
|
||||
onLoadChatRequest: (chat, index) => {
|
||||
ChatState.currentIdx = index;
|
||||
ChatState.chatSession = chat;
|
||||
}
|
||||
|
||||
onDeleteChatRequest: chat => {
|
||||
Chat.chats.remove(chat);
|
||||
ChatState.currentIdx = -1;
|
||||
ChatState.chatSession = null;
|
||||
}
|
||||
onNewChatRequest: {
|
||||
const data = Chat.chats.insert();
|
||||
ChatState.currentIdx = 0;
|
||||
ChatState.chatSession = data;
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.margins: Tokens.padding.extraLarge
|
||||
Layout.leftMargin: Tokens.spacing.extraLarge
|
||||
Layout.topMargin: Tokens.padding.large
|
||||
Layout.preferredWidth: Config.sidebar.sizes.width * 2
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
|
||||
ChatHost {
|
||||
id: convHost
|
||||
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
implicitWidth: Math.min(parent.width, 800)
|
||||
clip: true
|
||||
|
||||
onImplicitWidthChanged: console.log(implicitWidth)
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: noChatLayout
|
||||
anchors.centerIn: parent
|
||||
opacity: ChatState.chatSession ? 0 : 1
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {}
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
text: "chat_bubble_off"
|
||||
font.pointSize: Tokens.font.size.extraLarge
|
||||
color: Colors.tPalette.m3onSurfaceVariant
|
||||
}
|
||||
|
||||
CustomText {
|
||||
Layout.alignment: Qt.AlignCenter
|
||||
text: qsTr("Start or create a chat")
|
||||
color: Colors.tPalette.m3onSurfaceVariant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: conversationView
|
||||
|
||||
ChatContent {}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import qs.Components
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Components
|
||||
import qs.Modules.Notifications.Sidebar.Chat
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
@@ -10,21 +14,94 @@ Item {
|
||||
required property Props props
|
||||
required property var visibilities
|
||||
|
||||
Connections {
|
||||
function onIsWindowChanged(): void {
|
||||
if (ChatState.isWindow)
|
||||
root.props.currentTab = 0;
|
||||
}
|
||||
|
||||
target: ChatState
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
|
||||
anchors.fill: parent
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
CustomRect {
|
||||
Tabs {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: ChatState.isWindow ? 0 : implicitHeight
|
||||
dashState: root.props
|
||||
nonAnimWidth: layout.width
|
||||
visible: height > 0
|
||||
}
|
||||
|
||||
CustomClippingRect {
|
||||
Layout.fillHeight: true
|
||||
Layout.fillWidth: true
|
||||
color: Colors.tPalette.m3surfaceContainerLow
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
NotifDock {
|
||||
props: root.props
|
||||
visibilities: root.visibilities
|
||||
Item {
|
||||
id: pages
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.top: parent.top
|
||||
implicitWidth: parent.width
|
||||
opacity: root.props.currentTab === 0 ? 1 : 0
|
||||
visible: opacity > 0
|
||||
x: root.props.currentTab === 0 ? 0 : -root.width
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
Behavior on x {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
anchors.fill: parent
|
||||
color: Colors.tPalette.m3surfaceContainerLow
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
NotifDock {
|
||||
props: root.props
|
||||
visibilities: root.visibilities
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: chatPage
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.top: parent.top
|
||||
implicitWidth: parent.width
|
||||
opacity: root.props.currentTab === 1 ? 1 : 0
|
||||
visible: opacity > 0
|
||||
x: root.props.currentTab === 0 ? root.width : 0
|
||||
z: 1
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
Behavior on x {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
anchors.fill: parent
|
||||
color: Colors.tPalette.m3surfaceContainerLow
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
ChatPanel {
|
||||
anchors.fill: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Quickshell
|
||||
import ZShell.Llm
|
||||
|
||||
PersistentProperties {
|
||||
property int currentTab: 0
|
||||
property list<string> expandedNotifs: []
|
||||
|
||||
reloadableId: "sidebar"
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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 PersistentProperties dashState
|
||||
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.dashState.currentTab
|
||||
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.dashState.currentTab = 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
|
||||
font.pointSize: 18
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,11 @@ import qs.Services
|
||||
import qs.Helpers
|
||||
import qs.Daemons
|
||||
import qs.Modules.Settings
|
||||
import qs.Modules.Bar.Popouts as BarPopouts
|
||||
|
||||
CustomRect {
|
||||
id: root
|
||||
|
||||
readonly property bool needExtraRow: quickToggles.length > 6
|
||||
required property BarPopouts.Wrapper popouts
|
||||
readonly property var quickToggles: {
|
||||
const seenIds = new Set();
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Modules.Bar.Popouts as BarPopouts
|
||||
import qs.Modules.Notifications.Sidebar.Utils.Cards
|
||||
import ZShell.Config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property BarPopouts.Wrapper popouts
|
||||
required property PersistentProperties props
|
||||
required property var visibilities
|
||||
|
||||
@@ -21,8 +19,7 @@ Item {
|
||||
anchors.fill: parent
|
||||
spacing: 8
|
||||
|
||||
IdleInhibit {
|
||||
}
|
||||
IdleInhibit {}
|
||||
|
||||
Record {
|
||||
props: root.props
|
||||
@@ -32,7 +29,6 @@ Item {
|
||||
|
||||
Toggles {
|
||||
Layout.fillWidth: true
|
||||
popouts: root.popouts
|
||||
visibilities: root.visibilities
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import QtQuick
|
||||
import Quickshell
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Modules.Bar.Popouts as BarPopouts
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property real offsetScale: shouldBeActive ? 0 : 1
|
||||
required property BarPopouts.Wrapper popouts
|
||||
readonly property PersistentProperties props: PersistentProperties {
|
||||
property string recordingConfirmDelete
|
||||
property bool recordingListExpanded: false
|
||||
@@ -18,12 +16,12 @@ Item {
|
||||
|
||||
reloadableId: "utilities"
|
||||
}
|
||||
readonly property bool shouldBeActive: visibilities.sidebar
|
||||
readonly property bool shouldBeActive: visibilities.sidebar && !sidebar.chatActive
|
||||
required property Item sidebar
|
||||
required property var visibilities
|
||||
|
||||
anchors.bottomMargin: (-implicitHeight - 5) * offsetScale
|
||||
implicitHeight: content.implicitHeight + 8 * 2
|
||||
implicitHeight: content.implicitHeight + Tokens.padding.small * 2
|
||||
implicitWidth: sidebar.width * (1 - sidebar.offsetScale)
|
||||
opacity: 1 - offsetScale
|
||||
visible: offsetScale < 1
|
||||
@@ -45,7 +43,6 @@ Item {
|
||||
|
||||
sourceComponent: Content {
|
||||
implicitWidth: root.implicitWidth - 8 * 2
|
||||
popouts: root.popouts
|
||||
props: root.props
|
||||
visibilities: root.visibilities
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.Components
|
||||
import qs.Components.Toast
|
||||
import ZShell
|
||||
import ZShell.Config
|
||||
import ZShell.Llm
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
Item {
|
||||
@@ -12,6 +16,7 @@ Item {
|
||||
readonly property Props props: Props {
|
||||
}
|
||||
readonly property bool shouldBeActive: root.visibilities.sidebar && Config.sidebar.enabled
|
||||
readonly property bool chatActive: props.currentTab === 1
|
||||
required property var visibilities
|
||||
|
||||
anchors.rightMargin: (-implicitWidth - 5) * offsetScale
|
||||
@@ -26,6 +31,17 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Chat
|
||||
|
||||
function onErrorOccurred(message: string): void {
|
||||
if (root.shouldBeActive && root.chatActive)
|
||||
return;
|
||||
|
||||
Toaster.toast(qsTr("Chat"), message, "error_outline", Toast.Error);
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: content
|
||||
|
||||
|
||||
+20
-17
@@ -73,21 +73,20 @@ Scope {
|
||||
|
||||
// mask: Region { item: inputPanel }
|
||||
|
||||
CustomRect {
|
||||
Rectangle {
|
||||
id: inputPanel
|
||||
|
||||
anchors.centerIn: parent
|
||||
color: Colors.tPalette.m3surface
|
||||
implicitHeight: layout.implicitHeight + layout.anchors.margins * 2
|
||||
implicitWidth: Math.max(layout.implicitWidth + layout.anchors.margins * 2, 450)
|
||||
implicitHeight: layout.childrenRect.height + 28
|
||||
implicitWidth: layout.childrenRect.width + 32
|
||||
opacity: 0
|
||||
radius: Tokens.rounding.small * 2
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.medium
|
||||
anchors.centerIn: parent
|
||||
|
||||
RowLayout {
|
||||
id: contentRow
|
||||
@@ -131,7 +130,7 @@ Scope {
|
||||
Layout.preferredWidth: Math.min(600, contentWidth)
|
||||
font.bold: true
|
||||
font.pointSize: 16
|
||||
text: polkitAgent.flow?.message ?? ""
|
||||
text: polkitAgent.flow?.message
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
@@ -148,8 +147,8 @@ Scope {
|
||||
TextField {
|
||||
id: passInput
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 40
|
||||
Layout.preferredWidth: contentColumn.implicitWidth
|
||||
color: Colors.palette.m3onSurfaceVariant
|
||||
echoMode: polkitAgent.flow?.responseVisible ? TextInput.Normal : TextInput.Password
|
||||
placeholderText: polkitAgent.flow?.failed ? " Incorrect Password" : " Input Password"
|
||||
@@ -169,7 +168,7 @@ Scope {
|
||||
id: showPassCheckbox
|
||||
|
||||
Layout.alignment: Qt.AlignLeft
|
||||
checked: polkitAgent.flow?.responseVisible ?? false
|
||||
checked: polkitAgent.flow?.responseVisible
|
||||
text: "Show Password"
|
||||
|
||||
onCheckedChanged: {
|
||||
@@ -190,8 +189,7 @@ Scope {
|
||||
clip: true
|
||||
color: Colors.tPalette.m3surfaceContainerLow
|
||||
implicitHeight: 0
|
||||
implicitWidth: textDetailsColumn.implicitWidth + textDetailsColumn.anchors.margins * 2
|
||||
radius: Tokens.rounding.medium
|
||||
radius: 16
|
||||
visible: true
|
||||
|
||||
Behavior on open {
|
||||
@@ -199,8 +197,7 @@ Scope {
|
||||
Anim {
|
||||
property: "implicitHeight"
|
||||
target: detailsPanel
|
||||
to: !detailsPanel.open ? textDetailsColumn.implicitHeight + Tokens.padding.small * 2 : 0
|
||||
type: Anim.DefaultEffects
|
||||
to: !detailsPanel.open ? textDetailsColumn.childrenRect.height + 16 : 0
|
||||
}
|
||||
|
||||
Anim {
|
||||
@@ -208,6 +205,12 @@ Scope {
|
||||
target: textDetailsColumn
|
||||
to: !detailsPanel.open ? 1 : 0
|
||||
}
|
||||
|
||||
Anim {
|
||||
property: "scale"
|
||||
target: textDetailsColumn
|
||||
to: !detailsPanel.open ? 1 : 0.9
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,9 +218,10 @@ Scope {
|
||||
id: textDetailsColumn
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.small
|
||||
anchors.margins: 8
|
||||
opacity: 0
|
||||
spacing: Tokens.spacing.small
|
||||
scale: 0.9
|
||||
spacing: 8
|
||||
|
||||
CustomText {
|
||||
text: `actionId: ${polkitAgent.flow?.actionId}`
|
||||
@@ -235,17 +239,16 @@ Scope {
|
||||
Layout.preferredWidth: contentRow.implicitWidth
|
||||
spacing: 8
|
||||
|
||||
IconButton {
|
||||
IconTextButton {
|
||||
id: detailsButton
|
||||
|
||||
Layout.alignment: Qt.AlignLeft
|
||||
horizontalPadding: Tokens.padding.medium
|
||||
icon: "info"
|
||||
inactiveColor: Colors.palette.m3surfaceContainer
|
||||
inactiveOnColor: Colors.palette.m3onSurface
|
||||
isRound: true
|
||||
shapeMorph: true
|
||||
verticalPadding: Tokens.padding.medium
|
||||
text: "Details"
|
||||
|
||||
onClicked: {
|
||||
panelWindow.detailsOpen = !panelWindow.detailsOpen;
|
||||
|
||||
@@ -15,13 +15,19 @@ Item {
|
||||
required property Component content
|
||||
required property string header
|
||||
property int horizontalContentMargin
|
||||
required property string icon
|
||||
property string icon
|
||||
required property string label
|
||||
property alias iconLabel: openButton.iconLabel
|
||||
property alias rowButton: openButton
|
||||
property string subtext: ""
|
||||
property bool open
|
||||
property real openHeight: Math.min(rootParent.height * 0.8, 600)
|
||||
property real openWidth: Math.min(rootParent.width * 0.8, 400)
|
||||
required property Item rootParent
|
||||
property bool separateContent
|
||||
required property string settingAnchor
|
||||
property bool first: false
|
||||
property bool last: false
|
||||
|
||||
signal accepted
|
||||
signal cancelled
|
||||
@@ -44,8 +50,7 @@ Item {
|
||||
color: root.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3surfaceContainer
|
||||
|
||||
Behavior on color {
|
||||
CAnim {
|
||||
}
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +60,7 @@ Item {
|
||||
anchors.fill: parent
|
||||
enabled: false
|
||||
hoverEnabled: enabled
|
||||
preventStealing: true
|
||||
parent: root.open ? root.rootParent : root
|
||||
|
||||
onClicked: root.open = false
|
||||
@@ -75,7 +81,8 @@ Item {
|
||||
backdrop.enabled: true
|
||||
dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased
|
||||
dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased
|
||||
dialogBg.radius: Tokens.rounding.largeIncreased
|
||||
dialogBg.topRightRadius: Tokens.rounding.largeIncreased
|
||||
dialogBg.topLeftRadius: Tokens.rounding.largeIncreased
|
||||
dialogContent.opacity: 1
|
||||
dialogWrapper.height: root.openHeight
|
||||
dialogWrapper.width: root.openWidth
|
||||
@@ -103,7 +110,7 @@ Item {
|
||||
}
|
||||
|
||||
Anim {
|
||||
properties: "opacity,radius,bottomLeftRadius,bottomRightRadius"
|
||||
properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
|
||||
@@ -131,12 +138,13 @@ Item {
|
||||
id: dialogBg
|
||||
|
||||
anchors.fill: parent
|
||||
bottomLeftRadius: Tokens.rounding.largeIncreased
|
||||
bottomRightRadius: Tokens.rounding.largeIncreased
|
||||
bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
bottomRightRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
deformScale: 0
|
||||
group: blobGroup
|
||||
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
|
||||
radius: Tokens.rounding.extraSmall
|
||||
}
|
||||
|
||||
RowButton {
|
||||
@@ -145,9 +153,11 @@ Item {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
color: "transparent"
|
||||
height: Math.min(implicitHeight, parent.height) // Clamp to parent height due to overshoot anim
|
||||
height: Math.min(implicitHeight, parent.height)
|
||||
icon: root.icon
|
||||
last: true
|
||||
subtext: root.subtext
|
||||
last: root.last
|
||||
first: root.first
|
||||
text: root.label
|
||||
|
||||
transform: Matrix4x4 {
|
||||
@@ -167,6 +177,7 @@ Item {
|
||||
|
||||
sourceComponent: MouseArea {
|
||||
onWheel: event => event.accepted = true
|
||||
preventStealing: true
|
||||
|
||||
ColumnLayout {
|
||||
anchors.bottomMargin: Tokens.padding.largeIncreased
|
||||
@@ -219,6 +230,7 @@ Item {
|
||||
text: qsTr("Cancel")
|
||||
type: TextButton.Text
|
||||
verticalPadding: Tokens.padding.small
|
||||
stateLayer.preventStealing: true
|
||||
|
||||
onClicked: {
|
||||
root.cancelled();
|
||||
@@ -232,6 +244,7 @@ Item {
|
||||
isRound: true
|
||||
text: root.acceptLabel
|
||||
type: TextButton.Text
|
||||
stateLayer.preventStealing: true
|
||||
verticalPadding: Tokens.padding.small
|
||||
|
||||
onClicked: {
|
||||
|
||||
@@ -10,6 +10,7 @@ DialogRowButton {
|
||||
|
||||
required property var model
|
||||
property var selectedItem
|
||||
property var initialSelect
|
||||
|
||||
function keyFor(item: var): string {
|
||||
return item.id;
|
||||
@@ -91,7 +92,14 @@ DialogRowButton {
|
||||
}
|
||||
|
||||
onOpenChanged: {
|
||||
if (open)
|
||||
selectedItem = null;
|
||||
if (open) {
|
||||
root.rootParent.interactive = false;
|
||||
if (!initialSelect)
|
||||
selectedItem = null;
|
||||
else
|
||||
selectedItem = initialSelect;
|
||||
} else {
|
||||
root.rootParent.interactive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ ConnectedRect {
|
||||
property alias subtext: subLabel.text
|
||||
property alias text: label.text
|
||||
property string trailingIcon
|
||||
property string activeItem
|
||||
|
||||
signal clicked(event: MouseEvent)
|
||||
|
||||
@@ -40,15 +41,16 @@ ConnectedRect {
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
id: iconLabel
|
||||
|
||||
color: Colors.palette.m3onSurfaceVariant
|
||||
visible: root.icon !== ""
|
||||
fill: 1
|
||||
text: root.icon
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
}
|
||||
|
||||
@@ -79,6 +81,26 @@ ConnectedRect {
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
active: root.activeItem && root.subtext
|
||||
asynchronous: true
|
||||
visible: active
|
||||
Layout.fillHeight: true
|
||||
|
||||
sourceComponent: CustomRect {
|
||||
color: Colors.palette.m3secondaryContainer
|
||||
radius: Tokens.rounding.full
|
||||
implicitWidth: text.implicitWidth + Tokens.padding.medium * 2
|
||||
|
||||
CustomText {
|
||||
id: text
|
||||
anchors.centerIn: parent
|
||||
color: Colors.palette.m3onSecondaryContainer
|
||||
text: root.activeItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
active: root.trailingIcon
|
||||
asynchronous: true
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
DialogRowButton {
|
||||
id: root
|
||||
|
||||
required property var model
|
||||
property var selectedItem
|
||||
property var initialSelect
|
||||
|
||||
acceptAllowed: !!selectedItem
|
||||
horizontalContentMargin: -Tokens.padding.extraSmall
|
||||
separateContent: true
|
||||
|
||||
content: Component {
|
||||
ColumnLayout {
|
||||
VerticalFadeListView {
|
||||
bottomMargin: Tokens.padding.large
|
||||
Layout.fillHeight: true
|
||||
Layout.fillWidth: true
|
||||
model: ScriptModel {
|
||||
values: {
|
||||
const s = searchField.text;
|
||||
var regex = new RegExp(s, "i");
|
||||
|
||||
return root.model.filter(n => regex.test(n));
|
||||
}
|
||||
}
|
||||
spacing: 0
|
||||
topMargin: Tokens.padding.large
|
||||
|
||||
delegate: CustomRect {
|
||||
id: item
|
||||
|
||||
required property var modelData
|
||||
readonly property bool selected: root.selectedItem === modelData
|
||||
|
||||
anchors.left: ListView.view.contentItem.left
|
||||
anchors.margins: 1 // Gets cut off for some reason without this
|
||||
anchors.right: ListView.view.contentItem.right
|
||||
color: Qt.alpha(Colors.palette.m3tertiaryContainer, selected ? 1 : 0)
|
||||
implicitHeight: label.implicitHeight + Tokens.padding.small * 2
|
||||
radius: stateLayer.pressed ? Tokens.rounding.extraSmall : selected ? Tokens.rounding.largeIncreased : Tokens.rounding.medium
|
||||
|
||||
Behavior on radius {
|
||||
Anim {
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
id: stateLayer
|
||||
|
||||
onClicked: root.selectedItem = item.modelData
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: label
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.large
|
||||
anchors.right: item.selected ? checkIcon.left : parent.right
|
||||
anchors.rightMargin: item.selected ? Tokens.spacing.medium : anchors.margins
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: item.selected ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface
|
||||
elide: Text.ElideRight
|
||||
font.pointSize: Tokens.font.size.smaller
|
||||
text: item.modelData
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
id: checkIcon
|
||||
|
||||
anchors.margins: Tokens.padding.large
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: Colors.palette.m3onTertiaryContainer
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
opacity: item.selected ? 1 : 0
|
||||
text: "check"
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
type: Anim.SlowEffects
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SearchBar {
|
||||
id: searchField
|
||||
|
||||
Layout.fillWidth: true
|
||||
bg.border.color: Colors.palette.m3outlineVariant
|
||||
bg.color: Colors.tPalette.m3surfaceContainerLowest
|
||||
clearIcon.font.pointSize: Tokens.font.size.large
|
||||
clearIcon.padding: Tokens.padding.extraSmall
|
||||
font.pointSize: Tokens.font.size.small
|
||||
placeholderText: qsTr("Search...")
|
||||
searchIcon.anchors.leftMargin: Tokens.padding.large
|
||||
searchIcon.font.pointSize: Tokens.font.size.large
|
||||
|
||||
Behavior on bg.border.color {
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onOpenChanged: {
|
||||
if (open) {
|
||||
root.rootParent.interactive = false;
|
||||
if (!initialSelect)
|
||||
selectedItem = null;
|
||||
else
|
||||
selectedItem = initialSelect;
|
||||
} else {
|
||||
root.rootParent.interactive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Blobs
|
||||
import qs.Components
|
||||
import ZShell.Config
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool acceptAllowed: true
|
||||
required property string acceptLabel
|
||||
required property Component content
|
||||
required property string header
|
||||
property int horizontalContentMargin
|
||||
property string icon
|
||||
required property string label
|
||||
property alias iconLabel: openButton.iconText
|
||||
property alias rowButton: openButton
|
||||
property alias contentItem: dialogContent.item
|
||||
property string subtext: ""
|
||||
property bool open
|
||||
property real openHeight: Math.min(rootParent.height * 0.8, 400)
|
||||
property real openWidth: Math.min(rootParent.width * 0.8, 600)
|
||||
required property Item rootParent
|
||||
property bool first: false
|
||||
property bool last: false
|
||||
required property string settingAnchor
|
||||
property color openColor: Colors.palette.m3surfaceContainerHighest
|
||||
|
||||
signal accepted
|
||||
signal cancelled
|
||||
|
||||
function reparentWrapper(): void {
|
||||
const newParent = open ? rootParent : root;
|
||||
const pos = dialogWrapper.mapToItem(newParent, 0, 0);
|
||||
dialogWrapper.parent = newParent;
|
||||
dialogWrapper.x = pos.x;
|
||||
dialogWrapper.y = pos.y;
|
||||
}
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: openButton.implicitHeight
|
||||
z: open || dialogTransition.running ? 2 : 0
|
||||
|
||||
BlobGroup {
|
||||
id: blobGroup
|
||||
|
||||
color: root.open ? root.openColor : Colors.tPalette.m3surfaceContainer
|
||||
|
||||
Behavior on color {
|
||||
CAnim {}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: backdrop
|
||||
|
||||
anchors.fill: parent
|
||||
enabled: false
|
||||
hoverEnabled: enabled
|
||||
preventStealing: true
|
||||
parent: root.open ? root.rootParent : root
|
||||
|
||||
onClicked: root.open = false
|
||||
}
|
||||
|
||||
Item {
|
||||
id: dialogWrapper
|
||||
|
||||
height: openButton.implicitHeight
|
||||
width: root.width
|
||||
z: 1
|
||||
|
||||
states: State {
|
||||
name: "open"
|
||||
when: root.open
|
||||
|
||||
PropertyChanges {
|
||||
backdrop.enabled: true
|
||||
dialogBg.bottomLeftRadius: Tokens.rounding.largeIncreased
|
||||
dialogBg.bottomRightRadius: Tokens.rounding.largeIncreased
|
||||
dialogBg.topRightRadius: Tokens.rounding.largeIncreased
|
||||
dialogBg.topLeftRadius: Tokens.rounding.largeIncreased
|
||||
dialogContent.opacity: 1
|
||||
dialogWrapper.height: root.openHeight
|
||||
dialogWrapper.width: root.openWidth
|
||||
dialogWrapper.x: (root.rootParent.width - root.openWidth) / 2
|
||||
dialogWrapper.y: (root.rootParent.height - root.openHeight) / 2
|
||||
elevation.opacity: 1
|
||||
openButton.opacity: 0
|
||||
}
|
||||
}
|
||||
transitions: Transition {
|
||||
id: dialogTransition
|
||||
|
||||
SequentialAnimation {
|
||||
ScriptAction {
|
||||
script: root.reparentWrapper()
|
||||
}
|
||||
|
||||
Anim {
|
||||
properties: "x,y"
|
||||
}
|
||||
}
|
||||
|
||||
PropertyAction {
|
||||
property: "enabled"
|
||||
}
|
||||
|
||||
Anim {
|
||||
properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
|
||||
type: Anim.DefaultEffects
|
||||
}
|
||||
|
||||
Anim {
|
||||
properties: "width,height"
|
||||
}
|
||||
}
|
||||
|
||||
Elevation {
|
||||
id: elevation
|
||||
|
||||
anchors.fill: parent
|
||||
bottomLeftRadius: dialogBg.bottomLeftRadius
|
||||
bottomRightRadius: dialogBg.bottomRightRadius
|
||||
level: 4
|
||||
opacity: 0
|
||||
radius: dialogBg.radius
|
||||
|
||||
transform: Matrix4x4 {
|
||||
matrix: dialogBg.deformMatrix
|
||||
}
|
||||
}
|
||||
|
||||
BlobRect {
|
||||
id: dialogBg
|
||||
|
||||
anchors.fill: parent
|
||||
bottomLeftRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
bottomRightRadius: root.last ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
topRightRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
topLeftRadius: root.first ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
|
||||
deformScale: 0
|
||||
group: blobGroup
|
||||
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
|
||||
}
|
||||
|
||||
TimeDialogToggle {
|
||||
id: openButton
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
color: "transparent"
|
||||
height: Math.min(implicitHeight, parent.height)
|
||||
subtext: root.subtext
|
||||
last: root.last
|
||||
first: root.first
|
||||
text: root.label
|
||||
|
||||
transform: Matrix4x4 {
|
||||
matrix: dialogBg.deformMatrix
|
||||
}
|
||||
|
||||
onClicked: root.open = true
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: dialogContent
|
||||
|
||||
active: opacity > 0
|
||||
anchors.fill: parent
|
||||
asynchronous: false
|
||||
opacity: 0
|
||||
|
||||
sourceComponent: MouseArea {
|
||||
onWheel: event => event.accepted = true
|
||||
preventStealing: true
|
||||
clip: true
|
||||
implicitWidth: innerLayout.implicitWidth + innerLayout.anchors.margins * 2
|
||||
implicitHeight: innerLayout.implicitHeight + innerLayout.anchors.margins + innerLayout.anchors.bottomMargin
|
||||
|
||||
ColumnLayout {
|
||||
id: innerLayout
|
||||
anchors.bottomMargin: Tokens.padding.largeIncreased
|
||||
anchors.fill: parent
|
||||
anchors.margins: Tokens.padding.extraLarge
|
||||
spacing: 0
|
||||
|
||||
CustomText {
|
||||
font.pointSize: Tokens.font.size.large
|
||||
text: root.header
|
||||
}
|
||||
|
||||
Loader {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: root.horizontalContentMargin
|
||||
Layout.topMargin: Tokens.spacing.medium
|
||||
Layout.bottomMargin: Tokens.spacing.medium
|
||||
Layout.rightMargin: root.horizontalContentMargin
|
||||
sourceComponent: root.content
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.alignment: Qt.AlignRight
|
||||
spacing: Tokens.spacing.extraSmall
|
||||
|
||||
TextButton {
|
||||
horizontalPadding: Tokens.padding.largeIncreased
|
||||
isRound: true
|
||||
text: qsTr("Cancel")
|
||||
type: TextButton.Text
|
||||
verticalPadding: Tokens.padding.small
|
||||
stateLayer.preventStealing: true
|
||||
|
||||
onClicked: {
|
||||
root.cancelled();
|
||||
root.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
TextButton {
|
||||
enabled: root.acceptAllowed
|
||||
horizontalPadding: Tokens.padding.largeIncreased
|
||||
isRound: true
|
||||
text: root.acceptLabel
|
||||
type: TextButton.Text
|
||||
stateLayer.preventStealing: true
|
||||
verticalPadding: Tokens.padding.small
|
||||
|
||||
onClicked: {
|
||||
root.accepted();
|
||||
root.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
transform: Matrix4x4 {
|
||||
matrix: dialogBg.deformMatrix
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import qs.Helpers
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
TimeDialogRow {
|
||||
id: root
|
||||
|
||||
required property int start
|
||||
required property int end
|
||||
property int endCurrent
|
||||
property int startCurrent
|
||||
openColor: Colors.palette.m3surfaceContainer
|
||||
openHeight: Math.min(rootParent.height * 0.8, contentItem?.implicitHeight ?? 0)
|
||||
openWidth: Math.min(rootParent.width * 0.8, contentItem?.implicitWidth ?? 0)
|
||||
|
||||
function keyFor(item: var): string {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function labelFor(item: var): string {
|
||||
return item.label;
|
||||
}
|
||||
|
||||
horizontalContentMargin: -Tokens.padding.extraSmall
|
||||
|
||||
content: Component {
|
||||
TimeInput {
|
||||
id: timeContent
|
||||
start: root.start
|
||||
end: root.end
|
||||
|
||||
onEndEdit: value => root.endCurrent = value
|
||||
onStartEdit: value => root.startCurrent = value
|
||||
}
|
||||
}
|
||||
|
||||
onOpenChanged: {
|
||||
if (open) {
|
||||
root.rootParent.interactive = false;
|
||||
root.z = 5;
|
||||
} else {
|
||||
root.rootParent.interactive = true;
|
||||
root.z = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -10,12 +10,14 @@ ConnectedRect {
|
||||
|
||||
property alias checked: switchButton.checked
|
||||
property int horizontalPadding: Tokens.padding.largeIncreased
|
||||
required property Component popup
|
||||
property alias subtext: subtext.text
|
||||
property alias text: text.text
|
||||
property alias icon: icon
|
||||
property string iconText: "open_in_new"
|
||||
property int verticalPadding: Tokens.padding.small
|
||||
|
||||
signal clicked(checked: bool)
|
||||
signal clicked(event: MouseEvent)
|
||||
signal toggled(checked: bool)
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: layout.implicitHeight + verticalPadding * 2
|
||||
@@ -50,11 +52,11 @@ ConnectedRect {
|
||||
anchors.right: switchButton.left
|
||||
anchors.rightMargin: Tokens.spacing.medium
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "open_in_new"
|
||||
text: root.iconText
|
||||
}
|
||||
|
||||
StateLayer {
|
||||
onClicked: PopupManager.requestOpen(root.popup)
|
||||
onClicked: e => root.clicked(e)
|
||||
}
|
||||
|
||||
CustomSwitch {
|
||||
@@ -64,6 +66,6 @@ ConnectedRect {
|
||||
anchors.rightMargin: root.horizontalPadding
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
onClicked: root.clicked(checked)
|
||||
onClicked: root.toggled(checked)
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,13 @@ import qs.Components
|
||||
import qs.Helpers
|
||||
import qs.Services
|
||||
|
||||
CustomClippingRect {
|
||||
ColumnLayout {
|
||||
id: root
|
||||
|
||||
required property var object
|
||||
required property list<string> settings
|
||||
property bool shouldBeActive: true
|
||||
|
||||
signal applySettings(startTime: int, endTime: int)
|
||||
signal close
|
||||
required property int start
|
||||
required property int end
|
||||
signal endEdit(time: int)
|
||||
signal startEdit(time: int)
|
||||
|
||||
function convertHour(timeValue: int): int {
|
||||
return Math.floor(timeValue / 60);
|
||||
@@ -28,555 +26,459 @@ CustomClippingRect {
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
color: Colors.palette.m3surfaceContainer
|
||||
implicitHeight: column.implicitHeight + column.anchors.margins * 2 + buttonRow.implicitHeight + buttonRow.anchors.topMargin
|
||||
implicitWidth: column.implicitWidth + column.anchors.margins * 2
|
||||
radius: Tokens.rounding.largeIncreased
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
ColumnLayout {
|
||||
id: column
|
||||
RowLayout {
|
||||
CustomRect {
|
||||
id: startHourRect
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.largeIncreased
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
spacing: Tokens.spacing.medium
|
||||
Layout.preferredHeight: 72
|
||||
Layout.preferredWidth: 96
|
||||
color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
implicitHeight: 72
|
||||
implicitWidth: 96
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
CustomRect {
|
||||
anchors.fill: parent
|
||||
border.color: startHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
border.width: startHourField.focus ? 2 : 0
|
||||
radius: parent.radius - border.width
|
||||
|
||||
Behavior on border.width {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldBase {
|
||||
id: startHourField
|
||||
|
||||
function setConfigText(): string {
|
||||
var val = root.convertHour(root.start);
|
||||
if (val === 0) {
|
||||
return "00";
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
clip: true
|
||||
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
|
||||
font.family: "Roboto"
|
||||
font.letterSpacing: -0.25
|
||||
font.pixelSize: 56
|
||||
font.weight: 400
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
text: setConfigText()
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Backspace) {
|
||||
event.accepted = true;
|
||||
if (startHourField.text.length >= 2) {
|
||||
startHourField.text = "0" + startHourField.text[0];
|
||||
} else if (startHourField.text.length === 1) {
|
||||
startHourField.text = "0";
|
||||
}
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
event.accepted = true;
|
||||
startHourField.text = setConfigText();
|
||||
startHourField.focus = false;
|
||||
} else if (event.key === Qt.Key_Return) {
|
||||
startHourField.focus = false;
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Tab) {
|
||||
startMinuteField.focus = true;
|
||||
} else if (event.key === Qt.Key_Backtab) {
|
||||
endMinuteField.focus = true;
|
||||
}
|
||||
|
||||
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
|
||||
event.accepted = true;
|
||||
var digit = event.text;
|
||||
var textLen = startHourField.text.length;
|
||||
|
||||
if (textLen >= 2 && startHourField.text[0] !== '0') {
|
||||
return;
|
||||
}
|
||||
|
||||
var val = 0;
|
||||
if (textLen === 0) {
|
||||
val = parseInt(digit);
|
||||
} else if (textLen === 1) {
|
||||
val = parseInt(startHourField.text + digit);
|
||||
} else {
|
||||
val = parseInt(startHourField.text[1] + digit);
|
||||
}
|
||||
|
||||
val = Math.max(0, Math.min(23, val));
|
||||
|
||||
if (textLen >= 2 && val < 10) {
|
||||
startHourField.text = "0" + val;
|
||||
} else {
|
||||
startHourField.text = val.toString();
|
||||
}
|
||||
}
|
||||
|
||||
event.accepted = true;
|
||||
}
|
||||
onCursorPositionChanged: cursorPosition = 2
|
||||
onTextChanged: {
|
||||
var mins = parseInt(startMinuteField.text);
|
||||
const hours = root.convertToMinutes(parseInt(startHourField.text));
|
||||
root.startEdit(hours + mins);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
text: qsTr("Select time")
|
||||
id: startSeparator
|
||||
|
||||
font.pointSize: Tokens.font.size.extraLarge
|
||||
text: ":"
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
CustomRect {
|
||||
id: startHourRect
|
||||
CustomRect {
|
||||
id: startMinuteRect
|
||||
|
||||
Layout.preferredHeight: 72
|
||||
Layout.preferredWidth: 96
|
||||
color: startHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
implicitHeight: 72
|
||||
implicitWidth: 96
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
CustomRect {
|
||||
anchors.fill: parent
|
||||
border.color: startHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
border.width: startHourField.focus ? 2 : 0
|
||||
radius: parent.radius - border.width
|
||||
|
||||
Behavior on border.width {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldBase {
|
||||
id: startHourField
|
||||
|
||||
function setConfigText(setting: string): string {
|
||||
var val = root.convertHour(root.object[setting]);
|
||||
if (val === 0) {
|
||||
return "00";
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
clip: true
|
||||
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
|
||||
font.family: "Roboto"
|
||||
font.letterSpacing: -0.25
|
||||
font.pixelSize: 56
|
||||
font.weight: 400
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
text: setConfigText(root.settings[1])
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Backspace) {
|
||||
event.accepted = true;
|
||||
if (startHourField.text.length >= 2) {
|
||||
startHourField.text = "0" + startHourField.text[0];
|
||||
} else if (startHourField.text.length === 1) {
|
||||
startHourField.text = "0";
|
||||
}
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
event.accepted = true;
|
||||
startHourField.text = setConfigText(root.settings[1]);
|
||||
startHourField.focus = false;
|
||||
} else if (event.key === Qt.Key_Return) {
|
||||
startHourField.focus = false;
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Tab) {
|
||||
startMinuteField.focus = true;
|
||||
} else if (event.key === Qt.Key_Backtab) {
|
||||
endMinuteField.focus = true;
|
||||
}
|
||||
|
||||
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
|
||||
event.accepted = true;
|
||||
var digit = event.text;
|
||||
var textLen = startHourField.text.length;
|
||||
|
||||
if (textLen >= 2 && startHourField.text[0] !== '0') {
|
||||
return;
|
||||
}
|
||||
|
||||
var val = 0;
|
||||
if (textLen === 0) {
|
||||
val = parseInt(digit);
|
||||
} else if (textLen === 1) {
|
||||
val = parseInt(startHourField.text + digit);
|
||||
} else {
|
||||
val = parseInt(startHourField.text[1] + digit);
|
||||
}
|
||||
|
||||
val = Math.max(0, Math.min(23, val));
|
||||
|
||||
if (textLen >= 2 && val < 10) {
|
||||
startHourField.text = "0" + val;
|
||||
} else {
|
||||
startHourField.text = val.toString();
|
||||
}
|
||||
}
|
||||
|
||||
event.accepted = true;
|
||||
}
|
||||
onCursorPositionChanged: cursorPosition = 2
|
||||
onTextEdited: {
|
||||
if (startHourField.text === "")
|
||||
return;
|
||||
var val = parseInt(startHourField.text);
|
||||
if (isNaN(val))
|
||||
return;
|
||||
val = Math.max(0, Math.min(23, val));
|
||||
var newText = val.toString();
|
||||
if (newText !== startHourField.text)
|
||||
startHourField.text = newText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: startSeparator
|
||||
|
||||
font.pointSize: Tokens.font.size.extraLarge
|
||||
text: ":"
|
||||
}
|
||||
Layout.preferredHeight: 72
|
||||
Layout.preferredWidth: 96
|
||||
color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
implicitHeight: 72
|
||||
implicitWidth: 96
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
CustomRect {
|
||||
id: startMinuteRect
|
||||
anchors.fill: parent
|
||||
border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
border.width: startMinuteField.focus ? 2 : 0
|
||||
radius: parent.radius - border.width
|
||||
|
||||
Layout.preferredHeight: 72
|
||||
Layout.preferredWidth: 96
|
||||
color: startMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
implicitHeight: 72
|
||||
implicitWidth: 96
|
||||
radius: Tokens.rounding.small
|
||||
Behavior on border.width {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
anchors.fill: parent
|
||||
border.color: startMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
border.width: startMinuteField.focus ? 2 : 0
|
||||
radius: parent.radius - border.width
|
||||
TextFieldBase {
|
||||
id: startMinuteField
|
||||
|
||||
Behavior on border.width {
|
||||
Anim {
|
||||
}
|
||||
function setConfigText(): string {
|
||||
var val = root.convertMinute(root.start);
|
||||
if (val === 0) {
|
||||
return "00";
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
TextFieldBase {
|
||||
id: startMinuteField
|
||||
|
||||
function setConfigText(setting: string): string {
|
||||
var val = root.convertMinute(root.object[setting]);
|
||||
if (val === 0) {
|
||||
return "00";
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
clip: true
|
||||
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
|
||||
font.family: "Roboto"
|
||||
font.letterSpacing: -0.25
|
||||
font.pixelSize: 56
|
||||
font.weight: 400
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
text: setConfigText(root.settings[1])
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Backspace) {
|
||||
event.accepted = true;
|
||||
if (startMinuteField.text.length >= 2) {
|
||||
startMinuteField.text = "0" + startMinuteField.text[0];
|
||||
} else if (startMinuteField.text.length === 1) {
|
||||
startMinuteField.text = "0";
|
||||
}
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
event.accepted = true;
|
||||
startMinuteField.text = setConfigText(root.settings[1]);
|
||||
startMinuteField.focus = false;
|
||||
} else if (event.key === Qt.Key_Return) {
|
||||
startMinuteField.focus = false;
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Tab) {
|
||||
endHourField.focus = true;
|
||||
} else if (event.key === Qt.Key_Backtab) {
|
||||
startHourField.focus = true;
|
||||
}
|
||||
|
||||
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
|
||||
event.accepted = true;
|
||||
var digit = event.text;
|
||||
var textLen = startMinuteField.text.length;
|
||||
|
||||
if (textLen >= 2 && startMinuteField.text[0] !== '0') {
|
||||
return;
|
||||
}
|
||||
|
||||
var val = 0;
|
||||
if (textLen === 0) {
|
||||
val = parseInt(digit);
|
||||
} else if (textLen === 1) {
|
||||
val = parseInt(startMinuteField.text + digit);
|
||||
} else {
|
||||
val = parseInt(startMinuteField.text[1] + digit);
|
||||
}
|
||||
|
||||
val = Math.max(0, Math.min(59, val));
|
||||
|
||||
if (textLen >= 2 && val < 10) {
|
||||
startMinuteField.text = "0" + val;
|
||||
} else {
|
||||
startMinuteField.text = val.toString();
|
||||
}
|
||||
}
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
clip: true
|
||||
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
|
||||
font.family: "Roboto"
|
||||
font.letterSpacing: -0.25
|
||||
font.pixelSize: 56
|
||||
font.weight: 400
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
text: setConfigText()
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Backspace) {
|
||||
event.accepted = true;
|
||||
}
|
||||
onCursorPositionChanged: cursorPosition = 2
|
||||
onTextEdited: {
|
||||
if (startMinuteField.text === "")
|
||||
return;
|
||||
var val = parseInt(startMinuteField.text);
|
||||
if (isNaN(val))
|
||||
return;
|
||||
val = Math.max(0, Math.min(23, val));
|
||||
var newText = val.toString();
|
||||
if (newText !== startMinuteField.text)
|
||||
startMinuteField.text = newText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: spacer
|
||||
|
||||
Layout.preferredWidth: Tokens.spacing.largeIncreased
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: endHourRect
|
||||
|
||||
Layout.preferredHeight: 72
|
||||
Layout.preferredWidth: 96
|
||||
color: endHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
implicitHeight: 72
|
||||
implicitWidth: 96
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
CustomRect {
|
||||
anchors.fill: parent
|
||||
border.color: endHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
border.width: endHourField.focus ? 2 : 0
|
||||
radius: parent.radius - border.width
|
||||
|
||||
Behavior on border.width {
|
||||
Anim {
|
||||
if (startMinuteField.text.length >= 2) {
|
||||
startMinuteField.text = "0" + startMinuteField.text[0];
|
||||
} else if (startMinuteField.text.length === 1) {
|
||||
startMinuteField.text = "0";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldBase {
|
||||
id: endHourField
|
||||
|
||||
function setConfigText(setting: string): string {
|
||||
var val = root.convertHour(root.object[setting]);
|
||||
if (val === 0) {
|
||||
return "00";
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
clip: true
|
||||
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
|
||||
font.family: "Roboto"
|
||||
font.letterSpacing: -0.25
|
||||
font.pixelSize: 56
|
||||
font.weight: 400
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
text: setConfigText(root.settings[2])
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Backspace) {
|
||||
event.accepted = true;
|
||||
if (endHourField.text.length >= 2) {
|
||||
endHourField.text = "0" + endHourField.text[0];
|
||||
} else if (endHourField.text.length === 1) {
|
||||
endHourField.text = "0";
|
||||
}
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
event.accepted = true;
|
||||
endHourField.text = setConfigText(root.settings[2]);
|
||||
endHourField.focus = false;
|
||||
} else if (event.key === Qt.Key_Return) {
|
||||
endHourField.focus = false;
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Tab) {
|
||||
endMinuteField.focus = true;
|
||||
} else if (event.key === Qt.Key_Backtab) {
|
||||
startMinuteField.focus = true;
|
||||
}
|
||||
|
||||
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
|
||||
event.accepted = true;
|
||||
var digit = event.text;
|
||||
var textLen = endHourField.text.length;
|
||||
|
||||
if (textLen >= 2 && endHourField.text[0] !== '0') {
|
||||
return;
|
||||
}
|
||||
|
||||
var val = 0;
|
||||
if (textLen === 0) {
|
||||
val = parseInt(digit);
|
||||
} else if (textLen === 1) {
|
||||
val = parseInt(endHourField.text + digit);
|
||||
} else {
|
||||
val = parseInt(endHourField.text[1] + digit);
|
||||
}
|
||||
|
||||
val = Math.max(0, Math.min(23, val));
|
||||
|
||||
if (textLen >= 2 && val < 10) {
|
||||
endHourField.text = "0" + val;
|
||||
} else {
|
||||
endHourField.text = val.toString();
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
event.accepted = true;
|
||||
}
|
||||
onCursorPositionChanged: cursorPosition = 2
|
||||
onTextEdited: {
|
||||
if (endHourField.text === "")
|
||||
return;
|
||||
var val = parseInt(endHourField.text);
|
||||
if (isNaN(val))
|
||||
return;
|
||||
val = Math.max(0, Math.min(23, val));
|
||||
var newText = val.toString();
|
||||
if (newText !== endHourField.text)
|
||||
endHourField.text = newText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: endSeparator
|
||||
|
||||
font.pointSize: Tokens.font.size.extraLarge
|
||||
text: ":"
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: endMinuteRect
|
||||
|
||||
Layout.preferredHeight: 72
|
||||
Layout.preferredWidth: 96
|
||||
color: endMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
implicitHeight: 72
|
||||
implicitWidth: 96
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
CustomRect {
|
||||
anchors.fill: parent
|
||||
border.color: endMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
border.width: endMinuteField.focus ? 2 : 0
|
||||
radius: parent.radius - border.width
|
||||
|
||||
Behavior on border.width {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldBase {
|
||||
id: endMinuteField
|
||||
|
||||
function setConfigText(setting: string): string {
|
||||
var val = root.convertMinute(root.object[setting]);
|
||||
if (val === 0) {
|
||||
return "00";
|
||||
}
|
||||
return String(val);
|
||||
startMinuteField.text = setConfigText();
|
||||
startMinuteField.focus = false;
|
||||
} else if (event.key === Qt.Key_Return) {
|
||||
startMinuteField.focus = false;
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Tab) {
|
||||
endHourField.focus = true;
|
||||
} else if (event.key === Qt.Key_Backtab) {
|
||||
startHourField.focus = true;
|
||||
}
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
clip: true
|
||||
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
|
||||
font.family: "Roboto"
|
||||
font.letterSpacing: -0.25
|
||||
font.pixelSize: 56
|
||||
font.weight: 400
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
text: setConfigText(root.settings[2])
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Backspace) {
|
||||
event.accepted = true;
|
||||
if (endMinuteField.text.length >= 2) {
|
||||
endMinuteField.text = "0" + endMinuteField.text[0];
|
||||
} else if (endMinuteField.text.length === 1) {
|
||||
endMinuteField.text = "0";
|
||||
}
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
event.accepted = true;
|
||||
endMinuteField.text = setConfigText(root.settings[2]);
|
||||
endMinuteField.focus = false;
|
||||
} else if (event.key === Qt.Key_Return) {
|
||||
endMinuteField.focus = false;
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Tab) {
|
||||
startHourField.focus = true;
|
||||
} else if (event.key === Qt.Key_Backtab) {
|
||||
endHourField.focus = true;
|
||||
}
|
||||
|
||||
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
|
||||
event.accepted = true;
|
||||
var digit = event.text;
|
||||
var textLen = endMinuteField.text.length;
|
||||
|
||||
if (textLen >= 2 && endMinuteField.text[0] !== '0') {
|
||||
return;
|
||||
}
|
||||
|
||||
var val = 0;
|
||||
if (textLen === 0) {
|
||||
val = parseInt(digit);
|
||||
} else if (textLen === 1) {
|
||||
val = parseInt(endMinuteField.text + digit);
|
||||
} else {
|
||||
val = parseInt(endMinuteField.text[1] + digit);
|
||||
}
|
||||
|
||||
val = Math.max(0, Math.min(59, val));
|
||||
|
||||
if (textLen >= 2 && val < 10) {
|
||||
endMinuteField.text = "0" + val;
|
||||
} else {
|
||||
endMinuteField.text = val.toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
|
||||
event.accepted = true;
|
||||
}
|
||||
onCursorPositionChanged: cursorPosition = 2
|
||||
onTextEdited: {
|
||||
if (endMinuteField.text === "")
|
||||
var digit = event.text;
|
||||
var textLen = startMinuteField.text.length;
|
||||
|
||||
if (textLen >= 2 && startMinuteField.text[0] !== '0') {
|
||||
return;
|
||||
var val = parseInt(endMinuteField.text);
|
||||
if (isNaN(val))
|
||||
return;
|
||||
val = Math.max(0, Math.min(23, val));
|
||||
var newText = val.toString();
|
||||
if (newText !== endMinuteField.text)
|
||||
endMinuteField.text = newText;
|
||||
}
|
||||
|
||||
var val = 0;
|
||||
if (textLen === 0) {
|
||||
val = parseInt(digit);
|
||||
} else if (textLen === 1) {
|
||||
val = parseInt(startMinuteField.text + digit);
|
||||
} else {
|
||||
val = parseInt(startMinuteField.text[1] + digit);
|
||||
}
|
||||
|
||||
val = Math.max(0, Math.min(59, val));
|
||||
|
||||
if (textLen >= 2 && val < 10) {
|
||||
startMinuteField.text = "0" + val;
|
||||
} else {
|
||||
startMinuteField.text = val.toString();
|
||||
}
|
||||
}
|
||||
|
||||
event.accepted = true;
|
||||
}
|
||||
onCursorPositionChanged: cursorPosition = 2
|
||||
onTextChanged: {
|
||||
var mins = parseInt(startMinuteField.text);
|
||||
const hours = root.convertToMinutes(parseInt(startHourField.text));
|
||||
root.startEdit(hours + mins);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
CustomText {
|
||||
Layout.preferredWidth: spacer.x + spacer.width
|
||||
text: qsTr("Start")
|
||||
Item {
|
||||
id: spacer
|
||||
|
||||
Layout.preferredWidth: Tokens.spacing.largeIncreased
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: endHourRect
|
||||
|
||||
Layout.preferredHeight: 72
|
||||
Layout.preferredWidth: 96
|
||||
color: endHourField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
implicitHeight: 72
|
||||
implicitWidth: 96
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
CustomRect {
|
||||
anchors.fill: parent
|
||||
border.color: endHourField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
border.width: endHourField.focus ? 2 : 0
|
||||
radius: parent.radius - border.width
|
||||
|
||||
Behavior on border.width {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
Layout.preferredWidth: endMinuteRect.width + endHourRect.width
|
||||
text: qsTr("End")
|
||||
TextFieldBase {
|
||||
id: endHourField
|
||||
|
||||
function setConfigText(): string {
|
||||
var val = root.convertHour(root.end);
|
||||
if (val === 0) {
|
||||
return "00";
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
clip: true
|
||||
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
|
||||
font.family: "Roboto"
|
||||
font.letterSpacing: -0.25
|
||||
font.pixelSize: 56
|
||||
font.weight: 400
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
text: setConfigText()
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Backspace) {
|
||||
event.accepted = true;
|
||||
if (endHourField.text.length >= 2) {
|
||||
endHourField.text = "0" + endHourField.text[0];
|
||||
} else if (endHourField.text.length === 1) {
|
||||
endHourField.text = "0";
|
||||
}
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
event.accepted = true;
|
||||
endHourField.text = setConfigText();
|
||||
endHourField.focus = false;
|
||||
} else if (event.key === Qt.Key_Return) {
|
||||
endHourField.focus = false;
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Tab) {
|
||||
endMinuteField.focus = true;
|
||||
} else if (event.key === Qt.Key_Backtab) {
|
||||
startMinuteField.focus = true;
|
||||
}
|
||||
|
||||
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
|
||||
event.accepted = true;
|
||||
var digit = event.text;
|
||||
var textLen = endHourField.text.length;
|
||||
|
||||
if (textLen >= 2 && endHourField.text[0] !== '0') {
|
||||
return;
|
||||
}
|
||||
|
||||
var val = 0;
|
||||
if (textLen === 0) {
|
||||
val = parseInt(digit);
|
||||
} else if (textLen === 1) {
|
||||
val = parseInt(endHourField.text + digit);
|
||||
} else {
|
||||
val = parseInt(endHourField.text[1] + digit);
|
||||
}
|
||||
|
||||
val = Math.max(0, Math.min(23, val));
|
||||
|
||||
if (textLen >= 2 && val < 10) {
|
||||
endHourField.text = "0" + val;
|
||||
} else {
|
||||
endHourField.text = val.toString();
|
||||
}
|
||||
}
|
||||
|
||||
event.accepted = true;
|
||||
}
|
||||
onCursorPositionChanged: cursorPosition = 2
|
||||
onTextChanged: {
|
||||
var mins = parseInt(endMinuteField.text);
|
||||
const hours = root.convertToMinutes(parseInt(endHourField.text));
|
||||
root.endEdit(hours + mins);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomText {
|
||||
id: endSeparator
|
||||
|
||||
font.pointSize: Tokens.font.size.extraLarge
|
||||
text: ":"
|
||||
}
|
||||
|
||||
CustomRect {
|
||||
id: endMinuteRect
|
||||
|
||||
Layout.preferredHeight: 72
|
||||
Layout.preferredWidth: 96
|
||||
color: endMinuteField.focus ? Colors.palette.m3onPrimaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
implicitHeight: 72
|
||||
implicitWidth: 96
|
||||
radius: Tokens.rounding.small
|
||||
|
||||
CustomRect {
|
||||
anchors.fill: parent
|
||||
border.color: endMinuteField.focus ? Colors.palette.m3primaryContainer : Colors.palette.m3surfaceContainerHighest
|
||||
border.width: endMinuteField.focus ? 2 : 0
|
||||
radius: parent.radius - border.width
|
||||
|
||||
Behavior on border.width {
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldBase {
|
||||
id: endMinuteField
|
||||
|
||||
function setConfigText(): string {
|
||||
var val = root.convertMinute(root.end);
|
||||
if (val === 0) {
|
||||
return "00";
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
clip: true
|
||||
color: focus ? Colors.palette.m3primaryContainer : Colors.palette.m3onSurface
|
||||
font.family: "Roboto"
|
||||
font.letterSpacing: -0.25
|
||||
font.pixelSize: 56
|
||||
font.weight: 400
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
text: setConfigText()
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Backspace) {
|
||||
event.accepted = true;
|
||||
if (endMinuteField.text.length >= 2) {
|
||||
endMinuteField.text = "0" + endMinuteField.text[0];
|
||||
} else if (endMinuteField.text.length === 1) {
|
||||
endMinuteField.text = "0";
|
||||
}
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Escape) {
|
||||
event.accepted = true;
|
||||
endMinuteField.text = setConfigText();
|
||||
endMinuteField.focus = false;
|
||||
} else if (event.key === Qt.Key_Return) {
|
||||
endMinuteField.focus = false;
|
||||
return;
|
||||
} else if (event.key === Qt.Key_Tab) {
|
||||
startHourField.focus = true;
|
||||
} else if (event.key === Qt.Key_Backtab) {
|
||||
endHourField.focus = true;
|
||||
}
|
||||
|
||||
if (event.text.length === 1 && event.text >= "0" && event.text <= "9") {
|
||||
event.accepted = true;
|
||||
var digit = event.text;
|
||||
var textLen = endMinuteField.text.length;
|
||||
|
||||
if (textLen >= 2 && endMinuteField.text[0] !== '0') {
|
||||
return;
|
||||
}
|
||||
|
||||
var val = 0;
|
||||
if (textLen === 0) {
|
||||
val = parseInt(digit);
|
||||
} else if (textLen === 1) {
|
||||
val = parseInt(endMinuteField.text + digit);
|
||||
} else {
|
||||
val = parseInt(endMinuteField.text[1] + digit);
|
||||
}
|
||||
|
||||
val = Math.max(0, Math.min(59, val));
|
||||
|
||||
if (textLen >= 2 && val < 10) {
|
||||
endMinuteField.text = "0" + val;
|
||||
} else {
|
||||
endMinuteField.text = val.toString();
|
||||
}
|
||||
}
|
||||
|
||||
event.accepted = true;
|
||||
}
|
||||
onCursorPositionChanged: cursorPosition = 2
|
||||
onTextChanged: {
|
||||
var mins = parseInt(endMinuteField.text);
|
||||
const hours = root.convertToMinutes(parseInt(endHourField.text));
|
||||
root.endEdit(hours + mins);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: buttonRow
|
||||
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.margins: Tokens.padding.largeIncreased
|
||||
anchors.right: parent.right
|
||||
anchors.top: column.bottom
|
||||
anchors.topMargin: Tokens.spacing.medium
|
||||
|
||||
Item {
|
||||
id: buttonSpacer
|
||||
|
||||
Layout.fillWidth: true
|
||||
CustomText {
|
||||
Layout.preferredWidth: spacer.x + spacer.width
|
||||
text: qsTr("Start")
|
||||
}
|
||||
|
||||
ButtonRow {
|
||||
spacing: Tokens.spacing.medium
|
||||
|
||||
IconTextButton {
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
icon: "close"
|
||||
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2)
|
||||
inactiveOnColor: Colors.palette.m3onSurfaceVariant
|
||||
isRound: true
|
||||
isToggle: false
|
||||
shapeMorph: true
|
||||
shapeMorphExpansion: pressed ? 12 : 0
|
||||
text: "Cancel"
|
||||
|
||||
onClicked: root.close()
|
||||
}
|
||||
|
||||
IconTextButton {
|
||||
font.pointSize: Tokens.font.size.normal
|
||||
icon: "check"
|
||||
isRound: true
|
||||
isToggle: false
|
||||
shapeMorph: true
|
||||
shapeMorphExpansion: pressed ? 12 : 0
|
||||
text: "Apply"
|
||||
|
||||
onClicked: {
|
||||
const start = root.convertToMinutes(parseInt(startHourField.text)) + parseInt(startMinuteField.text);
|
||||
const end = root.convertToMinutes(parseInt(endHourField.text)) + parseInt(endMinuteField.text);
|
||||
root.applySettings(start, end);
|
||||
}
|
||||
}
|
||||
CustomText {
|
||||
Layout.preferredWidth: endMinuteRect.width + endHourRect.width
|
||||
text: qsTr("End")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ CustomSwitch {
|
||||
property alias last: bg.last
|
||||
property string settingAnchor
|
||||
property string subtext
|
||||
property string iconText
|
||||
|
||||
function flashHighlight(): void {
|
||||
bg.flashHighlight();
|
||||
@@ -63,8 +64,7 @@ CustomSwitch {
|
||||
text: root.text
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,10 +81,16 @@ CustomSwitch {
|
||||
visible: root.subtext
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
Anim {}
|
||||
}
|
||||
}
|
||||
|
||||
MaterialIcon {
|
||||
id: icon
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.iconText ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,7 @@ CustomClippingRect {
|
||||
radius: Tokens.rounding.largeIncreased + Tokens.padding.small
|
||||
|
||||
Behavior on blobColor {
|
||||
CAnim {
|
||||
}
|
||||
CAnim {}
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
@@ -70,8 +69,4 @@ CustomClippingRect {
|
||||
anchors.top: parent.top
|
||||
sState: root.sState
|
||||
}
|
||||
|
||||
PopupOverlay {
|
||||
anchors.fill: parent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import qs.Modules.Settings.Pages.Audio
|
||||
import qs.Modules.Settings.Pages.Apps
|
||||
import qs.Modules.Settings.Pages.Panels
|
||||
import qs.Modules.Settings.Pages.Panels.Bar
|
||||
import qs.Modules.Settings.Pages.Panels.Sidebar
|
||||
import qs.Modules.Settings.Pages.Services
|
||||
import qs.Services
|
||||
|
||||
@@ -23,13 +24,15 @@ QtObject {
|
||||
// Wallpaper & style
|
||||
StackPage {
|
||||
Component {
|
||||
WallpaperPage {
|
||||
}
|
||||
WallpaperPage {}
|
||||
}
|
||||
|
||||
Component {
|
||||
WallpaperSelect {
|
||||
}
|
||||
WallpaperSelect {}
|
||||
}
|
||||
|
||||
Component {
|
||||
ColorsFonts {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -37,32 +40,27 @@ QtObject {
|
||||
// Screenshot
|
||||
StackPage {
|
||||
Component {
|
||||
ScreenshotPage {
|
||||
}
|
||||
ScreenshotPage {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Connectivity
|
||||
Component {
|
||||
PlaceholderComp {
|
||||
}
|
||||
PlaceholderComp {}
|
||||
},
|
||||
Component {
|
||||
PlaceholderComp {
|
||||
}
|
||||
PlaceholderComp {}
|
||||
},
|
||||
Component {
|
||||
// Audio
|
||||
StackPage {
|
||||
Component {
|
||||
AudioPage {
|
||||
}
|
||||
AudioPage {}
|
||||
}
|
||||
|
||||
Component {
|
||||
AppVolumes {
|
||||
}
|
||||
AppVolumes {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -71,49 +69,45 @@ QtObject {
|
||||
Component {
|
||||
StackPage {
|
||||
Component {
|
||||
PanelsPage {
|
||||
}
|
||||
PanelsPage {}
|
||||
}
|
||||
|
||||
Component {
|
||||
BarPanel {
|
||||
}
|
||||
BarPanel {}
|
||||
}
|
||||
|
||||
Component {
|
||||
DashboardPanel {
|
||||
}
|
||||
DashboardPanel {}
|
||||
}
|
||||
|
||||
Component {
|
||||
ResourcesPanel {
|
||||
}
|
||||
ResourcesPanel {}
|
||||
}
|
||||
|
||||
Component {
|
||||
LauncherPanel {
|
||||
}
|
||||
LauncherPanel {}
|
||||
}
|
||||
|
||||
Component {
|
||||
SidebarPanel {
|
||||
}
|
||||
SidebarPanel {}
|
||||
}
|
||||
|
||||
// Bar sub pages
|
||||
Component {
|
||||
BarTray {
|
||||
}
|
||||
BarTray {}
|
||||
}
|
||||
|
||||
Component {
|
||||
BarStatusIcons {
|
||||
}
|
||||
BarStatusIcons {}
|
||||
}
|
||||
|
||||
Component {
|
||||
BarClock {
|
||||
}
|
||||
BarClock {}
|
||||
}
|
||||
|
||||
// Sidebar sub pages
|
||||
Component {
|
||||
SidebarLlm {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -122,18 +116,15 @@ QtObject {
|
||||
Component {
|
||||
StackPage {
|
||||
Component {
|
||||
AppsPage {
|
||||
}
|
||||
AppsPage {}
|
||||
}
|
||||
|
||||
Component {
|
||||
AllApps {
|
||||
}
|
||||
AllApps {}
|
||||
}
|
||||
|
||||
Component {
|
||||
AppInfo {
|
||||
}
|
||||
AppInfo {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -142,13 +133,11 @@ QtObject {
|
||||
Component {
|
||||
StackPage {
|
||||
Component {
|
||||
ServicesPage {
|
||||
}
|
||||
ServicesPage {}
|
||||
}
|
||||
|
||||
Component {
|
||||
NotificationsPage {
|
||||
}
|
||||
NotificationsPage {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -157,15 +146,13 @@ QtObject {
|
||||
Component {
|
||||
StackPage {
|
||||
Component {
|
||||
AboutPage {
|
||||
}
|
||||
AboutPage {}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
readonly property Component placeholderComp: Component {
|
||||
PlaceholderComp {
|
||||
}
|
||||
PlaceholderComp {}
|
||||
}
|
||||
|
||||
component PlaceholderComp: Item {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import QtQuick
|
||||
import qs.Components
|
||||
import ZShell.Config
|
||||
import qs.Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -41,6 +42,18 @@ Item {
|
||||
objectName: "PageContainer"
|
||||
|
||||
Component.onCompleted: root.loadPage(root.sState.currentPageIdx)
|
||||
|
||||
// CustomRect {
|
||||
// anchors.fill: parent
|
||||
// color: Qt.alpha(Colors.palette.m3shadow, 0.3)
|
||||
// opacity: root.sState.dimmed ? 1 : 0
|
||||
// visible: opacity > 0
|
||||
// z: 0
|
||||
//
|
||||
// Behavior on opacity {
|
||||
// Anim {}
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
Connections {
|
||||
|
||||
@@ -55,6 +55,11 @@ PageBase {
|
||||
enabled: Object.keys(model).length > 0
|
||||
header: qsTr("Add new entry")
|
||||
icon: "add"
|
||||
settingAnchor: "panels-bar-status-icons-add-item"
|
||||
onOpenChanged: {
|
||||
root.sState.dimmed = open;
|
||||
}
|
||||
last: true
|
||||
label: qsTr("Add entry")
|
||||
model: {
|
||||
const present = new Set(Config.bar.tray.statusIcons.values.map(item => item.id));
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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("Appearance")
|
||||
}
|
||||
|
||||
DialogSelectButton {
|
||||
id: selectScheme
|
||||
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: Object.keys(model).length > 0
|
||||
settingAnchor: "panels-sidebar-select-scheme"
|
||||
separateContent: false
|
||||
header: qsTr("Select scheme")
|
||||
first: true
|
||||
last: true
|
||||
icon: "format_paint"
|
||||
iconLabel.font.pointSize: Tokens.font.size.large
|
||||
rowButton.trailingIcon: "open_in_new"
|
||||
onOpenChanged: {
|
||||
root.sState.dimmed = open;
|
||||
}
|
||||
rowButton.activeItem: root.schemes.find(s => s.id === Config.llm.appearance.scheme).label
|
||||
label: qsTr("Color scheme")
|
||||
subtext: qsTr("Select the color scheme used for code blocks")
|
||||
model: root.schemes
|
||||
rootParent: root.flickable
|
||||
|
||||
initialSelect: Config.llm.appearance.scheme
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
Config.llm.appearance.scheme = selectedItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,12 @@ PageBase {
|
||||
enabled: Object.keys(model).length > 0
|
||||
header: qsTr("Add new entry")
|
||||
icon: "add"
|
||||
onOpenChanged: {
|
||||
root.sState.dimmed = open;
|
||||
}
|
||||
settingAnchor: "panels-sidebar-quick-toggles-add-item"
|
||||
label: qsTr("Add entry")
|
||||
last: true
|
||||
model: {
|
||||
const present = new Set(Config.utilities.quickToggles.values.map(item => item.id));
|
||||
return Object.keys(root.builtinIcons).filter(id => !present.has(id)).map(id => ({
|
||||
@@ -108,5 +113,18 @@ PageBase {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
SectionHeader {
|
||||
text: qsTr("AI chat")
|
||||
}
|
||||
|
||||
NavRow {
|
||||
text: qsTr("AI chat")
|
||||
icon: "robot_2"
|
||||
first: true
|
||||
last: true
|
||||
|
||||
onClicked: root.sState.openSubPage(9)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,12 +202,12 @@ PageBase {
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
checked: Config.general.color.smart
|
||||
checked: Config.colors.smart
|
||||
settingAnchor: "services-smart-color-scheme"
|
||||
subtext: qsTr("Derive theme mode from the wallpaper")
|
||||
text: qsTr("Smart color scheme")
|
||||
|
||||
onToggled: Config.general.color.smart = checked
|
||||
onToggled: Config.colors.smart = checked
|
||||
}
|
||||
|
||||
SelectRow {
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import ZShell.Config
|
||||
import qs.Paths
|
||||
import qs.Services
|
||||
import qs.Modules.Settings.Common
|
||||
|
||||
PageBase {
|
||||
id: root
|
||||
|
||||
readonly property list<string> fonts: Qt.fontFamilies()
|
||||
readonly property var schemes: [
|
||||
{
|
||||
id: "onedark",
|
||||
label: qsTr("One Dark")
|
||||
},
|
||||
{
|
||||
id: "nord",
|
||||
label: qsTr("Nord")
|
||||
},
|
||||
{
|
||||
id: "dracula",
|
||||
label: qsTr("Dracula")
|
||||
},
|
||||
{
|
||||
id: "darkgreen",
|
||||
label: qsTr("Darkgreen")
|
||||
},
|
||||
{
|
||||
id: "solarized",
|
||||
label: qsTr("Solarized")
|
||||
},
|
||||
{
|
||||
id: "rosepine",
|
||||
label: qsTr("Rosepine")
|
||||
},
|
||||
{
|
||||
id: "gruvbox",
|
||||
label: qsTr("Gruvbox")
|
||||
},
|
||||
{
|
||||
id: "catppuccin",
|
||||
label: qsTr("Catppuccin")
|
||||
},
|
||||
{
|
||||
id: "tokyonight",
|
||||
label: qsTr("Tokyo Night")
|
||||
},
|
||||
{
|
||||
id: "shadotheme",
|
||||
label: qsTr("Shadotheme")
|
||||
}
|
||||
]
|
||||
|
||||
isSubPage: true
|
||||
title: qsTr("Colors & font")
|
||||
|
||||
ColumnLayout {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
spacing: Tokens.spacing.extraSmall / 2
|
||||
width: root.cappedWidth
|
||||
|
||||
SectionHeader {
|
||||
first: true
|
||||
text: qsTr("Colors")
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
text: qsTr("Dynamic colors")
|
||||
first: true
|
||||
subtext: qsTr("Automatic color scheme based on wallpaper is %1").arg(Config.colors.schemeGen ? "enabled" : "disabled")
|
||||
checked: Config.colors.schemeGen
|
||||
settingAnchor: "colors-fonts-dynamic-colors"
|
||||
|
||||
onToggled: {
|
||||
Config.colors.schemeGen = checked;
|
||||
|
||||
if (checked)
|
||||
Colors.setMode(Config.colors.mode);
|
||||
}
|
||||
}
|
||||
|
||||
DialogSelectButton {
|
||||
settingAnchor: "colors-fonts-preset-scheme"
|
||||
model: root.schemes
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: !Config.colors.schemeGen
|
||||
header: qsTr("Select scheme")
|
||||
rowButton.activeItem: root.schemes.find(s => {
|
||||
var regex = new RegExp(Config.colors.presets.name, "i");
|
||||
return regex.test(s.id);
|
||||
}).label ?? ""
|
||||
label: qsTr("Preset scheme")
|
||||
subtext: qsTr("Select preset color scheme")
|
||||
rootParent: root.flickable
|
||||
|
||||
FileView {
|
||||
id: view
|
||||
path: `${Paths.state}/presets.json`
|
||||
|
||||
watchChanges: true
|
||||
onFileChanged: reload()
|
||||
|
||||
JsonAdapter {
|
||||
id: adapter
|
||||
|
||||
property var presets
|
||||
}
|
||||
}
|
||||
|
||||
initialSelect: Config.colors.presets.name.toLowerCase()
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
const s = selectedItem.toLowerCase();
|
||||
let v = "";
|
||||
let a = "";
|
||||
const existingVariant = Config.colors.presets.variant;
|
||||
const existingAccent = Config.colors.presets.accent;
|
||||
const variantKeys = Object.keys(adapter.presets[s].variants);
|
||||
|
||||
if (existingVariant !== "" && variantKeys.some(v => v === existingVariant))
|
||||
v = existingVariant;
|
||||
|
||||
if (v !== "" && existingAccent !== "" && adapter.presets[s].variants[v]?.accents.some(a => a === existingAccent))
|
||||
a = existingAccent;
|
||||
|
||||
if (a !== "")
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]);
|
||||
else if (v !== "")
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`]);
|
||||
else
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${variantKeys[0]}`]);
|
||||
}
|
||||
}
|
||||
|
||||
DialogSelectButton {
|
||||
settingAnchor: "colors-fonts-preset-variant"
|
||||
// qmlformat off
|
||||
model: {
|
||||
const v = adapter.presets[Config.colors.presets.name].variants;
|
||||
const map = Object.keys(v).map(v => ({
|
||||
id: v,
|
||||
label: qsTr("%1%2")
|
||||
.arg(v.charAt(0).toUpperCase())
|
||||
.arg(v.slice(1).toLowerCase())
|
||||
}));
|
||||
|
||||
return map;
|
||||
}
|
||||
// qmlformat on
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: !Config.colors.schemeGen && model.length > 1
|
||||
header: qsTr("Select variant")
|
||||
rowButton.activeItem: {
|
||||
const v = Config.colors.presets.variant ?? "";
|
||||
if (v === "")
|
||||
return "";
|
||||
return qsTr("%1%2").arg(v.charAt(0).toUpperCase()).arg(v.slice(1).toLowerCase());
|
||||
}
|
||||
label: qsTr("Variant")
|
||||
subtext: qsTr("Select preset variant")
|
||||
rootParent: root.flickable
|
||||
|
||||
initialSelect: Config.colors.presets.variant
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
const v = selectedItem.toLowerCase();
|
||||
const s = Config.colors.presets.name;
|
||||
const existingAccent = Config.colors.presets.accent;
|
||||
let a = "";
|
||||
|
||||
if (existingAccent !== "" && adapter.presets[s].variants[v]?.accents.some(a => a === existingAccent))
|
||||
a = existingAccent;
|
||||
|
||||
if (a !== "")
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]);
|
||||
else
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`]);
|
||||
}
|
||||
}
|
||||
|
||||
DialogSelectButton {
|
||||
settingAnchor: "colors-fonts-preset-accent"
|
||||
// qmlformat off
|
||||
model: {
|
||||
const a = adapter.presets[Config.colors.presets.name].variants[Config.colors.presets.variant]?.accents ?? [];
|
||||
|
||||
if (a.length === 0)
|
||||
return a;
|
||||
|
||||
const map = a.map(a => ({
|
||||
id: a,
|
||||
label: qsTr("%1%2")
|
||||
.arg(a.charAt(0).toUpperCase())
|
||||
.arg(a.slice(1).toLowerCase())
|
||||
}));
|
||||
|
||||
return map;
|
||||
}
|
||||
// qmlformat on
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: !Config.colors.schemeGen && model.length > 1
|
||||
header: qsTr("Select accent")
|
||||
rowButton.activeItem: {
|
||||
const a = Config.colors.presets.accent ?? "";
|
||||
if (a === "")
|
||||
return "";
|
||||
return qsTr("%1%2").arg(a.charAt(0).toUpperCase()).arg(a.slice(1).toLowerCase());
|
||||
}
|
||||
label: qsTr("Accent")
|
||||
last: true
|
||||
subtext: qsTr("Select preset accent")
|
||||
rootParent: root.flickable
|
||||
|
||||
initialSelect: Config.colors.presets.accent
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
const a = selectedItem.toLowerCase();
|
||||
const v = Config.colors.presets.variant;
|
||||
const s = Config.colors.presets.name;
|
||||
|
||||
Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--preset", `${s}:${v}`, "-a", `${a}`]);
|
||||
}
|
||||
}
|
||||
|
||||
SectionHeader {
|
||||
text: qsTr("Fonts")
|
||||
}
|
||||
|
||||
SearchPopup {
|
||||
id: selectSansFont
|
||||
|
||||
settingAnchor: "colors-fonts-sans-font"
|
||||
model: root.fonts
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: Object.keys(model).length > 0
|
||||
separateContent: false
|
||||
header: qsTr("Select font")
|
||||
first: true
|
||||
rowButton.activeItem: root.fonts.find(s => {
|
||||
var regex = new RegExp(Config.appearance.font.family.sans, "i");
|
||||
return regex.test(s);
|
||||
}) ?? ""
|
||||
label: qsTr("Sans font")
|
||||
subtext: qsTr("Select desired sans font")
|
||||
rootParent: root.flickable
|
||||
|
||||
initialSelect: Config.appearance.font.family.sans
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
Config.appearance.font.family.sans = selectedItem;
|
||||
}
|
||||
}
|
||||
|
||||
SearchPopup {
|
||||
id: selectMonoFont
|
||||
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: Object.keys(model).length > 0
|
||||
settingAnchor: "colors-fonts-mono-font"
|
||||
separateContent: false
|
||||
header: qsTr("Select font")
|
||||
rowButton.activeItem: root.fonts.find(s => {
|
||||
var regex = new RegExp(Config.appearance.font.family.mono, "i");
|
||||
return regex.test(s);
|
||||
}) ?? ""
|
||||
label: qsTr("Mono font")
|
||||
subtext: qsTr("Select desired mono font")
|
||||
model: root.fonts
|
||||
rootParent: root.flickable
|
||||
|
||||
initialSelect: Config.appearance.font.family.mono
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
Config.appearance.font.family.mono = selectedItem;
|
||||
}
|
||||
}
|
||||
|
||||
SearchPopup {
|
||||
id: selectClockFont
|
||||
|
||||
acceptLabel: qsTr("Confirm")
|
||||
enabled: Object.keys(model).length > 0
|
||||
separateContent: false
|
||||
settingAnchor: "colors-fonts-clock-font"
|
||||
header: qsTr("Select font")
|
||||
last: true
|
||||
rowButton.activeItem: root.fonts.find(s => {
|
||||
var regex = new RegExp(Config.appearance.font.family.clock, "i");
|
||||
return regex.test(s);
|
||||
}) ?? ""
|
||||
label: qsTr("Clock font")
|
||||
subtext: qsTr("Select desired clock font")
|
||||
model: root.fonts
|
||||
rootParent: root.flickable
|
||||
|
||||
initialSelect: Config.appearance.font.family.clock
|
||||
|
||||
onAccepted: {
|
||||
if (!selectedItem)
|
||||
return;
|
||||
|
||||
Config.appearance.font.family.clock = selectedItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,18 +33,35 @@ PageBase {
|
||||
}
|
||||
}
|
||||
|
||||
IconTextButton {
|
||||
ButtonRow {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
enabled: Config.background.enabled
|
||||
horizontalPadding: Tokens.padding.extraLarge
|
||||
icon: "wallpaper"
|
||||
isRound: true
|
||||
shapeMorph: true
|
||||
text: qsTr("Wallpapers")
|
||||
type: IconTextButton.Tonal
|
||||
verticalPadding: Tokens.padding.small
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
onClicked: root.sState.openSubPage(1) // Wallpaper page
|
||||
IconTextButton {
|
||||
enabled: Config.background.enabled
|
||||
horizontalPadding: Tokens.padding.extraLarge
|
||||
icon: "wallpaper"
|
||||
isRound: true
|
||||
shapeMorph: true
|
||||
text: qsTr("Wallpapers")
|
||||
type: IconTextButton.Tonal
|
||||
verticalPadding: Tokens.padding.small
|
||||
|
||||
onClicked: root.sState.openSubPage(1) // Wallpaper page
|
||||
}
|
||||
|
||||
IconTextButton {
|
||||
enabled: Config.background.enabled
|
||||
horizontalPadding: Tokens.padding.extraLarge
|
||||
icon: "palette"
|
||||
isRound: true
|
||||
shapeMorph: true
|
||||
text: qsTr("Colors & font")
|
||||
type: IconTextButton.Tonal
|
||||
verticalPadding: Tokens.padding.small
|
||||
|
||||
onClicked: root.sState.openSubPage(2) // Colors & font page
|
||||
}
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
@@ -76,85 +93,116 @@ PageBase {
|
||||
onToggled: Colors.setMode(checked ? "dark" : "light")
|
||||
}
|
||||
|
||||
OverlayRow {
|
||||
id: darkMode
|
||||
|
||||
TimeDialogSelect {
|
||||
readonly property string endTime: {
|
||||
var d = new Date(0, 0, 0, 0, 0, 0, 0);
|
||||
d.setMinutes(Config.general.color.scheduleDarkEnd);
|
||||
d.setMinutes(Config.colors.scheduleDarkEnd);
|
||||
return Qt.formatTime(d, "hh:mm AP");
|
||||
}
|
||||
readonly property string startTime: {
|
||||
var d = new Date(0, 0, 0, 0, 0, 0, 0);
|
||||
d.setMinutes(Config.general.color.scheduleDarkStart);
|
||||
d.setMinutes(Config.colors.scheduleDarkStart);
|
||||
return Qt.formatTime(d, "hh:mm AP");
|
||||
}
|
||||
|
||||
checked: Config.general.color.scheduleDark
|
||||
rowButton.checked: Config.colors.scheduleDark
|
||||
rowButton.onToggled: Config.colors.scheduleDark = rowButton.checked
|
||||
start: Config.colors.scheduleDarkStart
|
||||
end: Config.colors.scheduleDarkEnd
|
||||
first: true
|
||||
last: true
|
||||
settingAnchor: "style-schedule-dark-mode"
|
||||
acceptLabel: qsTr("Apply")
|
||||
rootParent: root.flickable
|
||||
header: qsTr("Select time")
|
||||
subtext: qsTr("Dark mode will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime)
|
||||
text: qsTr("Schedule dark mode")
|
||||
label: qsTr("Schedule dark mode")
|
||||
|
||||
popup: Component {
|
||||
TimeInput {
|
||||
object: Config.general.color
|
||||
settings: ["scheduleDark", "scheduleDarkStart", "scheduleDarkEnd"]
|
||||
|
||||
onApplySettings: (start, end) => {
|
||||
Config.general.color.scheduleDarkStart = start;
|
||||
Config.general.color.scheduleDarkEnd = end;
|
||||
ModeScheduler.checkStartup();
|
||||
PopupManager.requestClose();
|
||||
}
|
||||
onClose: PopupManager.requestClose()
|
||||
}
|
||||
onOpenChanged: {
|
||||
root.sState.dimmed = open;
|
||||
}
|
||||
|
||||
onClicked: value => {
|
||||
Config.general.color.scheduleDark = value;
|
||||
onAccepted: {
|
||||
Config.colors.scheduleDarkStart = startCurrent;
|
||||
Config.colors.scheduleDarkEnd = endCurrent;
|
||||
ModeScheduler.checkStartup();
|
||||
}
|
||||
}
|
||||
|
||||
OverlayRow {
|
||||
id: hyprsunset
|
||||
|
||||
TimeDialogSelect {
|
||||
readonly property string endTime: {
|
||||
var d = new Date(0, 0, 0, 0, 0, 0, 0);
|
||||
d.setMinutes(Config.general.color.scheduleHyprsunsetEnd);
|
||||
d.setMinutes(Config.display.nightlight.scheduleEnd);
|
||||
return Qt.formatTime(d, "hh:mm AP");
|
||||
}
|
||||
readonly property string startTime: {
|
||||
var d = new Date(0, 0, 0, 0, 0, 0, 0);
|
||||
d.setMinutes(Config.general.color.scheduleHyprsunsetStart);
|
||||
d.setMinutes(Config.display.nightlight.scheduleStart);
|
||||
return Qt.formatTime(d, "hh:mm AP");
|
||||
}
|
||||
|
||||
rowButton.checked: Config.display.nightlight.schedule
|
||||
rowButton.onToggled: Config.display.nightlight.schedule = rowButton.checked
|
||||
start: Config.display.nightlight.scheduleStart
|
||||
end: Config.display.nightlight.scheduleEnd
|
||||
first: true
|
||||
settingAnchor: "style-schedule-nightlight"
|
||||
acceptLabel: qsTr("Apply")
|
||||
rootParent: root.flickable
|
||||
header: qsTr("Select time")
|
||||
subtext: qsTr("Nightlight will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime)
|
||||
label: qsTr("Schedule nightlight")
|
||||
|
||||
onOpenChanged: {
|
||||
root.sState.dimmed = open;
|
||||
}
|
||||
|
||||
onAccepted: {
|
||||
Config.display.nightlight.scheduleStart = startCurrent;
|
||||
Config.display.nightlight.scheduleEnd = endCurrent;
|
||||
Hyprsunset.checkStartup();
|
||||
}
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
|
||||
checked: Config.general.color.scheduleHyprsunset
|
||||
|
||||
checked: Config.display.nightlight.useNative
|
||||
settingAnchor: "display-nightlight-useNative"
|
||||
text: qsTr("Use native")
|
||||
subtext: qsTr("Use native shader nightlight for more features")
|
||||
|
||||
onToggled: Config.display.nightlight.useNative = checked
|
||||
}
|
||||
|
||||
SpinRow {
|
||||
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
|
||||
|
||||
from: 2000
|
||||
settingAnchor: "display-nightlight-temp"
|
||||
stepSize: 100
|
||||
subtext: qsTr("How warm/orange the screen will be. Lower is warmer (Kelvin)")
|
||||
text: qsTr("Temperature")
|
||||
to: 6500
|
||||
value: Config.display.nightlight.temp
|
||||
|
||||
onMoved: v => Config.display.nightlight.temp = v
|
||||
}
|
||||
|
||||
SpinRow {
|
||||
Layout.topMargin: Tokens.spacing.extraSmall / 2 - parent.spacing
|
||||
|
||||
from: 0
|
||||
settingAnchor: "display-nightlight-fade-duration"
|
||||
enabled: Config.display.nightlight.useNative
|
||||
last: true
|
||||
settingAnchor: "style-schedule-hyprsunset"
|
||||
subtext: qsTr("Hyprsunset will turn on at %1, and turn off at %2.").arg(startTime).arg(endTime)
|
||||
text: qsTr("Schedule hyprsunset")
|
||||
stepSize: 1
|
||||
subtext: qsTr("How long it takes for the gradual nightlight to finish (s)")
|
||||
text: qsTr("Fade duration")
|
||||
to: 60 * 5
|
||||
value: Config.display.nightlight.fadeDuration / 1000
|
||||
|
||||
popup: Component {
|
||||
TimeInput {
|
||||
object: Config.general.color
|
||||
settings: ["scheduleHyprsunset", "scheduleHyprsunsetStart", "scheduleHyprsunsetEnd"]
|
||||
|
||||
onApplySettings: (start, end) => {
|
||||
Config.general.color.scheduleHyprsunsetStart = start;
|
||||
Config.general.color.scheduleHyprsunsetEnd = end;
|
||||
Hyprsunset.checkStartup();
|
||||
PopupManager.requestClose();
|
||||
}
|
||||
onClose: PopupManager.requestClose()
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: value => {
|
||||
Config.general.color.scheduleHyprsunset = value;
|
||||
}
|
||||
onMoved: v => Config.display.nightlight.fadeDuration = v * 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool closed: true
|
||||
property Component currentPopup: null
|
||||
|
||||
function requestClose(): void {
|
||||
closed = true;
|
||||
}
|
||||
|
||||
function requestOpen(component: Component): void {
|
||||
currentPopup = component;
|
||||
closed = false;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import QtQuick
|
||||
import ZShell.Config
|
||||
import qs.Components
|
||||
import qs.Services
|
||||
|
||||
CustomRect {
|
||||
id: root
|
||||
|
||||
readonly property bool closing: PopupManager.closed
|
||||
readonly property var currentPopup: PopupManager.currentPopup
|
||||
property bool shouldBeVisible: loader.status === Loader.Ready
|
||||
|
||||
color: Qt.alpha(Colors.palette.m3shadow, 0.3)
|
||||
opacity: shouldBeVisible && !closing ? 1 : 0
|
||||
visible: opacity > 0
|
||||
|
||||
Behavior on opacity {
|
||||
Anim {
|
||||
}
|
||||
}
|
||||
|
||||
onVisibleChanged: if (!visible)
|
||||
PopupManager.currentPopup = null
|
||||
|
||||
CustomMouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
preventStealing: true
|
||||
propagateComposedEvents: false
|
||||
|
||||
onClicked: {
|
||||
const insideItemWidth = mouseX < loader.x + loader.item.width && mouseX > loader.x;
|
||||
const insideItemHeight = mouseY < loader.y + loader.item.height && mouseY > loader.y;
|
||||
if (insideItemHeight && insideItemWidth)
|
||||
return;
|
||||
|
||||
PopupManager.requestClose();
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: loader
|
||||
|
||||
anchors.centerIn: parent
|
||||
sourceComponent: root.currentPopup
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ QtObject {
|
||||
property string searchText
|
||||
property DesktopEntry selectedApp
|
||||
property BluetoothDevice selectedBtDevice
|
||||
property bool dimmed
|
||||
property string selectedWallpaperCategory
|
||||
property list<int> subPageIdxStack
|
||||
|
||||
|
||||
@@ -79,3 +79,12 @@ add_subdirectory(Services)
|
||||
add_subdirectory(Components)
|
||||
add_subdirectory(Blobs)
|
||||
add_subdirectory(Config)
|
||||
add_subdirectory(Llm)
|
||||
|
||||
pkg_check_modules(HYPRLAND_PROBE hyprland)
|
||||
if(HYPRLAND_PROBE_FOUND)
|
||||
add_subdirectory(HyprPlugins)
|
||||
else()
|
||||
message(STATUS
|
||||
"hyprland development headers not found; skipping zshell-nightlight plugin")
|
||||
endif()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
qml_module(ZShell-components
|
||||
URI ZShell.Components
|
||||
SOURCES
|
||||
lazylistview.hpp lazylistview.cpp
|
||||
SOURCES
|
||||
lazylistview.hpp lazylistview.cpp
|
||||
wavyline.hpp wavyline.cpp
|
||||
buttonrow.hpp buttonrow.cpp
|
||||
carouselview.hpp carouselview.cpp
|
||||
LIBRARIES
|
||||
Qt::Quick
|
||||
wheelinverter.hpp
|
||||
LIBRARIES
|
||||
Qt::Quick
|
||||
)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include <QQuickItem>
|
||||
#include <QQuickWindow>
|
||||
#include <QWheelEvent>
|
||||
#include <QCoreApplication>
|
||||
#include <QPointer>
|
||||
#include <QDebug>
|
||||
|
||||
namespace ZShell::components {
|
||||
|
||||
class WheelInverter : public QQuickItem {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
|
||||
Q_PROPERTY(
|
||||
QQuickItem* target READ target WRITE setTarget NOTIFY targetChanged)
|
||||
|
||||
public:
|
||||
using QQuickItem::QQuickItem;
|
||||
|
||||
QQuickItem* target() const { return m_target; }
|
||||
|
||||
void setTarget(QQuickItem* target) {
|
||||
if (m_target == target) return;
|
||||
|
||||
if (m_target) m_target->removeEventFilter(this);
|
||||
|
||||
m_target = target;
|
||||
|
||||
if (m_target) m_target->installEventFilter(this);
|
||||
|
||||
emit targetChanged();
|
||||
}
|
||||
|
||||
protected:
|
||||
class InvertedWheelEvent : public QWheelEvent {
|
||||
public:
|
||||
using QWheelEvent::QWheelEvent;
|
||||
|
||||
bool invertedByWheelInverter = true;
|
||||
};
|
||||
|
||||
bool eventFilter(QObject* watched, QEvent* event) override {
|
||||
if (watched != m_target || event->type() != QEvent::Wheel)
|
||||
return QQuickItem::eventFilter(watched, event);
|
||||
|
||||
auto* wheel = static_cast<QWheelEvent*>(event);
|
||||
|
||||
const bool inverted = dynamic_cast<InvertedWheelEvent*>(wheel) !=
|
||||
nullptr;
|
||||
|
||||
if (inverted) return false;
|
||||
|
||||
auto* window = m_target ? m_target->window() : nullptr;
|
||||
if (!window) {
|
||||
qInfo() << "[WheelInverter] no window";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QPointF windowPos =
|
||||
window->mapFromGlobal(wheel->globalPosition());
|
||||
|
||||
auto* invertedEvent = new InvertedWheelEvent(
|
||||
windowPos,
|
||||
wheel->globalPosition(),
|
||||
-wheel->pixelDelta(),
|
||||
-wheel->angleDelta(),
|
||||
wheel->buttons(),
|
||||
wheel->modifiers(),
|
||||
wheel->phase(),
|
||||
wheel->inverted(),
|
||||
wheel->source(),
|
||||
wheel->pointingDevice());
|
||||
|
||||
invertedEvent->setTimestamp(wheel->timestamp());
|
||||
|
||||
QCoreApplication::postEvent(window, invertedEvent);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
signals:
|
||||
void targetChanged();
|
||||
|
||||
private:
|
||||
QPointer<QQuickItem> m_target;
|
||||
};
|
||||
|
||||
} // namespace ZShell::components
|
||||
@@ -5,6 +5,7 @@ qml_module(ZShell-config
|
||||
configobject.hpp configobject.cpp
|
||||
configlist.hpp configlist.cpp
|
||||
config.hpp config.cpp
|
||||
migration.hpp migration.cpp
|
||||
anim.hpp anim.cpp
|
||||
tokens.hpp
|
||||
appearance.hpp
|
||||
@@ -13,9 +14,11 @@ qml_module(ZShell-config
|
||||
clipboard.hpp
|
||||
colors.hpp
|
||||
dashboard.hpp
|
||||
display.hpp
|
||||
dock.hpp
|
||||
general.hpp
|
||||
launcher.hpp
|
||||
llm.hpp
|
||||
lock.hpp
|
||||
notifs.hpp
|
||||
osd.hpp
|
||||
|
||||
@@ -22,6 +22,13 @@ class Colors : public ConfigObject {
|
||||
|
||||
CONFIG_SUBOBJECT(Presets, presets)
|
||||
CFG_PROPERTY(QString, schemeType, QStringLiteral("fidelity"))
|
||||
CFG_PROPERTY(bool, scheduleDark, false)
|
||||
CFG_PROPERTY(int, scheduleDarkEnd, 600)
|
||||
CFG_PROPERTY(int, scheduleDarkStart, 1140)
|
||||
CFG_PROPERTY(bool, schemeGen, true)
|
||||
CFG_PROPERTY(bool, neovimColors, false)
|
||||
CFG_PROPERTY(QString, mode, QStringLiteral("dark"))
|
||||
CFG_PROPERTY(bool, smart, false)
|
||||
|
||||
public:
|
||||
explicit Colors(QObject* parent = nullptr)
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
#include "clipboard.hpp"
|
||||
#include "colors.hpp"
|
||||
#include "dashboard.hpp"
|
||||
#include "display.hpp"
|
||||
#include "dock.hpp"
|
||||
#include "general.hpp"
|
||||
#include "launcher.hpp"
|
||||
#include "llm.hpp"
|
||||
#include "lock.hpp"
|
||||
#include "migration.hpp"
|
||||
#include "notifs.hpp"
|
||||
#include "osd.hpp"
|
||||
#include "screenshot.hpp"
|
||||
@@ -33,6 +36,8 @@
|
||||
|
||||
namespace ZShell::config {
|
||||
|
||||
Config* Config::s_instance = nullptr;
|
||||
|
||||
Config::Config(QObject* parent)
|
||||
: ConfigObject(parent)
|
||||
, m_appearance(new Appearance(this))
|
||||
@@ -41,9 +46,11 @@ Config::Config(QObject* parent)
|
||||
, m_clipboard(new Clipboard(this))
|
||||
, m_colors(new Colors(this))
|
||||
, m_dashboard(new Dashboard(this))
|
||||
, m_display(new Display(this))
|
||||
, m_dock(new Dock(this))
|
||||
, m_general(new General(this))
|
||||
, m_launcher(new Launcher(this))
|
||||
, m_llm(new Llm(this))
|
||||
, m_lock(new Lock(this))
|
||||
, m_notifs(new Notifs(this))
|
||||
, m_osd(new Osd(this))
|
||||
@@ -51,6 +58,7 @@ Config::Config(QObject* parent)
|
||||
, m_services(new Services(this))
|
||||
, m_sidebar(new Sidebar(this))
|
||||
, m_utilities(new Utilities(this)) {
|
||||
s_instance = this;
|
||||
connect(this, &ConfigObject::propertiesChanged, this, &Config::scheduleSave);
|
||||
|
||||
m_saveTimer.setSingleShot(true);
|
||||
@@ -81,8 +89,13 @@ Config::Config(QObject* parent)
|
||||
m_firstLoadDone = true;
|
||||
}
|
||||
|
||||
Config* Config::instance() {
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
Config* Config::create(QQmlEngine*, QJSEngine*) {
|
||||
return new Config();
|
||||
if (!s_instance) s_instance = new Config();
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
QString Config::filePath() const {
|
||||
@@ -100,11 +113,14 @@ void Config::loadSync() {
|
||||
QJsonObject before;
|
||||
|
||||
bool existed = false;
|
||||
bool migrated = false;
|
||||
|
||||
if (f.open(QIODevice::ReadOnly)) {
|
||||
const auto doc = QJsonDocument::fromJson(f.readAll());
|
||||
if (doc.isObject()) {
|
||||
before = doc.object();
|
||||
const QJsonObject raw = doc.object();
|
||||
before = ConfigMigrations::apply(raw);
|
||||
migrated = before != raw;
|
||||
existed = true;
|
||||
} else {
|
||||
qInfo() << "Config: existing config at" << filePath()
|
||||
@@ -119,7 +135,7 @@ void Config::loadSync() {
|
||||
m_loading = false;
|
||||
|
||||
const auto after = toJson().toObject();
|
||||
if (!existed || after != before) saveNow();
|
||||
if (!existed || migrated || after != before) saveNow();
|
||||
}
|
||||
|
||||
void Config::updateWatch() {
|
||||
@@ -165,16 +181,18 @@ void Config::loadAsync() {
|
||||
if (f.open(QIODevice::ReadOnly)) {
|
||||
const auto doc = QJsonDocument::fromJson(f.readAll());
|
||||
const bool valid = doc.isObject();
|
||||
const QJsonObject before = valid ? doc.object() : QJsonObject();
|
||||
const QJsonObject raw = valid ? doc.object() : QJsonObject();
|
||||
const QJsonObject before = ConfigMigrations::apply(raw);
|
||||
const bool migrated = valid && before != raw;
|
||||
|
||||
QMetaObject::invokeMethod(
|
||||
this,
|
||||
[this, valid, before]() {
|
||||
[this, valid, migrated, before]() {
|
||||
loadFromJson(QJsonValue(before));
|
||||
m_loading = false;
|
||||
|
||||
const auto after = toJson().toObject();
|
||||
if (!valid || after != before) saveNow();
|
||||
if (!valid || migrated || after != before) saveNow();
|
||||
|
||||
if (m_reloadPending) m_reloadTimer.start();
|
||||
},
|
||||
|
||||
@@ -21,9 +21,11 @@ class Bar;
|
||||
class Clipboard;
|
||||
class Colors;
|
||||
class Dashboard;
|
||||
class Display;
|
||||
class Dock;
|
||||
class General;
|
||||
class Launcher;
|
||||
class Llm;
|
||||
class Lock;
|
||||
class Notifs;
|
||||
class Osd;
|
||||
@@ -43,9 +45,11 @@ class Config : public ConfigObject {
|
||||
Q_MOC_INCLUDE("clipboard.hpp")
|
||||
Q_MOC_INCLUDE("colors.hpp")
|
||||
Q_MOC_INCLUDE("dashboard.hpp")
|
||||
Q_MOC_INCLUDE("display.hpp")
|
||||
Q_MOC_INCLUDE("dock.hpp")
|
||||
Q_MOC_INCLUDE("general.hpp")
|
||||
Q_MOC_INCLUDE("launcher.hpp")
|
||||
Q_MOC_INCLUDE("llm.hpp")
|
||||
Q_MOC_INCLUDE("lock.hpp")
|
||||
Q_MOC_INCLUDE("notifs.hpp")
|
||||
Q_MOC_INCLUDE("osd.hpp")
|
||||
@@ -60,9 +64,11 @@ class Config : public ConfigObject {
|
||||
CONFIG_SUBOBJECT(Clipboard, clipboard)
|
||||
CONFIG_SUBOBJECT(Colors, colors)
|
||||
CONFIG_SUBOBJECT(Dashboard, dashboard)
|
||||
CONFIG_SUBOBJECT(Display, display)
|
||||
CONFIG_SUBOBJECT(Dock, dock)
|
||||
CONFIG_SUBOBJECT(General, general)
|
||||
CONFIG_SUBOBJECT(Launcher, launcher)
|
||||
CONFIG_SUBOBJECT(Llm, llm)
|
||||
CONFIG_SUBOBJECT(Lock, lock)
|
||||
CONFIG_SUBOBJECT(Notifs, notifs)
|
||||
CONFIG_SUBOBJECT(Osd, osd)
|
||||
@@ -74,6 +80,7 @@ class Config : public ConfigObject {
|
||||
public:
|
||||
explicit Config(QObject* parent = nullptr);
|
||||
static Config* create(QQmlEngine*, QJSEngine*);
|
||||
[[nodiscard]] static Config* instance();
|
||||
|
||||
Q_INVOKABLE void load();
|
||||
Q_INVOKABLE void saveNow();
|
||||
@@ -103,6 +110,8 @@ class Config : public ConfigObject {
|
||||
bool m_loading = false;
|
||||
bool m_firstLoadDone = false;
|
||||
QFuture<void> m_loadFuture;
|
||||
|
||||
static Config* s_instance;
|
||||
};
|
||||
|
||||
} // namespace ZShell::config
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
#include "configobject.hpp"
|
||||
#include <qqmlintegration.h>
|
||||
|
||||
namespace ZShell::config {
|
||||
|
||||
class Nightlight : public ConfigObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
|
||||
CFG_PROPERTY(bool, schedule, true)
|
||||
CFG_PROPERTY(int, scheduleStart, 1200)
|
||||
CFG_PROPERTY(int, scheduleEnd, 570)
|
||||
CFG_PROPERTY(bool, useNative, true)
|
||||
CFG_PROPERTY(int, fadeDuration, 2000)
|
||||
CFG_PROPERTY(int, temp, 2600)
|
||||
|
||||
public:
|
||||
explicit Nightlight(QObject* parent = nullptr) : ConfigObject(parent) {}
|
||||
};
|
||||
|
||||
class Display : public ConfigObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
|
||||
CONFIG_SUBOBJECT(Nightlight, nightlight)
|
||||
|
||||
public:
|
||||
explicit Display(QObject* parent = nullptr)
|
||||
: ConfigObject(parent), m_nightlight(new Nightlight(this)) {}
|
||||
};
|
||||
|
||||
} // namespace ZShell::config
|
||||
@@ -91,26 +91,6 @@ class Battery : public ConfigObject {
|
||||
explicit Battery(QObject* parent = nullptr) : ConfigObject(parent) {}
|
||||
};
|
||||
|
||||
class ColorSettings : public ConfigObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
|
||||
CFG_PROPERTY(int, hyprsunsetTemp, 2600)
|
||||
CFG_PROPERTY(QString, mode, QStringLiteral("dark"))
|
||||
CFG_PROPERTY(bool, neovimColors, false)
|
||||
CFG_PROPERTY(bool, scheduleDark, false)
|
||||
CFG_PROPERTY(int, scheduleDarkEnd, 600)
|
||||
CFG_PROPERTY(int, scheduleDarkStart, 1140)
|
||||
CFG_PROPERTY(bool, scheduleHyprsunset, true)
|
||||
CFG_PROPERTY(int, scheduleHyprsunsetEnd, 570)
|
||||
CFG_PROPERTY(int, scheduleHyprsunsetStart, 1200)
|
||||
CFG_PROPERTY(bool, schemeGeneration, true)
|
||||
CFG_PROPERTY(bool, smart, false)
|
||||
|
||||
public:
|
||||
explicit ColorSettings(QObject* parent = nullptr) : ConfigObject(parent) {}
|
||||
};
|
||||
|
||||
class Idle : public ConfigObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
@@ -135,7 +115,6 @@ class General : public ConfigObject {
|
||||
|
||||
CONFIG_SUBOBJECT(Apps, apps)
|
||||
CONFIG_SUBOBJECT(Battery, battery)
|
||||
CONFIG_SUBOBJECT(ColorSettings, color)
|
||||
CFG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss"))
|
||||
CFG_PROPERTY(bool, desktopIcons, true)
|
||||
CONFIG_SUBOBJECT(Idle, idle)
|
||||
@@ -151,7 +130,6 @@ class General : public ConfigObject {
|
||||
: ConfigObject(parent)
|
||||
, m_apps(new Apps(this))
|
||||
, m_battery(new Battery(this))
|
||||
, m_color(new ColorSettings(this))
|
||||
, m_idle(new Idle(this)) {}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
#include "configobject.hpp"
|
||||
#include <qhashfunctions.h>
|
||||
#include <qobject.h>
|
||||
#include <qqmlintegration.h>
|
||||
|
||||
namespace ZShell::config {
|
||||
|
||||
class LlmAppearance : public ConfigObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
|
||||
CFG_PROPERTY(QString, scheme, QStringLiteral("tokyoNight"))
|
||||
|
||||
public:
|
||||
explicit LlmAppearance(QObject* parent = nullptr) : ConfigObject(parent) {}
|
||||
};
|
||||
|
||||
class Llm : public ConfigObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
|
||||
CFG_PROPERTY(QString, endpoint, "http://localhost:8080")
|
||||
CFG_PROPERTY(QString, model, "")
|
||||
CFG_PROPERTY(double, temperature, 0.7)
|
||||
CFG_PROPERTY(bool, tools, true)
|
||||
CONFIG_SUBOBJECT(LlmAppearance, appearance)
|
||||
|
||||
public:
|
||||
explicit Llm(QObject* parent = nullptr)
|
||||
: ConfigObject(parent), m_appearance(new LlmAppearance(this)) {}
|
||||
};
|
||||
|
||||
} // namespace ZShell::config
|
||||
@@ -0,0 +1,123 @@
|
||||
#include "migration.hpp"
|
||||
|
||||
#include <QDebug>
|
||||
|
||||
namespace ZShell::config {
|
||||
|
||||
namespace {
|
||||
|
||||
// A stored value is "missing" if the path does not resolve to an entry.
|
||||
// Note: default-constructed QJsonValue is Null, not Undefined.
|
||||
bool isMissing(const QJsonValue& v) {
|
||||
return v.isUndefined() || v.isNull();
|
||||
}
|
||||
|
||||
QJsonValue valueAt(const QJsonObject& obj, const QStringList& path) {
|
||||
QJsonObject cur = obj;
|
||||
|
||||
for (int i = 0; i < path.size() - 1; ++i) {
|
||||
if (!cur.value(path.at(i)).isObject()) return QJsonValue::Undefined;
|
||||
cur = cur.value(path.at(i)).toObject();
|
||||
}
|
||||
|
||||
const QString key = path.last();
|
||||
if (!cur.contains(key)) return QJsonValue::Undefined;
|
||||
return cur.value(key);
|
||||
}
|
||||
|
||||
QJsonObject placeAt(QJsonObject obj, QStringList path, const QJsonValue& value) {
|
||||
const QString head = path.takeFirst();
|
||||
|
||||
if (path.isEmpty()) {
|
||||
obj[head] = value;
|
||||
return obj;
|
||||
}
|
||||
|
||||
QJsonObject child = obj.value(head).isObject() ? obj.value(head).toObject()
|
||||
: QJsonObject();
|
||||
obj[head] = placeAt(child, path, value);
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Removes `path` from `obj`. Returns true if a value was removed.
|
||||
// Parent objects left empty are removed as well.
|
||||
bool removeAt(QJsonObject& obj, const QStringList& path) {
|
||||
const QString head = path.first();
|
||||
|
||||
if (path.size() == 1) {
|
||||
if (!obj.contains(head)) return false;
|
||||
obj.remove(head);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!obj.value(head).isObject()) return false;
|
||||
|
||||
QJsonObject child = obj.value(head).toObject();
|
||||
const bool removed = removeAt(child, path.mid(1));
|
||||
|
||||
if (child.isEmpty())
|
||||
obj.remove(head);
|
||||
else
|
||||
obj[head] = child;
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const QList<ConfigMigrationRule>& ConfigMigrations::rules() {
|
||||
static const QList<ConfigMigrationRule> s_rules = {
|
||||
migrate(
|
||||
"general.color.scheduleHyprsunset", "display.nightlight.schedule"),
|
||||
migrate(
|
||||
"general.color.scheduleHyprsunsetStart",
|
||||
"display.nightlight.scheduleStart"),
|
||||
migrate(
|
||||
"general.color.scheduleHyprsunsetEnd",
|
||||
"display.nightlight.scheduleEnd"),
|
||||
migrate("general.color.scheduleDark", "colors.scheduleDark"),
|
||||
migrate("general.color.scheduleDarkStart", "colors.scheduleDarkStart"),
|
||||
migrate("general.color.scheduleDarkEnd", "colors.scheduleDarkEnd"),
|
||||
migrate("general.color.schemeGeneration", "colors.schemeGen"),
|
||||
migrate(
|
||||
"general.color.useNativeNightlight",
|
||||
"display.nightlight.useNative"),
|
||||
migrate(
|
||||
"general.color.nativeFadeDuration",
|
||||
"display.nightlight.fadeDuration"),
|
||||
migrate("general.color.hyprsunsetTemp", "display.nightlight.temp"),
|
||||
migrate("general.color.neovimColors", "colors.neovimColors"),
|
||||
migrate("general.color.mode", "colors.mode"),
|
||||
migrate("general.color.smart", "colors.smart"),
|
||||
};
|
||||
return s_rules;
|
||||
}
|
||||
|
||||
QJsonObject ConfigMigrations::apply(const QJsonObject& json) {
|
||||
QJsonObject obj = json;
|
||||
|
||||
for (const auto& rule : rules()) {
|
||||
const QStringList from =
|
||||
rule.from.split(QLatin1Char('.'), Qt::SkipEmptyParts);
|
||||
const QStringList to =
|
||||
rule.to.split(QLatin1Char('.'), Qt::SkipEmptyParts);
|
||||
|
||||
if (from.isEmpty() || to.isEmpty()) continue;
|
||||
|
||||
const QJsonValue oldValue = valueAt(obj, from);
|
||||
if (isMissing(oldValue)) continue;
|
||||
|
||||
const bool targetExists = !isMissing(valueAt(obj, to));
|
||||
if (!targetExists) obj = placeAt(obj, to, oldValue);
|
||||
|
||||
if (removeAt(obj, from)) {
|
||||
qInfo() << "Config migration:" << rule.from << "->" << rule.to
|
||||
<< (targetExists ? "(target already set, old value dropped)"
|
||||
: "(value moved)");
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
} // namespace ZShell::config
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QStringList>
|
||||
|
||||
namespace ZShell::config {
|
||||
|
||||
// A single migration rule: move the value stored at the dotted `from` path
|
||||
// to the dotted `to` path, then delete the old key (and any parent objects
|
||||
// left empty).
|
||||
//
|
||||
// Rules are applied to the raw JSON file on every load, before the schema
|
||||
// loads it. They are idempotent: once the old key is gone the rule never
|
||||
// fires again, so no version stamping is needed. Old rules can stay in the
|
||||
// table forever.
|
||||
//
|
||||
// WARNING: the target path must exist in the schema (i.e. a ConfigObject
|
||||
// property somewhere in the tree). If it does not, the migrated value is
|
||||
// loaded as an unknown key and silently dropped on the next save.
|
||||
struct ConfigMigrationRule {
|
||||
QString from;
|
||||
QString to;
|
||||
};
|
||||
|
||||
// Helper for writing rules: migrate("general.color.foo", "display.bar.foo")
|
||||
inline ConfigMigrationRule migrate(const QString& from, const QString& to) {
|
||||
return {from, to};
|
||||
}
|
||||
|
||||
class ConfigMigrations {
|
||||
public:
|
||||
[[nodiscard]] static const QList<ConfigMigrationRule>& rules();
|
||||
|
||||
// Applies all rules to `json`, returning the migrated object.
|
||||
[[nodiscard]] static QJsonObject apply(const QJsonObject& json);
|
||||
};
|
||||
|
||||
} // namespace ZShell::config
|
||||
@@ -0,0 +1,12 @@
|
||||
pkg_check_modules(HYPRLAND REQUIRED hyprland)
|
||||
|
||||
add_library(zshell-nightlight SHARED main.cpp)
|
||||
|
||||
target_compile_features(zshell-nightlight PRIVATE cxx_std_23)
|
||||
target_include_directories(zshell-nightlight PRIVATE ${HYPRLAND_INCLUDE_DIRS})
|
||||
target_link_directories(zshell-nightlight PRIVATE ${HYPRLAND_LIBRARY_DIRS})
|
||||
target_compile_options(zshell-nightlight PRIVATE ${HYPRLAND_CFLAGS_OTHER})
|
||||
|
||||
target_compile_options(zshell-nightlight PRIVATE -fno-gnu-unique)
|
||||
|
||||
install(TARGETS zshell-nightlight LIBRARY DESTINATION "${INSTALL_LIBDIR}/plugins")
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
namespace ZShell::services {
|
||||
|
||||
inline std::array<float, 3> kelvinToGain(int kelvin) {
|
||||
const double t = std::clamp(kelvin, 1000, 40000) / 100.0;
|
||||
double r, g, b;
|
||||
|
||||
r = t <= 66.0
|
||||
? 1.0
|
||||
: std::clamp(
|
||||
1.29293618606 * std::pow(t - 60.0, -0.1332047592), 0.0, 1.0);
|
||||
g = t <= 66.0
|
||||
? std::clamp(0.39008157876 * std::log(t) - 0.63184144378, 0.0, 1.0)
|
||||
: std::clamp(
|
||||
1.12989086089 * std::pow(t - 60.0, -0.0755148492), 0.0, 1.0);
|
||||
b = t >= 66.0
|
||||
? 1.0
|
||||
: (t <= 19.0
|
||||
? 0.0
|
||||
: std::clamp(
|
||||
0.54320678911 * std::log(t - 10.0) - 1.19625408914,
|
||||
0.0,
|
||||
1.0));
|
||||
|
||||
return {static_cast<float>(r), static_cast<float>(g), static_cast<float>(b)};
|
||||
}
|
||||
|
||||
} // namespace ZShell::services
|
||||
@@ -0,0 +1,447 @@
|
||||
#include <any>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#define private public
|
||||
#include <hyprland/src/plugins/PluginAPI.hpp>
|
||||
#include <hyprland/src/render/OpenGL.hpp>
|
||||
#include <hyprland/src/render/Renderer.hpp>
|
||||
#undef private
|
||||
|
||||
#include <hyprland/src/managers/EventManager.hpp>
|
||||
|
||||
extern "C" {
|
||||
#include <lua.h>
|
||||
#include <lauxlib.h>
|
||||
}
|
||||
|
||||
#include "colortemp.hpp"
|
||||
|
||||
using ZShell::services::kelvinToGain;
|
||||
|
||||
using Render::IFramebuffer;
|
||||
using Render::GL::CHyprOpenGLImpl;
|
||||
using Render::GL::g_pHyprOpenGL;
|
||||
|
||||
inline HANDLE PHANDLE = nullptr;
|
||||
|
||||
static std::filesystem::path g_runtimeDir{"/tmp"};
|
||||
static std::filesystem::path g_cacheBase{"/tmp/.cache"};
|
||||
|
||||
static void initEnvPaths() {
|
||||
if (const char* env = getenv("XDG_RUNTIME_DIR")) g_runtimeDir = env;
|
||||
|
||||
if (const char* env = getenv("XDG_CACHE_HOME")) {
|
||||
if (*env) g_cacheBase = env;
|
||||
} else if (const char* home = getenv("HOME")) {
|
||||
g_cacheBase = std::filesystem::path(home) / ".cache";
|
||||
}
|
||||
}
|
||||
|
||||
static std::ofstream& debugLog() {
|
||||
static std::ofstream log(
|
||||
g_runtimeDir / "zshell-nightlight-debug.log", std::ios::app);
|
||||
return log;
|
||||
}
|
||||
|
||||
static std::mutex g_logMutex;
|
||||
static std::atomic<uint64_t> g_logSeq{0};
|
||||
|
||||
static void dlog(const std::string& msg) {
|
||||
std::lock_guard<std::mutex> lk(g_logMutex);
|
||||
auto& log = debugLog();
|
||||
log << "[" << g_logSeq++ << "][tid=" << std::this_thread::get_id() << "] "
|
||||
<< msg << std::endl;
|
||||
}
|
||||
|
||||
static constexpr int BAKED_STEPS = 24;
|
||||
|
||||
static std::string glslFloat(float v) {
|
||||
std::ostringstream ss;
|
||||
ss << std::fixed << std::setprecision(6) << v;
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
static std::filesystem::path cacheDir() {
|
||||
return g_cacheBase / "zshell" / "night-light";
|
||||
}
|
||||
|
||||
static std::filesystem::path shaderPath() {
|
||||
return cacheDir() / "nightlight.frag";
|
||||
}
|
||||
|
||||
static std::filesystem::path bakedShaderPath(const std::array<float, 3>& gain) {
|
||||
return cacheDir() /
|
||||
("nightlight-" + glslFloat(gain[0]) + "-" + glslFloat(gain[1]) +
|
||||
"-" + glslFloat(gain[2]) + ".frag");
|
||||
}
|
||||
|
||||
static std::string defaultShaderSource() {
|
||||
return "#version 100\n"
|
||||
"precision highp float;\n"
|
||||
"varying vec2 v_texcoord;\n"
|
||||
"uniform sampler2D tex;\n"
|
||||
"uniform vec3 tint;\n"
|
||||
"void main() {\n"
|
||||
" vec4 pixColor = texture2D(tex, v_texcoord);\n"
|
||||
" gl_FragColor = vec4(pixColor.rgb * tint, pixColor.a);\n"
|
||||
"}\n";
|
||||
}
|
||||
|
||||
static std::string bakedShaderSource(const std::array<float, 3>& gain) {
|
||||
std::ostringstream src;
|
||||
src << "#version 100\n"
|
||||
"precision highp float;\n"
|
||||
"varying vec2 v_texcoord;\n"
|
||||
"uniform sampler2D tex;\n"
|
||||
"const vec3 GAIN = vec3("
|
||||
<< glslFloat(gain[0]) << "," << glslFloat(gain[1]) << ","
|
||||
<< glslFloat(gain[2])
|
||||
<< ");\n"
|
||||
"void main() {\n"
|
||||
" vec4 pixColor = texture2D(tex, v_texcoord);\n"
|
||||
" gl_FragColor = vec4(pixColor.rgb * GAIN, pixColor.a);\n"
|
||||
"}\n";
|
||||
return src.str();
|
||||
}
|
||||
|
||||
static void ensureShaderFile(
|
||||
const std::filesystem::path& path, const std::string& source) {
|
||||
std::error_code ec;
|
||||
if (std::filesystem::is_regular_file(path, ec)) return;
|
||||
std::filesystem::create_directories(path.parent_path(), ec);
|
||||
std::ofstream out(path, std::ios::trunc);
|
||||
out << source;
|
||||
}
|
||||
|
||||
static bool g_active = false;
|
||||
static bool g_uniformMode = false;
|
||||
static bool g_modeDecided = false;
|
||||
static std::array<float, 3> g_currentGain{1.f, 1.f, 1.f};
|
||||
|
||||
static int g_currentKelvin = 6500;
|
||||
static int g_lastKelvin = 6500;
|
||||
|
||||
struct STransition {
|
||||
bool active = false;
|
||||
std::chrono::steady_clock::time_point start;
|
||||
float durationSec = 0.4f;
|
||||
std::array<float, 3> fromGain{1.f, 1.f, 1.f};
|
||||
std::array<float, 3> toGain{1.f, 1.f, 1.f};
|
||||
int lastBakedStep = -1;
|
||||
};
|
||||
static STransition g_transition;
|
||||
|
||||
static void postStateEvent(bool enabled, bool animating);
|
||||
|
||||
static bool gainIsNeutral(const std::array<float, 3>& gain) {
|
||||
for (float v : gain)
|
||||
if (std::abs(v - 1.f) > 0.001f) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static CFunctionHook* g_pBeginHook = nullptr;
|
||||
using origBegin = void (*)(
|
||||
CHyprOpenGLImpl*,
|
||||
PHLMONITOR,
|
||||
const CRegion&,
|
||||
SP<IFramebuffer>,
|
||||
std::optional<CRegion>);
|
||||
|
||||
static CFunctionHook* g_pSaveMirrorHook = nullptr;
|
||||
using origSaveMirror = bool (*)(CHyprOpenGLImpl*, const CBox&);
|
||||
|
||||
static bool hkSaveBufferForMirror(CHyprOpenGLImpl* thisptr, const CBox& box) {
|
||||
const bool saved = thisptr->m_applyFinalShader;
|
||||
thisptr->m_applyFinalShader = false;
|
||||
const bool result = (*reinterpret_cast<origSaveMirror>(
|
||||
g_pSaveMirrorHook->m_original))(thisptr, box);
|
||||
thisptr->m_applyFinalShader = saved;
|
||||
return result;
|
||||
}
|
||||
|
||||
static void installShader() {
|
||||
auto* gl = g_pHyprOpenGL.get();
|
||||
if (!gl) return;
|
||||
|
||||
ensureShaderFile(shaderPath(), defaultShaderSource());
|
||||
gl->applyScreenShader(shaderPath().string());
|
||||
|
||||
auto* shader = gl->m_finalScreenShader.get();
|
||||
if (shader && shader->program() >= 1 &&
|
||||
shader->getUniformLocation(SHADER_TINT) != -1) {
|
||||
g_uniformMode = true;
|
||||
} else {
|
||||
g_uniformMode = false;
|
||||
if (!g_modeDecided)
|
||||
dlog(
|
||||
"shader has no 'tint' uniform, falling back to "
|
||||
"per-step recompiled shaders");
|
||||
}
|
||||
g_modeDecided = true;
|
||||
}
|
||||
|
||||
static void setUniformGain(const std::array<float, 3>& gain) {
|
||||
auto* gl = g_pHyprOpenGL.get();
|
||||
auto* shader = gl ? gl->m_finalScreenShader.get() : nullptr;
|
||||
if (!gl || !shader) return;
|
||||
|
||||
gl->useShader(gl->m_finalScreenShader);
|
||||
shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]);
|
||||
}
|
||||
|
||||
static void hkBegin(
|
||||
CHyprOpenGLImpl* thisptr,
|
||||
PHLMONITOR mon,
|
||||
const CRegion& damage,
|
||||
SP<IFramebuffer> fb,
|
||||
std::optional<CRegion> finalDamage) {
|
||||
(*reinterpret_cast<origBegin>(
|
||||
g_pBeginHook->m_original))(thisptr, mon, damage, fb, finalDamage);
|
||||
|
||||
if (fb) {
|
||||
auto* renderer = g_pHyprRenderer.get();
|
||||
if (renderer) renderer->m_renderData.blockScreenShader = true;
|
||||
}
|
||||
|
||||
if (!g_active) return;
|
||||
|
||||
auto* gl = g_pHyprOpenGL.get();
|
||||
if (!gl) return;
|
||||
|
||||
if (gl->m_finalScreenShader.get() && gl->m_finalScreenShader->program() < 1)
|
||||
installShader();
|
||||
if (!gl->m_finalScreenShader.get() ||
|
||||
gl->m_finalScreenShader->program() < 1)
|
||||
return;
|
||||
|
||||
if (g_transition.active) {
|
||||
auto* monPtr = mon.get();
|
||||
if (!monPtr) return;
|
||||
const float elapsed =
|
||||
std::chrono::duration<float>(
|
||||
std::chrono::steady_clock::now() - g_transition.start)
|
||||
.count();
|
||||
const float t =
|
||||
std::clamp(elapsed / g_transition.durationSec, 0.f, 1.f);
|
||||
|
||||
for (size_t i = 0; i < 3; ++i)
|
||||
g_currentGain[i] =
|
||||
g_transition.fromGain[i] +
|
||||
(g_transition.toGain[i] - g_transition.fromGain[i]) * t;
|
||||
|
||||
if (g_uniformMode) {
|
||||
setUniformGain(g_currentGain);
|
||||
} else {
|
||||
const int step = static_cast<int>(t * BAKED_STEPS);
|
||||
if (step != g_transition.lastBakedStep) {
|
||||
g_transition.lastBakedStep = step;
|
||||
ensureShaderFile(
|
||||
bakedShaderPath(g_currentGain),
|
||||
bakedShaderSource(g_currentGain));
|
||||
gl->applyScreenShader(bakedShaderPath(g_currentGain).string());
|
||||
}
|
||||
}
|
||||
|
||||
if (t >= 1.f) {
|
||||
g_transition.active = false;
|
||||
if (gainIsNeutral(g_currentGain)) {
|
||||
g_active = false;
|
||||
gl->applyScreenShader("");
|
||||
}
|
||||
postStateEvent(g_active, false);
|
||||
} else {
|
||||
monPtr->m_forceFullFrames = 2;
|
||||
monPtr->scheduleFrame();
|
||||
}
|
||||
} else if (g_uniformMode) {
|
||||
setUniformGain(g_currentGain);
|
||||
}
|
||||
}
|
||||
|
||||
static constexpr int KELVIN_MIN = 2000;
|
||||
static constexpr int KELVIN_MAX = 6500;
|
||||
|
||||
static int luaNightlightSet(lua_State* L) {
|
||||
const int rawKelvin = static_cast<int>(luaL_checknumber(L, 1));
|
||||
const float durationSec =
|
||||
lua_gettop(L) >= 2 ? static_cast<float>(luaL_checknumber(L, 2)) : 0.4f;
|
||||
|
||||
const int targetKelvin = std::clamp(rawKelvin, KELVIN_MIN, KELVIN_MAX);
|
||||
if (targetKelvin != rawKelvin)
|
||||
dlog(
|
||||
"clamped " + std::to_string(rawKelvin) + "K to " +
|
||||
std::to_string(targetKelvin) + "K");
|
||||
|
||||
g_transition.fromGain = g_currentGain;
|
||||
g_transition.toGain = kelvinToGain(targetKelvin);
|
||||
g_transition.active = true;
|
||||
g_transition.start = std::chrono::steady_clock::now();
|
||||
g_transition.durationSec = durationSec;
|
||||
g_transition.lastBakedStep = -1;
|
||||
g_active = true;
|
||||
|
||||
g_lastKelvin = g_currentKelvin;
|
||||
g_currentKelvin = targetKelvin;
|
||||
|
||||
postStateEvent(true, true);
|
||||
|
||||
lua_pushinteger(L, targetKelvin);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int luaNightlightDisable(lua_State* L) {
|
||||
if (!g_active) return 0;
|
||||
|
||||
const float durationSec =
|
||||
lua_gettop(L) >= 1 ? static_cast<float>(luaL_checknumber(L, 1)) : 0.4f;
|
||||
|
||||
g_transition.fromGain = g_currentGain;
|
||||
g_transition.toGain = {1.f, 1.f, 1.f};
|
||||
g_transition.active = true;
|
||||
g_transition.start = std::chrono::steady_clock::now();
|
||||
g_transition.durationSec = durationSec;
|
||||
g_transition.lastBakedStep = -1;
|
||||
|
||||
g_lastKelvin = g_currentKelvin;
|
||||
g_currentKelvin = 6500;
|
||||
|
||||
postStateEvent(false, true);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int luaNightlightState(lua_State* L) {
|
||||
lua_pushstring(
|
||||
L,
|
||||
std::format(
|
||||
"{{\"enabled\":{},\"animating\":{},\"last\":{},\"current\":{}}}",
|
||||
g_active ? "true" : "false",
|
||||
g_transition.active ? "true" : "false",
|
||||
g_lastKelvin,
|
||||
g_currentKelvin)
|
||||
.c_str());
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void postStateEvent(bool enabled, bool animating) {
|
||||
if (!g_pEventManager) return;
|
||||
g_pEventManager->postEvent(
|
||||
SHyprIPCEvent{
|
||||
.event = "zshell-nightlight",
|
||||
.data = std::format(
|
||||
"{},{},{},{}",
|
||||
enabled ? 1 : 0,
|
||||
animating ? 1 : 0,
|
||||
g_lastKelvin,
|
||||
g_currentKelvin)});
|
||||
}
|
||||
|
||||
static const SFunctionMatch* findExactMethod(
|
||||
const std::vector<SFunctionMatch>& methods,
|
||||
const std::string& classAndMethod) {
|
||||
for (const auto& m : methods) {
|
||||
if (m.signature.find(classAndMethod) != std::string::npos) return &m;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static std::string manglePrefix(
|
||||
const std::string& className, const std::string& methodName) {
|
||||
return std::to_string(className.size()) + className +
|
||||
std::to_string(methodName.size()) + methodName;
|
||||
}
|
||||
|
||||
APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) {
|
||||
PHANDLE = handle;
|
||||
initEnvPaths();
|
||||
dlog("=== PLUGIN_INIT, build timestamp " __DATE__ " " __TIME__ " ===");
|
||||
|
||||
const std::string serverHash = __hyprland_api_get_hash();
|
||||
const std::string clientHash = __hyprland_api_get_client_hash();
|
||||
|
||||
if (serverHash != clientHash) {
|
||||
HyprlandAPI::addNotification(
|
||||
PHANDLE,
|
||||
"[zshell-nightlight] Mismatched headers! Can't proceed.",
|
||||
CHyprColor{1.0f, 0.2f, 0.2f, 1.0f},
|
||||
5000);
|
||||
throw std::runtime_error("[zshell-nightlight] version mismatch");
|
||||
}
|
||||
|
||||
static const auto SAVEMIRROR_METHODS =
|
||||
HyprlandAPI::findFunctionsByName(PHANDLE, "saveBufferForMirror");
|
||||
const SFunctionMatch* saveMirrorTarget = findExactMethod(
|
||||
SAVEMIRROR_METHODS,
|
||||
manglePrefix("CHyprOpenGLImpl", "saveBufferForMirror"));
|
||||
|
||||
if (saveMirrorTarget) {
|
||||
g_pSaveMirrorHook = HyprlandAPI::createFunctionHook(
|
||||
PHANDLE,
|
||||
saveMirrorTarget->address,
|
||||
reinterpret_cast<void*>(&hkSaveBufferForMirror));
|
||||
if (g_pSaveMirrorHook && g_pSaveMirrorHook->hook())
|
||||
dlog(
|
||||
"saveBufferForMirror hook installed: " +
|
||||
saveMirrorTarget->signature);
|
||||
}
|
||||
|
||||
if (!g_pSaveMirrorHook)
|
||||
HyprlandAPI::addNotification(
|
||||
PHANDLE,
|
||||
"[zshell-nightlight] WARNING: couldn't hook saveBufferForMirror — "
|
||||
"captures may still show the nightlight",
|
||||
CHyprColor{1.f, 0.6f, 0.2f, 1.f},
|
||||
8000);
|
||||
|
||||
static const auto METHODS =
|
||||
HyprlandAPI::findFunctionsByName(PHANDLE, "begin");
|
||||
const SFunctionMatch* target =
|
||||
findExactMethod(METHODS, manglePrefix("CHyprOpenGLImpl", "begin"));
|
||||
if (!target)
|
||||
throw std::runtime_error(
|
||||
"[zshell-nightlight] couldn't uniquely resolve "
|
||||
"CHyprOpenGLImpl::begin");
|
||||
|
||||
g_pBeginHook = HyprlandAPI::createFunctionHook(
|
||||
PHANDLE, target->address, reinterpret_cast<void*>(&hkBegin));
|
||||
if (!g_pBeginHook || !g_pBeginHook->hook())
|
||||
throw std::runtime_error("[zshell-nightlight] failed to hook begin()");
|
||||
|
||||
if (!HyprlandAPI::addLuaFunction(
|
||||
PHANDLE, "zshell", "nlSet", luaNightlightSet))
|
||||
throw std::runtime_error(
|
||||
"[zshell-nightlight] failed to register Lua function");
|
||||
|
||||
if (!HyprlandAPI::addLuaFunction(
|
||||
PHANDLE, "zshell", "nlDisable", luaNightlightDisable))
|
||||
throw std::runtime_error(
|
||||
"[zshell-nightlight] failed to register Lua function");
|
||||
|
||||
if (!HyprlandAPI::addLuaFunction(
|
||||
PHANDLE, "zshell", "nlState", luaNightlightState))
|
||||
throw std::runtime_error(
|
||||
"[zshell-nightlight] failed to register Lua function");
|
||||
|
||||
return {
|
||||
"zshell-nightlight",
|
||||
"Animated nightlight, excluded from screencopy",
|
||||
"ZShell",
|
||||
"1.2"};
|
||||
}
|
||||
|
||||
|
||||
APICALL EXPORT std::string PLUGIN_API_VERSION() {
|
||||
return HYPRLAND_API_VERSION;
|
||||
}
|
||||
|
||||
|
||||
APICALL EXPORT void PLUGIN_EXIT() {}
|
||||
@@ -0,0 +1,314 @@
|
||||
# cmark-gfm: markdown -> block structure (pkg-config; no CMake config
|
||||
# package is installed).
|
||||
pkg_check_modules(CMARK_GFM REQUIRED IMPORTED_TARGET libcmark-gfm)
|
||||
|
||||
# tree-sitter runtime for code block highlighting (grammars are
|
||||
# dlopen()'d at runtime and optional).
|
||||
pkg_check_modules(TREE_SITTER REQUIRED IMPORTED_TARGET tree-sitter)
|
||||
|
||||
# JKQTMathText (JKQtPlotter) for LaTeX rendering. The config files live
|
||||
# in a shared JKQTPlotter6 directory, not one named after the package.
|
||||
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}")
|
||||
|
||||
# --- tree-sitter grammar discovery (configure time) ---
|
||||
#
|
||||
# Discover installed tree-sitter grammars — system packages
|
||||
# (libtree-sitter-<lang>.so) and the parsers Neovim's nvim-treesitter
|
||||
# installs (~/.local/share/nvim/site/parser/*.so) — and pair each with
|
||||
# highlight queries. Query sources, in priority order:
|
||||
# 1. vendored highlight-queries/*.scm (version-pinned; see the
|
||||
# per-file source headers, MIT),
|
||||
# 2. Neovim's own queries (version-matched to its parsers),
|
||||
# 3. tree-sitter/highlighting from GitHub (cached in the build dir).
|
||||
# The result is embedded as highlight-queries.hpp. Re-run cmake to pick
|
||||
# up grammars installed later.
|
||||
#
|
||||
# Candidate entry points are read from the .so with `nm` rather than
|
||||
# assumed to be tree_sitter_<id>; a missing entry point just makes that
|
||||
# candidate fail at runtime.
|
||||
function(_ts_entry_point out file)
|
||||
# OUTPUT_VARIABLE + OUTPUT_QUIET loses the output on CMake 4, so
|
||||
# capture through a temp file.
|
||||
get_filename_component(_ts_nm_base "${file}" NAME)
|
||||
set(_ts_nm_file "${CMAKE_CURRENT_BINARY_DIR}/ts-entry-${_ts_nm_base}")
|
||||
execute_process(
|
||||
COMMAND nm -D --defined-only "${file}"
|
||||
RESULT_VARIABLE _ts_nm_rc
|
||||
OUTPUT_FILE "${_ts_nm_file}"
|
||||
ERROR_FILE "${_ts_nm_file}.err")
|
||||
set(_ts_sym "tree_sitter_missing")
|
||||
if(_ts_nm_rc EQUAL 0 AND EXISTS "${_ts_nm_file}")
|
||||
file(READ "${_ts_nm_file}" _ts_nm_out)
|
||||
file(REMOVE "${_ts_nm_file}" "${_ts_nm_file}.err")
|
||||
# The library also exports the external scanner functions; the
|
||||
# entry point is the one without the _external suffix.
|
||||
string(REGEX MATCHALL " T tree_sitter_[A-Za-z0-9_]+" _ts_syms "${_ts_nm_out}")
|
||||
foreach(_ts_s IN LISTS _ts_syms)
|
||||
string(REPLACE " T " "" _ts_s "${_ts_s}")
|
||||
if(NOT _ts_s MATCHES "_external")
|
||||
set(_ts_sym "${_ts_s}")
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
set(${out} "${_ts_sym}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# Append a query text to <files> (PARENT_SCOPE) unless it duplicates a
|
||||
# hash in <hashes>. The text is written to the build tree right away:
|
||||
# the s-expression `;` comments would split CMake list items and the
|
||||
# query `(` `)` break bracket arguments, so query text only ever lives
|
||||
# in variables and files, never in lists.
|
||||
# Expand Neovim's "; inherits:" directives by appending the inherited
|
||||
# query set's highlights file (recursively; a visited set prevents
|
||||
# cycles). Several languages are stubs that inherit the real query
|
||||
# (html inherits html_tags, qmljs inherits ecma, ...).
|
||||
function(_ts_expand_inherits query_dir text outVar)
|
||||
set(result "${text}")
|
||||
set(_visited "")
|
||||
set(_depth 0)
|
||||
while(_depth LESS 8)
|
||||
# Do not match the leading `;`: a MATCHALL result that itself
|
||||
# contains a semicolon is re-split into list items.
|
||||
string(REGEX MATCHALL "inherits:[ \t]*[A-Za-z0-9_]+" _inh "${result}")
|
||||
if(NOT _inh)
|
||||
break()
|
||||
endif()
|
||||
set(_added FALSE)
|
||||
foreach(_entry IN LISTS _inh)
|
||||
string(REGEX REPLACE "^inherits:[ \t]*" "" _name "${_entry}")
|
||||
set(_file "${query_dir}/${_name}/highlights.scm")
|
||||
if(NOT EXISTS "${_file}" OR _file IN_LIST _visited)
|
||||
continue()
|
||||
endif()
|
||||
list(APPEND _visited "${_file}")
|
||||
file(READ "${_file}" _inh_text)
|
||||
string(APPEND result "\n${_inh_text}")
|
||||
set(_added TRUE)
|
||||
endforeach()
|
||||
if(NOT _added)
|
||||
break()
|
||||
endif()
|
||||
math(EXPR _depth "${_depth} + 1")
|
||||
endwhile()
|
||||
set(${outVar} "${result}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_ts_add_query sid text filesVar hashesVar)
|
||||
# filesVar/hashesVar hold the caller's variable names.
|
||||
set(files "${${filesVar}}")
|
||||
set(hashes "${${hashesVar}}")
|
||||
string(SHA256 _ts_qh "${text}")
|
||||
if(_ts_qh IN_LIST hashes)
|
||||
return()
|
||||
endif()
|
||||
list(APPEND hashes "${_ts_qh}")
|
||||
list(LENGTH files _ts_qi)
|
||||
set(_ts_qfile "${CMAKE_CURRENT_BINARY_DIR}/ts-queries/${sid}_${_ts_qi}.scm")
|
||||
file(WRITE "${_ts_qfile}" "${text}")
|
||||
list(APPEND files "${_ts_qfile}")
|
||||
set(${filesVar} "${files}" PARENT_SCOPE)
|
||||
set(${hashesVar} "${hashes}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
set(_ts_candidates "") # entries: <id>|<cmake-safe id>|<lib>|<symbol>
|
||||
file(GLOB _ts_sys_files
|
||||
"/usr/lib/libtree-sitter-*.so" "/usr/local/lib/libtree-sitter-*.so")
|
||||
foreach(_ts_file IN LISTS _ts_sys_files)
|
||||
get_filename_component(_ts_name "${_ts_file}" NAME)
|
||||
string(REGEX REPLACE "^libtree-sitter-(.+)\.so$" "\\1" _ts_id "${_ts_name}")
|
||||
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
|
||||
_ts_entry_point(_ts_sym "${_ts_file}")
|
||||
list(APPEND _ts_candidates
|
||||
"${_ts_id}|${_ts_sid}|libtree-sitter-${_ts_id}.so|${_ts_sym}")
|
||||
endforeach()
|
||||
set(_ts_nvim_parser_dirs
|
||||
"$ENV{HOME}/.local/share/nvim/site/parser"
|
||||
"$ENV{HOME}/.local/share/nvim/runtime/parser"
|
||||
"$ENV{HOME}/.local/share/nvim/lazy/nvim-treesitter/parser")
|
||||
set(_ts_nvim_query_dirs
|
||||
"$ENV{HOME}/.local/share/nvim/site/queries"
|
||||
"$ENV{HOME}/.local/share/nvim/lazy/nvim-treesitter/runtime/queries")
|
||||
foreach(_ts_dir IN LISTS _ts_nvim_parser_dirs)
|
||||
file(GLOB _ts_dir_files "${_ts_dir}/*.so")
|
||||
foreach(_ts_file IN LISTS _ts_dir_files)
|
||||
get_filename_component(_ts_name "${_ts_file}" NAME)
|
||||
string(REGEX REPLACE "\\.so$" "" _ts_id "${_ts_name}")
|
||||
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
|
||||
_ts_entry_point(_ts_sym "${_ts_file}")
|
||||
list(APPEND _ts_candidates
|
||||
"${_ts_id}|${_ts_sid}|${_ts_file}|${_ts_sym}")
|
||||
endforeach()
|
||||
endforeach()
|
||||
|
||||
set(_ts_ids "")
|
||||
foreach(_ts_c IN LISTS _ts_candidates)
|
||||
string(REPLACE "|" ";" _ts_parts "${_ts_c}")
|
||||
list(GET _ts_parts 0 _ts_id)
|
||||
if(NOT _ts_id IN_LIST _ts_ids)
|
||||
list(APPEND _ts_ids "${_ts_id}")
|
||||
endif()
|
||||
endforeach()
|
||||
list(SORT _ts_ids)
|
||||
|
||||
set(HIGHLIGHT_QUERIES_HPP "${CMAKE_CURRENT_BINARY_DIR}/highlight-queries.hpp")
|
||||
set(_hl_header
|
||||
"#pragma once\n\n// Generated by CMake. Discoverd tree-sitter grammars and their\n// highlight queries: vendored highlight-queries/*.scm (MIT), Neovim\n// nvim-treesitter queries, and tree-sitter/highlighting (MIT).\nnamespace ZShell::llm::hq {\nstruct Candidate { const char* lib; const char* symbol; };\nstruct Grammar { const char* id; int nCandidates; const Candidate* candidates; int nQueries; const char* const* queries; };\n")
|
||||
set(_ts_grammar_rows "")
|
||||
set(_ts_vendored
|
||||
c cpp python javascript typescript tsx bash json rust go yaml toml sql)
|
||||
foreach(_ts_id IN LISTS _ts_ids)
|
||||
string(REPLACE "-" "_" _ts_sid "${_ts_id}")
|
||||
|
||||
# Query candidates in priority order (deduped; identical texts are
|
||||
# skipped, the nvim site and plugin copies are usually the same
|
||||
# file).
|
||||
set(_ts_qfiles "")
|
||||
set(_ts_qhashes "")
|
||||
if(_ts_id STREQUAL "cpp")
|
||||
# The C++ grammar is a superset of C and its query only covers
|
||||
# the C++ delta; base C coverage comes from the C query.
|
||||
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/c.scm" _ts_qa)
|
||||
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/cpp.scm" _ts_qb)
|
||||
_ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes)
|
||||
elseif(_ts_id STREQUAL "typescript" OR _ts_id STREQUAL "tsx")
|
||||
# The TS grammars reuse the JS node names; the JS query usually
|
||||
# compiles against them and gives full coverage.
|
||||
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/javascript.scm" _ts_qa)
|
||||
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/typescript.scm" _ts_qb)
|
||||
_ts_add_query(${_ts_sid} "${_ts_qa}\n${_ts_qb}" _ts_qfiles _ts_qhashes)
|
||||
_ts_add_query(${_ts_sid} "${_ts_qb}" _ts_qfiles _ts_qhashes)
|
||||
elseif(_ts_id IN_LIST _ts_vendored)
|
||||
file(READ
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/highlight-queries/${_ts_id}.scm" _ts_q)
|
||||
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
|
||||
endif()
|
||||
foreach(_ts_qd IN LISTS _ts_nvim_query_dirs)
|
||||
if(EXISTS "${_ts_qd}/${_ts_id}/highlights.scm")
|
||||
file(READ "${_ts_qd}/${_ts_id}/highlights.scm" _ts_q)
|
||||
_ts_expand_inherits("${_ts_qd}" "${_ts_q}" _ts_q)
|
||||
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
|
||||
endif()
|
||||
endforeach()
|
||||
if(NOT _ts_qfiles)
|
||||
# Last resort: fetch from the tree-sitter/highlighting repo.
|
||||
# Version-skewed against locally installed grammars, so only
|
||||
# used when nothing local exists. Cached; offline builds simply
|
||||
# drop the language.
|
||||
set(_ts_dl "${CMAKE_CURRENT_BINARY_DIR}/ts-query-downloads/${_ts_sid}.scm")
|
||||
if(NOT EXISTS "${_ts_dl}")
|
||||
file(DOWNLOAD
|
||||
"https://raw.githubusercontent.com/tree-sitter/highlighting/main/queries/${_ts_id}/highlight.scm"
|
||||
"${_ts_dl}" STATUS _ts_dl_status TIMEOUT 30)
|
||||
list(GET _ts_dl_status 0 _ts_dl_rc)
|
||||
if(NOT _ts_dl_rc EQUAL 0)
|
||||
file(REMOVE "${_ts_dl}")
|
||||
endif()
|
||||
endif()
|
||||
if(EXISTS "${_ts_dl}")
|
||||
file(READ "${_ts_dl}" _ts_q)
|
||||
_ts_add_query(${_ts_sid} "${_ts_q}" _ts_qfiles _ts_qhashes)
|
||||
endif()
|
||||
endif()
|
||||
list(LENGTH _ts_qfiles _ts_nq)
|
||||
if(_ts_nq EQUAL 0)
|
||||
continue()
|
||||
endif()
|
||||
|
||||
# Emit query sources.
|
||||
set(_ts_qn 0)
|
||||
set(_ts_q_ptrs "")
|
||||
foreach(_ts_qfile IN LISTS _ts_qfiles)
|
||||
file(READ "${_ts_qfile}" _ts_q)
|
||||
string(APPEND _hl_header
|
||||
"inline constexpr const char* q_${_ts_sid}_${_ts_qn} = R\"ZSQUERY(${_ts_q})ZSQUERY\";\n")
|
||||
string(APPEND _ts_q_ptrs "q_${_ts_sid}_${_ts_qn}, ")
|
||||
math(EXPR _ts_qn "${_ts_qn} + 1")
|
||||
endforeach()
|
||||
string(APPEND _hl_header
|
||||
"inline constexpr const char* const q_${_ts_sid}[] = { ${_ts_q_ptrs} };\n")
|
||||
|
||||
# Emit library candidates (system package first, then Neovim).
|
||||
string(APPEND _hl_header "inline constexpr Candidate cand_${_ts_sid}[] = {\n")
|
||||
set(_ts_nc 0)
|
||||
foreach(_ts_c IN LISTS _ts_candidates)
|
||||
string(REPLACE "|" ";" _ts_parts "${_ts_c}")
|
||||
list(GET _ts_parts 0 _ts_cid)
|
||||
if(_ts_cid STREQUAL _ts_id)
|
||||
list(GET _ts_parts 2 _ts_lib)
|
||||
list(GET _ts_parts 3 _ts_sym)
|
||||
string(APPEND _hl_header
|
||||
" { R\"ZSLIB(${_ts_lib})ZSLIB\", R\"ZSSYM(${_ts_sym})ZSSYM\" },\n")
|
||||
math(EXPR _ts_nc "${_ts_nc} + 1")
|
||||
endif()
|
||||
endforeach()
|
||||
string(APPEND _hl_header "};\n")
|
||||
if(_ts_nc EQUAL 0)
|
||||
# Queries but no library: pointless, drop the language.
|
||||
string(APPEND _hl_header "") # (arrays stay; grammar row is skipped)
|
||||
continue()
|
||||
endif()
|
||||
|
||||
list(APPEND _ts_grammar_rows
|
||||
"{ \"${_ts_id}\", ${_ts_nc}, cand_${_ts_sid}, ${_ts_nq}, q_${_ts_sid} },")
|
||||
endforeach()
|
||||
string(APPEND _hl_header "inline constexpr Grammar grammars[] = {\n")
|
||||
foreach(_ts_row IN LISTS _ts_grammar_rows)
|
||||
string(APPEND _hl_header " ${_ts_row}\n")
|
||||
endforeach()
|
||||
string(APPEND _hl_header "};\n}\n")
|
||||
file(WRITE "${HIGHLIGHT_QUERIES_HPP}" "${_hl_header}")
|
||||
|
||||
# Embed the vendored Latin Modern fonts (GUST Font License; provenance
|
||||
# in fonts/latinmodern/GUST-FONT-LICENSE.txt) as byte arrays.
|
||||
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
|
||||
chat.hpp chat.cpp
|
||||
chatstore.hpp chatstore.cpp
|
||||
codehighlighter.hpp codehighlighter.cpp
|
||||
generation.hpp generation.cpp
|
||||
llmclient.hpp llmclient.cpp
|
||||
markdownblock.hpp
|
||||
markdownparser.hpp markdownparser.cpp
|
||||
mathtext.hpp mathtext.cpp
|
||||
message.hpp message.cpp
|
||||
messagemodel.hpp messagemodel.cpp
|
||||
segment.hpp segment.cpp
|
||||
session.hpp session.cpp
|
||||
tool.hpp tool.cpp
|
||||
webfetchtool.hpp webfetchtool.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})
|
||||
target_include_directories(ZShell-llm PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../Config)
|
||||
@@ -0,0 +1,167 @@
|
||||
#include "chat.hpp"
|
||||
|
||||
#include "config.hpp"
|
||||
#include "llm.hpp"
|
||||
#include "llmclient.hpp"
|
||||
#include "webfetchtool.hpp"
|
||||
|
||||
#include <QDebug>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
Chat::Chat(QObject* parent)
|
||||
: QObject(parent)
|
||||
, m_store(new ChatStore(this))
|
||||
, m_client(new LlmClient(this)) {
|
||||
if (!config::Config::instance()) new config::Config();
|
||||
|
||||
m_store->setLlmClient(m_client);
|
||||
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
|
||||
|
||||
const auto* llm = config::Config::instance()->llm();
|
||||
m_client->setEndpoint(llm->endpoint());
|
||||
m_client->setModel(llm->model());
|
||||
m_client->setTemperature(llm->temperature());
|
||||
m_client->setToolsEnabled(llm->tools());
|
||||
|
||||
connect(llm, &config::Llm::endpointChanged, this, [this, llm]() {
|
||||
m_client->setEndpoint(llm->endpoint());
|
||||
});
|
||||
connect(llm, &config::Llm::modelChanged, this, [this, llm]() {
|
||||
m_client->setModel(llm->model());
|
||||
});
|
||||
connect(llm, &config::Llm::temperatureChanged, this, [this, llm]() {
|
||||
m_client->setTemperature(llm->temperature());
|
||||
});
|
||||
connect(llm, &config::Llm::toolsChanged, this, [this, llm]() {
|
||||
m_client->setToolsEnabled(llm->tools());
|
||||
});
|
||||
|
||||
connect(m_client, &LlmClient::busyChanged, this, [this]() {
|
||||
// A fresh run supersedes the previous error.
|
||||
if (m_client->busy() && !m_lastError.isEmpty()) {
|
||||
m_lastError.clear();
|
||||
Q_EMIT lastErrorChanged();
|
||||
}
|
||||
Q_EMIT busyChanged();
|
||||
});
|
||||
connect(m_client, &LlmClient::endpointChanged, this, &Chat::endpointChanged);
|
||||
connect(m_client, &LlmClient::modelChanged, this, &Chat::modelChanged);
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::availableModelsChanged,
|
||||
this,
|
||||
&Chat::availableModelsChanged);
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::contextSizeChanged,
|
||||
this,
|
||||
&Chat::contextSizeChanged);
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::streamingChatIdChanged,
|
||||
this,
|
||||
&Chat::streamingChatIdChanged);
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::toolsEnabledChanged,
|
||||
this,
|
||||
&Chat::toolsEnabledChanged);
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::errorOccurred,
|
||||
this,
|
||||
[this](const QString& message) {
|
||||
m_lastError = message;
|
||||
Q_EMIT lastErrorChanged();
|
||||
Q_EMIT errorOccurred(message);
|
||||
});
|
||||
connect(
|
||||
m_store,
|
||||
&ChatStore::sessionRemoved,
|
||||
this,
|
||||
[this](ChatSession* session) { m_client->sessionRemoved(session); });
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::titleSuggested,
|
||||
this,
|
||||
[this](ChatSession* session, const QString& title) {
|
||||
qInfo() << "Chat: applying generated title" << session->id()
|
||||
<< title << "(was" << session->title() << ")";
|
||||
session->setTitle(title);
|
||||
m_store->saveMeta(session);
|
||||
});
|
||||
connect(
|
||||
m_client,
|
||||
&LlmClient::iconSuggested,
|
||||
this,
|
||||
[this](ChatSession* session, const QString& icon) {
|
||||
qInfo() << "Chat: applying generated icon" << session->id() << icon
|
||||
<< "(was" << session->icon() << ")";
|
||||
session->setIcon(icon);
|
||||
m_store->saveMeta(session);
|
||||
});
|
||||
}
|
||||
|
||||
bool Chat::busy() const {
|
||||
return m_client->busy();
|
||||
}
|
||||
|
||||
QString Chat::endpoint() const {
|
||||
return m_client->endpoint();
|
||||
}
|
||||
|
||||
QString Chat::model() const {
|
||||
return m_client->model();
|
||||
}
|
||||
|
||||
QStringList Chat::availableModels() const {
|
||||
return m_client->availableModels();
|
||||
}
|
||||
|
||||
int Chat::contextSize() const {
|
||||
return m_client->contextSize();
|
||||
}
|
||||
|
||||
bool Chat::toolsEnabled() const {
|
||||
return m_client->toolsEnabled();
|
||||
}
|
||||
|
||||
void Chat::setToolsEnabled(bool value) {
|
||||
m_client->setToolsEnabled(value);
|
||||
if (auto* config = config::Config::instance())
|
||||
config->llm()->set_tools(value);
|
||||
}
|
||||
|
||||
QString Chat::streamingChatId() const {
|
||||
return m_client->streamingChatId();
|
||||
}
|
||||
|
||||
Chat* Chat::s_instance = nullptr;
|
||||
|
||||
Chat* Chat::create(QQmlEngine*, QJSEngine*) {
|
||||
if (!s_instance) s_instance = new Chat();
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
void Chat::stop() {
|
||||
m_client->stop();
|
||||
}
|
||||
|
||||
void Chat::dismissError() {
|
||||
if (m_lastError.isEmpty()) return;
|
||||
m_lastError.clear();
|
||||
Q_EMIT lastErrorChanged();
|
||||
}
|
||||
|
||||
void Chat::refreshModels() {
|
||||
m_client->refreshModels();
|
||||
}
|
||||
|
||||
void Chat::selectModel(const QString& id) {
|
||||
if (id.isEmpty()) return;
|
||||
m_client->setModel(id);
|
||||
if (auto* config = config::Config::instance()) config->llm()->set_model(id);
|
||||
}
|
||||
|
||||
} // namespace ZShell::llm
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QtQml>
|
||||
|
||||
#include "chatstore.hpp"
|
||||
|
||||
class QQmlEngine;
|
||||
class QJSEngine;
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
class LlmClient;
|
||||
|
||||
// QML-facing facade. Persistence lives in ChatStore, network streaming in
|
||||
// LlmClient; this class only wires them together and exposes the
|
||||
// application-wide state.
|
||||
class Chat : public QObject {
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_SINGLETON
|
||||
|
||||
Q_PROPERTY(bool busy READ busy NOTIFY busyChanged)
|
||||
Q_PROPERTY(QString endpoint READ endpoint NOTIFY endpointChanged)
|
||||
Q_PROPERTY(QString model READ model NOTIFY modelChanged)
|
||||
Q_PROPERTY(
|
||||
QStringList availableModels READ availableModels NOTIFY
|
||||
availableModelsChanged)
|
||||
Q_PROPERTY(int contextSize READ contextSize NOTIFY contextSizeChanged)
|
||||
Q_PROPERTY(
|
||||
bool toolsEnabled READ toolsEnabled WRITE setToolsEnabled NOTIFY
|
||||
toolsEnabledChanged)
|
||||
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
|
||||
Q_PROPERTY(ZShell::llm::ChatStore* chats READ chats CONSTANT)
|
||||
Q_PROPERTY(
|
||||
QString streamingChatId READ streamingChatId NOTIFY
|
||||
streamingChatIdChanged)
|
||||
|
||||
public:
|
||||
explicit Chat(QObject* parent = nullptr);
|
||||
|
||||
[[nodiscard]] bool busy() const;
|
||||
[[nodiscard]] QString endpoint() const;
|
||||
[[nodiscard]] QString model() const;
|
||||
[[nodiscard]] QStringList availableModels() const;
|
||||
[[nodiscard]] int contextSize() const;
|
||||
[[nodiscard]] bool toolsEnabled() const;
|
||||
void setToolsEnabled(bool value);
|
||||
[[nodiscard]] QString lastError() const { return m_lastError; }
|
||||
[[nodiscard]] ChatStore* chats() const { return m_store; }
|
||||
[[nodiscard]] QString streamingChatId() const;
|
||||
|
||||
Q_INVOKABLE void stop();
|
||||
Q_INVOKABLE void dismissError();
|
||||
Q_INVOKABLE void refreshModels();
|
||||
Q_INVOKABLE void selectModel(const QString& id);
|
||||
|
||||
static Chat* create(QQmlEngine*, QJSEngine*);
|
||||
|
||||
Q_SIGNALS:
|
||||
void busyChanged();
|
||||
void endpointChanged();
|
||||
void modelChanged();
|
||||
void availableModelsChanged();
|
||||
void contextSizeChanged();
|
||||
void toolsEnabledChanged();
|
||||
void errorOccurred(const QString& message);
|
||||
void lastErrorChanged();
|
||||
void streamingChatIdChanged();
|
||||
|
||||
private:
|
||||
ChatStore* m_store = nullptr;
|
||||
LlmClient* m_client = nullptr;
|
||||
QString m_lastError;
|
||||
|
||||
static Chat* s_instance;
|
||||
};
|
||||
|
||||
} // namespace ZShell::llm
|
||||
@@ -0,0 +1,615 @@
|
||||
#include "chatstore.hpp"
|
||||
|
||||
#include "llmclient.hpp"
|
||||
#include "message.hpp"
|
||||
#include "segment.hpp"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QPointer>
|
||||
#include <QStandardPaths>
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlError>
|
||||
#include <QSqlQuery>
|
||||
#include <QThreadPool>
|
||||
#include <QVector>
|
||||
#include <QUuid>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
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 MessageRow {
|
||||
bool user = false;
|
||||
qint64 timestamp = 0;
|
||||
QVector<GenerationRow> generations;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
ChatStore::ChatStore(QObject* parent)
|
||||
: QObject(parent), m_connectionName(QUuid::createUuid().toString()) {
|
||||
openDb();
|
||||
load();
|
||||
}
|
||||
|
||||
ChatStore::~ChatStore() {
|
||||
if (m_connectionName.isEmpty()) return;
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connectionName, false);
|
||||
db.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 db =
|
||||
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
|
||||
db.setDatabaseName(m_dbPath);
|
||||
if (!db.open()) {
|
||||
qWarning() << "ChatStore: failed to open database" << m_dbPath << ":"
|
||||
<< db.lastError().text();
|
||||
return;
|
||||
}
|
||||
{
|
||||
QSqlQuery pragma(db);
|
||||
pragma.exec(QStringLiteral("PRAGMA foreign_keys = ON"));
|
||||
}
|
||||
{
|
||||
QSqlQuery query(db);
|
||||
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(db);
|
||||
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"
|
||||
" timestamp INTEGER NOT NULL\n"
|
||||
")"));
|
||||
}
|
||||
{
|
||||
QSqlQuery query(db);
|
||||
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(db);
|
||||
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(db);
|
||||
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)"));
|
||||
}
|
||||
}
|
||||
|
||||
int ChatStore::count() const {
|
||||
return static_cast<int>(m_sessions.size());
|
||||
}
|
||||
|
||||
QVariantList ChatStore::values() const {
|
||||
QVariantList vals;
|
||||
vals.reserve(m_sessions.size());
|
||||
for (const auto* session : m_sessions)
|
||||
vals.append(QVariant::fromValue(session));
|
||||
return vals;
|
||||
}
|
||||
|
||||
ChatSession* ChatStore::at(int index) const {
|
||||
if (index < 0 || index >= m_sessions.size()) return nullptr;
|
||||
return m_sessions.at(index);
|
||||
}
|
||||
|
||||
ChatSession* ChatStore::insert(int index) {
|
||||
const qint64 now = QDateTime::currentMSecsSinceEpoch();
|
||||
const QString id = QUuid::createUuid().toString();
|
||||
{
|
||||
QSqlQuery query(db());
|
||||
query.prepare(
|
||||
"INSERT INTO sessions (id, title, created_at, updated_at) "
|
||||
"VALUES (:id, '', :created_at, :updated_at)");
|
||||
query.bindValue(":id", id);
|
||||
query.bindValue(":created_at", now);
|
||||
query.bindValue(":updated_at", now);
|
||||
if (!query.exec())
|
||||
qWarning() << "ChatStore: failed to insert session" << id << ":"
|
||||
<< query.lastError().text();
|
||||
}
|
||||
auto* session = new ChatSession(id, this);
|
||||
session->setMeta(QString(), now, now, 0);
|
||||
const int pos = index >= 0 && index <= m_sessions.size() ? index : 0;
|
||||
m_sessions.insert(pos, session);
|
||||
Q_EMIT countChanged();
|
||||
Q_EMIT valuesChanged();
|
||||
return session;
|
||||
}
|
||||
|
||||
void ChatStore::remove(int index) {
|
||||
removeSession(at(index));
|
||||
}
|
||||
|
||||
void ChatStore::remove(ChatSession* chat) {
|
||||
removeSession(chat);
|
||||
}
|
||||
|
||||
void ChatStore::removeSession(ChatSession* session) {
|
||||
if (!session || !m_sessions.contains(session)) return;
|
||||
const QList<ChatSession*> before = m_sessions;
|
||||
Q_EMIT sessionRemoved(session);
|
||||
{
|
||||
QSqlQuery query(db());
|
||||
query.prepare("DELETE FROM sessions WHERE id = :id");
|
||||
query.bindValue(":id", session->id());
|
||||
query.exec();
|
||||
}
|
||||
m_sessions.removeOne(session);
|
||||
session->deleteLater();
|
||||
notify(before);
|
||||
}
|
||||
|
||||
void ChatStore::move(int from, int to) {
|
||||
if (from < 0 || from >= m_sessions.size() || to < 0 ||
|
||||
to >= m_sessions.size() || from == to)
|
||||
return;
|
||||
m_sessions.move(from, to);
|
||||
Q_EMIT valuesChanged();
|
||||
}
|
||||
|
||||
void ChatStore::clear() {
|
||||
const QList<ChatSession*> sessions = m_sessions;
|
||||
for (ChatSession* session : sessions)
|
||||
removeSession(session);
|
||||
}
|
||||
|
||||
ChatSession* ChatStore::sessionById(const QString& id) {
|
||||
for (auto* session : m_sessions)
|
||||
if (session->id() == id) return session;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void ChatStore::setLlmClient(LlmClient* client) {
|
||||
m_llmClient = client;
|
||||
}
|
||||
|
||||
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;
|
||||
sortAndNotify();
|
||||
}
|
||||
|
||||
void ChatStore::saveMeta(ChatSession* session) {
|
||||
if (!session || !m_sessions.contains(session)) return;
|
||||
QSqlQuery query(db());
|
||||
query.prepare(
|
||||
"UPDATE sessions SET title = :title, icon = :icon "
|
||||
"WHERE id = :id");
|
||||
query.bindValue(":title", sqlText(session->title()));
|
||||
query.bindValue(":icon", sqlText(session->icon()));
|
||||
query.bindValue(":id", session->id());
|
||||
if (!query.exec())
|
||||
qWarning() << "ChatStore: failed to save meta for" << session->id()
|
||||
<< ":" << query.lastError().text();
|
||||
}
|
||||
|
||||
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, updated_at = :updated_at "
|
||||
"WHERE id = :id");
|
||||
query.bindValue(":title", sqlText(session->title()));
|
||||
query.bindValue(":updated_at", session->updatedAtMs());
|
||||
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, timestamp) "
|
||||
"VALUES (:id, :role, :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)");
|
||||
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(":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()));
|
||||
segmentInsert.bindValue(":text", sqlText(segment->text()));
|
||||
segmentInsert.bindValue(":name", sqlText(segment->name()));
|
||||
segmentInsert.bindValue(
|
||||
":tool_call_id", sqlText(segment->toolCallId()));
|
||||
segmentInsert.bindValue(
|
||||
":arguments", sqlText(segment->arguments()));
|
||||
segmentInsert.bindValue(
|
||||
":result", sqlText(segment->result()));
|
||||
segmentInsert.bindValue(
|
||||
":status", static_cast<int>(segment->status()));
|
||||
segmentInsert.bindValue(":elapsed_ms", segment->elapsedMs());
|
||||
segmentInsert.bindValue(":timestamp", segment->timestamp());
|
||||
if (!segmentInsert.exec()) {
|
||||
ok = false;
|
||||
qWarning() << "ChatStore: saveSession" << id
|
||||
<< "segment insert failed:"
|
||||
<< segmentInsert.lastError().text();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ok || !handle.commit()) {
|
||||
qWarning() << "ChatStore: saveSession" << id
|
||||
<< "commit failed, rolling back";
|
||||
handle.rollback();
|
||||
ok = false;
|
||||
qWarning() << "ChatStore: failed to save session" << session->id()
|
||||
<< ":" << handle.lastError().text();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
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 db =
|
||||
QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connName);
|
||||
db.setDatabaseName(path);
|
||||
if (db.open()) {
|
||||
QSqlQuery busy(db);
|
||||
busy.exec(QStringLiteral("PRAGMA busy_timeout = 3000"));
|
||||
QSqlQuery query(db);
|
||||
query.prepare(
|
||||
"SELECT id, role, timestamp FROM messages "
|
||||
"WHERE session_id = :id ORDER BY rowid DESC");
|
||||
query.bindValue(":id", sessionId);
|
||||
if (!query.exec()) {
|
||||
qWarning() << "ChatStore: failed to load messages for"
|
||||
<< sessionId << ":" << query.lastError().text();
|
||||
} else {
|
||||
while (query.next()) {
|
||||
const int messageId = query.value(0).toInt();
|
||||
MessageRow message;
|
||||
message.user = query.value(1).toString() ==
|
||||
QLatin1String("user");
|
||||
message.timestamp = query.value(2).toLongLong();
|
||||
QSqlQuery generationQuery(db);
|
||||
generationQuery.prepare(
|
||||
"SELECT id, timestamp, is_active FROM "
|
||||
"generations WHERE message_id = :mid "
|
||||
"ORDER BY rowid");
|
||||
generationQuery.bindValue(":mid", messageId);
|
||||
if (generationQuery.exec()) {
|
||||
while (generationQuery.next()) {
|
||||
GenerationRow generation;
|
||||
generation.timestamp =
|
||||
generationQuery.value(1).toLongLong();
|
||||
generation.active =
|
||||
generationQuery.value(2).toInt() != 0;
|
||||
QSqlQuery segmentQuery(db);
|
||||
segmentQuery.prepare(
|
||||
"SELECT type, text, name, tool_call_id, "
|
||||
"arguments, result, status, elapsed_ms, "
|
||||
"timestamp FROM segments WHERE "
|
||||
"generation_id = :gid ORDER BY rowid");
|
||||
segmentQuery.bindValue(
|
||||
":gid", generationQuery.value(0).toInt());
|
||||
if (segmentQuery.exec()) {
|
||||
while (segmentQuery.next()) {
|
||||
SegmentRow segment;
|
||||
segment.type =
|
||||
segmentQuery.value(0).toString();
|
||||
segment.text =
|
||||
segmentQuery.value(1).toString();
|
||||
segment.name =
|
||||
segmentQuery.value(2).toString();
|
||||
segment.toolCallId =
|
||||
segmentQuery.value(3).toString();
|
||||
segment.arguments =
|
||||
segmentQuery.value(4).toString();
|
||||
segment.result =
|
||||
segmentQuery.value(5).toString();
|
||||
segment.status =
|
||||
segmentQuery.value(6).toInt();
|
||||
segment.elapsedMs =
|
||||
segmentQuery.value(7).toLongLong();
|
||||
segment.timestamp =
|
||||
segmentQuery.value(8).toLongLong();
|
||||
generation.segments.append(segment);
|
||||
}
|
||||
} else {
|
||||
qWarning()
|
||||
<< "ChatStore: failed to load "
|
||||
"segments for generation"
|
||||
<< generationQuery.value(0).toInt()
|
||||
<< ":"
|
||||
<< segmentQuery.lastError().text();
|
||||
}
|
||||
message.generations.append(generation);
|
||||
}
|
||||
} else {
|
||||
qWarning()
|
||||
<< "ChatStore: failed to load generations "
|
||||
"for message"
|
||||
<< messageId << ":"
|
||||
<< generationQuery.lastError().text();
|
||||
}
|
||||
rows.append(message);
|
||||
}
|
||||
}
|
||||
db.close();
|
||||
} else {
|
||||
qWarning() << "ChatStore: failed to open database for load:"
|
||||
<< db.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->model();
|
||||
if (!model) return;
|
||||
QList<ChatMessage*> messages;
|
||||
for (const MessageRow& row : rows) {
|
||||
auto* message = model->createMessage(
|
||||
row.user ? ChatMessage::Role::User
|
||||
: ChatMessage::Role::Assistant,
|
||||
row.timestamp);
|
||||
int activeIndex = 0;
|
||||
for (int i = 0; i < row.generations.size(); ++i) {
|
||||
const GenerationRow& generationRow =
|
||||
row.generations.at(i);
|
||||
auto* generation =
|
||||
message->addGeneration(generationRow.timestamp);
|
||||
for (const SegmentRow& segmentRow :
|
||||
generationRow.segments) {
|
||||
auto* segment = new LlmSegment(
|
||||
segmentTypeFromName(segmentRow.type),
|
||||
segmentRow.timestamp,
|
||||
generation);
|
||||
segment->setText(segmentRow.text);
|
||||
segment->setName(segmentRow.name);
|
||||
segment->setToolCallId(segmentRow.toolCallId);
|
||||
segment->appendArguments(segmentRow.arguments);
|
||||
segment->setResult(segmentRow.result);
|
||||
segment->setStatus(
|
||||
static_cast<LlmSegment::Status>(
|
||||
segmentRow.status));
|
||||
segment->restore(segmentRow.elapsedMs);
|
||||
generation->addSegment(segment);
|
||||
}
|
||||
if (generationRow.active) activeIndex = i;
|
||||
}
|
||||
message->setActiveGeneration(activeIndex);
|
||||
messages.append(message);
|
||||
}
|
||||
if (model->rowCount() > 0) {
|
||||
QList<ChatMessage*> live = messages;
|
||||
for (int r = 0; r < model->rowCount(); ++r)
|
||||
live.prepend(model->at(r));
|
||||
messages = live;
|
||||
}
|
||||
if (!messages.isEmpty() || model->rowCount() > 0)
|
||||
s->adoptMessages(messages);
|
||||
|
||||
s->markLoaded();
|
||||
|
||||
if (s->takeClearPending()) {
|
||||
s->clear();
|
||||
} else if (st->m_pendingPersists.remove(s)) {
|
||||
st->persist(s);
|
||||
}
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
});
|
||||
}
|
||||
|
||||
void ChatStore::load() {
|
||||
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(query.value(0).toString(), this);
|
||||
session->setMeta(
|
||||
query.value(1).toString(),
|
||||
query.value(3).toLongLong(),
|
||||
query.value(4).toLongLong(),
|
||||
query.value(6).toInt());
|
||||
session->setIcon(query.value(2).toString());
|
||||
session->setPinned(query.value(5).toInt() != 0);
|
||||
m_sessions.append(session);
|
||||
}
|
||||
sortAndNotify();
|
||||
}
|
||||
|
||||
void ChatStore::sortAndNotify() {
|
||||
const QList<ChatSession*> before = m_sessions;
|
||||
std::stable_sort(
|
||||
m_sessions.begin(),
|
||||
m_sessions.end(),
|
||||
[](const ChatSession* a, const ChatSession* b) {
|
||||
if (a->pinned() != b->pinned()) return a->pinned() > b->pinned();
|
||||
return a->updatedAtMs() > b->updatedAtMs();
|
||||
});
|
||||
notify(before);
|
||||
}
|
||||
|
||||
void ChatStore::notify(const QList<ChatSession*>& before) {
|
||||
if (before.size() != m_sessions.size()) Q_EMIT countChanged();
|
||||
bool same = before.size() == m_sessions.size();
|
||||
for (int i = 0; same && i < m_sessions.size(); ++i)
|
||||
if (before.at(i) != m_sessions.at(i)) same = false;
|
||||
if (!same) Q_EMIT valuesChanged();
|
||||
}
|
||||
|
||||
} // namespace ZShell::llm
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include "session.hpp"
|
||||
|
||||
#include <QObject>
|
||||
#include <QSet>
|
||||
#include <QSqlDatabase>
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
|
||||
namespace ZShell::llm {
|
||||
|
||||
class LlmClient;
|
||||
|
||||
class ChatStore : public QObject {
|
||||
Q_OBJECT
|
||||
QML_ANONYMOUS
|
||||
|
||||
Q_PROPERTY(int count READ count NOTIFY countChanged)
|
||||
Q_PROPERTY(QVariantList values READ values NOTIFY valuesChanged)
|
||||
|
||||
public:
|
||||
explicit ChatStore(QObject* parent = nullptr);
|
||||
~ChatStore() override;
|
||||
|
||||
[[nodiscard]] int count() const;
|
||||
[[nodiscard]] QVariantList values() const;
|
||||
[[nodiscard]] ChatSession* at(int index) const;
|
||||
|
||||
Q_INVOKABLE ZShell::llm::ChatSession* insert(int index = -1);
|
||||
Q_INVOKABLE void remove(int index);
|
||||
Q_INVOKABLE void remove(ZShell::llm::ChatSession* chat);
|
||||
Q_INVOKABLE void move(int from, int to);
|
||||
Q_INVOKABLE void clear();
|
||||
|
||||
[[nodiscard]] ChatSession* sessionById(const QString& id);
|
||||
[[nodiscard]] LlmClient* llmClient() const { return m_llmClient; }
|
||||
void setLlmClient(LlmClient* client);
|
||||
|
||||
void persist(ChatSession* session);
|
||||
void saveMeta(ChatSession* session);
|
||||
void loadMessagesInto(ChatSession* session);
|
||||
|
||||
Q_SIGNALS:
|
||||
void countChanged();
|
||||
void valuesChanged();
|
||||
void sessionRemoved(ZShell::llm::ChatSession* session);
|
||||
|
||||
private:
|
||||
void openDb();
|
||||
void load();
|
||||
bool saveSession(ChatSession* session);
|
||||
void sortAndNotify();
|
||||
void removeSession(ChatSession* session);
|
||||
void notify(const QList<ChatSession*>& before);
|
||||
|
||||
QList<ChatSession*> m_sessions;
|
||||
LlmClient* m_llmClient = nullptr;
|
||||
QString m_connectionName;
|
||||
QString m_dbPath;
|
||||
QSet<ChatSession*> m_pendingPersists;
|
||||
|
||||
[[nodiscard]] QSqlDatabase db() const;
|
||||
};
|
||||
|
||||
} // namespace ZShell::llm
|
||||
@@ -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; // unknown tags = grammar id
|
||||
}
|
||||
|
||||
QString CodeHighlighter::cacheKey(const QString& id, const QString& code) {
|
||||
return id + QLatin1Char('\x01') + QString::number(code.size()) +
|
||||
QLatin1Char('\x01') + QString::number(qHash(code));
|
||||
}
|
||||
|
||||
QVariantList CodeHighlighter::lookupSpans(
|
||||
const QString& code, const QString& language) const {
|
||||
if (code.isEmpty()) return {};
|
||||
const QString key = cacheKey(resolveId(language), code);
|
||||
QMutexLocker locker(&m_cacheMutex);
|
||||
const auto it = m_spanCache.constFind(key);
|
||||
if (it == m_spanCache.constEnd() || it->code != code) return {};
|
||||
const qsizetype pos = m_spanCacheOrder.indexOf(key);
|
||||
if (pos >= 0) m_spanCacheOrder.move(pos, m_spanCacheOrder.size() - 1);
|
||||
return it->spans;
|
||||
}
|
||||
|
||||
void CodeHighlighter::storeSpans(
|
||||
const QString& code,
|
||||
const QString& language,
|
||||
const QVariantList& spans) const {
|
||||
if (spans.isEmpty() || code.isEmpty()) return;
|
||||
static constexpr int kMaxEntries = 32;
|
||||
static constexpr int kMaxBytes = 1024 * 1024;
|
||||
const QString key = cacheKey(resolveId(language), code);
|
||||
const int bytes = static_cast<int>(code.toUtf8().size());
|
||||
QMutexLocker locker(&m_cacheMutex);
|
||||
auto it = m_spanCache.find(key);
|
||||
if (it != m_spanCache.end()) {
|
||||
m_spanCacheBytes -= static_cast<int>(it->code.toUtf8().size());
|
||||
m_spanCache.erase(it);
|
||||
m_spanCacheOrder.removeAll(key);
|
||||
}
|
||||
while (m_spanCacheOrder.size() >= kMaxEntries ||
|
||||
m_spanCacheBytes + bytes > kMaxBytes) {
|
||||
if (m_spanCacheOrder.isEmpty()) break;
|
||||
const QString oldest = m_spanCacheOrder.takeFirst();
|
||||
m_spanCacheBytes -=
|
||||
static_cast<int>(m_spanCache.value(oldest).code.toUtf8().size());
|
||||
m_spanCache.remove(oldest);
|
||||
}
|
||||
m_spanCache.insert(key, SpanCacheEntry{code, spans});
|
||||
m_spanCacheOrder.append(key);
|
||||
m_spanCacheBytes += bytes;
|
||||
}
|
||||
|
||||
void CodeHighlighter::highlight(
|
||||
const QString& code, const QString& language, QObject* target, int token) {
|
||||
QPointer<QObject> targetGuard(target);
|
||||
const QVariantList cached = lookupSpans(code, language);
|
||||
if (!cached.isEmpty()) {
|
||||
QMetaObject::invokeMethod(
|
||||
targetGuard,
|
||||
"onHighlightSpans",
|
||||
Qt::DirectConnection,
|
||||
Q_ARG(QVariant, token),
|
||||
Q_ARG(QVariant, cached));
|
||||
return;
|
||||
}
|
||||
QThreadPool::globalInstance()->start(
|
||||
[this, target, token, code, language]() {
|
||||
const QVariantList spans = doHighlight(code, language);
|
||||
storeSpans(code, language, spans);
|
||||
QPointer<QObject> guard(target);
|
||||
QMetaObject::invokeMethod(
|
||||
QCoreApplication::instance(),
|
||||
[guard, token, spans]() {
|
||||
if (!guard) return;
|
||||
QMetaObject::invokeMethod(
|
||||
guard,
|
||||
"onHighlightSpans",
|
||||
Q_ARG(QVariant, token),
|
||||
Q_ARG(QVariant, spans));
|
||||
},
|
||||
Qt::QueuedConnection);
|
||||
});
|
||||
}
|
||||
|
||||
QVariantList CodeHighlighter::doHighlight(
|
||||
const QString& code, const QString& language) const {
|
||||
QVariantList spans;
|
||||
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)
|
||||
; // continuation byte
|
||||
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,76 @@
|
||||
#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; // permanent failure, do not retry
|
||||
void* lib = nullptr;
|
||||
const void* lang = nullptr; // const TSLanguage*
|
||||
void* query = nullptr; // TSQuery*
|
||||
};
|
||||
|
||||
[[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; // re-compared on lookup; a hash collision can
|
||||
// never deliver the wrong spans
|
||||
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; // LRU order, oldest first
|
||||
mutable int m_spanCacheBytes = 0;
|
||||
mutable QMutex m_cacheMutex;
|
||||
static CodeHighlighter* s_instance;
|
||||
};
|
||||
|
||||
} // 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.
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