diff --git a/Components/ColorArcPicker.qml b/Components/ColorArcPicker.qml index 0a14f32..43b9c34 100644 --- a/Components/ColorArcPicker.qml +++ b/Components/ColorArcPicker.qml @@ -46,10 +46,10 @@ Item { } function syncFromPenColor() { - if (!drawing.drawingState) + if (!drawing.screenState) return; - const c = drawing.drawingState.penColor; + const c = drawing.screenState.penColor; if (c.hsvSaturation > 0) { currentHue = c.hsvHue; @@ -86,7 +86,7 @@ Item { currentHue = relative / arcSweep; lastChromaticHue = currentHue; - drawing.drawingState.penColor = Qt.hsva(currentHue, drawing.drawingState.penColor.hsvSaturation, drawing.drawingState.penColor.hsvValue, drawing.drawingState.penColor.a); + drawing.screenState.penColor = Qt.hsva(currentHue, drawing.screenState.penColor.hsvSaturation, drawing.screenState.penColor.hsvValue, drawing.screenState.penColor.a); } implicitHeight: 180 @@ -112,7 +112,7 @@ Item { root.syncFromPenColor(); } - target: root.drawing.drawingState + target: root.drawing.screenState } Canvas { @@ -152,7 +152,7 @@ Item { CustomRect { anchors.centerIn: parent - color: root.drawing?.drawingState.penColor + color: root.drawing?.screenState.penColor implicitHeight: implicitWidth implicitWidth: canvas.height - root.handleSize - Tokens.padding.extraLarge * 2 radius: Tokens.rounding.full diff --git a/Components/CustomFlickable.qml b/Components/CustomFlickable.qml index 556d439..7bf9cfb 100644 --- a/Components/CustomFlickable.qml +++ b/Components/CustomFlickable.qml @@ -1,12 +1,12 @@ import QtQuick -import qs.Helpers +import qs.Services Flickable { id: root property bool doneFakeFlick - interactive: !Visibilities.getForActive().isDrawing + interactive: !ShellState.forActive().isDrawing maximumFlickVelocity: 3000 rebound: Transition { diff --git a/Components/CustomListView.qml b/Components/CustomListView.qml index 84c6aba..97936d3 100644 --- a/Components/CustomListView.qml +++ b/Components/CustomListView.qml @@ -1,12 +1,12 @@ import QtQuick -import qs.Helpers +import qs.Services ListView { id: root property bool doneFakeFlick - interactive: !Visibilities.getForActive()?.isDrawing + interactive: !ShellState.forActive()?.isDrawing maximumFlickVelocity: 3000 rebound: Transition { diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml index 69339d5..cad617c 100644 --- a/Components/CustomScrollBar.qml +++ b/Components/CustomScrollBar.qml @@ -2,7 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Templates -import qs.Helpers +import qs.Services import ZShell.Config ScrollBar { @@ -26,7 +26,7 @@ ScrollBar { property bool shouldBeActive property bool inverted - enabled: !Visibilities.getForActive().isDrawing + enabled: !ShellState.forActive().isDrawing implicitWidth: size === 1 ? 0 : isHorizontal ? 0 : Tokens.padding.extraSmall * 2 implicitHeight: size === 1 ? 0 : isHorizontal ? Tokens.padding.extraSmall * 2 : 0 diff --git a/Components/CustomTextField.qml b/Components/CustomTextField.qml index 8ddc120..66141b8 100644 --- a/Components/CustomTextField.qml +++ b/Components/CustomTextField.qml @@ -15,28 +15,33 @@ TextFieldBase { readonly property int clampedRadius: Math.min(horizontalPadding, Math.min(width, height) / 2, radius) readonly property string effectiveSupportingText: isError && errorText ? errorText : supportingText + readonly property int filledOffset: type === CustomTextField.Filled ? Tokens.spacing.small : 0 + readonly property int leadingOffset: leadingIcon ? leadingIconLoader.width + leadingIconLoader.anchors.leftMargin : 0 + readonly property real smallFontScale: smallFontSize / font.pointSize + readonly property int supportingTextOffset: effectiveSupportingText ? supportingTextLoader.height + Tokens.spacing.extraSmall : 0 + readonly property int trailingOffset: trailingIcon ? trailingIconLoader.width + trailingIconLoader.anchors.rightMargin : 0 + readonly property bool valid: !validate || (!text && emptyIsValid) || (validate instanceof RegExp ? validate.test(text) : !!validate(text)) + + property int horizontalPadding: Tokens.padding.large + property int verticalPadding: Tokens.padding.large + + property int radius: Tokens.rounding.small + + property string supportingText + property string trailingIcon + property int smallFontSize: Tokens.font.size.small + + property int type: CustomTextField.Outlined + property var validate // Regex or function property bool emptyIsValid: true property string errorText - readonly property int filledOffset: type === CustomTextField.Filled ? Tokens.spacing.small : 0 - readonly property int horizontalPadding: Tokens.padding.large property bool isError property string leadingIcon - readonly property int leadingOffset: leadingIcon ? leadingIconLoader.width + leadingIconLoader.anchors.leftMargin : 0 - property int radius: Tokens.rounding.small - readonly property real smallFontScale: smallFontSize / font.pointSize - property int smallFontSize: Tokens.font.size.small - property string supportingText - readonly property int supportingTextOffset: effectiveSupportingText ? supportingTextLoader.height + Tokens.spacing.extraSmall : 0 - property string trailingIcon - readonly property int trailingOffset: trailingIcon ? trailingIconLoader.width + trailingIconLoader.anchors.rightMargin : 0 - property int type: CustomTextField.Outlined - readonly property bool valid: !validate || (!text && emptyIsValid) || (validate instanceof RegExp ? validate.test(text) : !!validate(text)) - property var validate // Regex or function - bottomPadding: Tokens.padding.large + supportingTextOffset - filledOffset + bottomPadding: verticalPadding + supportingTextOffset - filledOffset leftPadding: horizontalPadding + leadingOffset rightPadding: horizontalPadding + trailingOffset - topPadding: Tokens.padding.large + filledOffset + topPadding: verticalPadding + filledOffset background: Loader { anchors.bottomMargin: root.supportingTextOffset @@ -212,12 +217,10 @@ TextFieldBase { } } Behavior on strokeColor { - CAnim { - } + CAnim {} } Behavior on strokeWidth { - Anim { - } + Anim {} } PathLine { @@ -292,8 +295,7 @@ TextFieldBase { implicitHeight: root.activeFocus ? 2 : 1 Behavior on implicitHeight { - Anim { - } + Anim {} } } } diff --git a/Components/LineGraph.qml b/Components/LineGraph.qml new file mode 100644 index 0000000..c16a1a6 --- /dev/null +++ b/Components/LineGraph.qml @@ -0,0 +1,467 @@ +pragma ComponentBehavior: Bound + +import QtQuick + +Item { + id: root + + required property list values + + property int xSteps: 24 + property real yMin: NaN + property real yMax: NaN + property real yPaddingTop: 0 + property real yPaddingBottom: 0 + + property int verticalLineStep: 0 + property int horizontalLineStep: 0 + property bool firstXLine: true + property bool lastXLine: false + property int minHorizontalLines: 0 + + property var xLabel: null + property var yLabel: null + + property real smoothing: 0 + property bool curveSmoothing: true + property real markerX: -1 + property real markerGap: 2 // px gap between line and marker dot edge, each side + + property color lineColor: "white" + property color gridColor: "white" + property color markerColor: "white" + property color labelColor: "white" + property real gridAlpha: 0.12 + property real labelAlpha: 0.6 + property real fillAlpha: 0.15 + property real lineWidth: 2 + property real markerDotRadius: 4 + property real labelFontSize: 10 + + Canvas { + id: canvas + + anchors.fill: parent + renderStrategy: Canvas.Immediate + + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + function strokeCurve(ctx, pts) { + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + + if (root.curveSmoothing && pts.length > 2) { + const k = 1 / 6; + const n = pts.length; + + for (let i = 0; i < n - 1; i++) { + const p0 = pts[Math.max(i - 1, 0)]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[Math.min(i + 2, n - 1)]; + + let c1x = p1.x + (p2.x - p0.x) * k; + let c1y = p1.y + (p2.y - p0.y) * k; + let c2x = p2.x - (p3.x - p1.x) * k; + let c2y = p2.y - (p3.y - p1.y) * k; + + const minY = Math.min(p1.y, p2.y); + const maxY = Math.max(p1.y, p2.y); + c1y = Math.max(minY, Math.min(maxY, c1y)); + c2y = Math.max(minY, Math.min(maxY, c2y)); + + ctx.bezierCurveTo(c1x, c1y, c2x, c2y, p2.x, p2.y); + } + } else { + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i]; + const p1 = pts[i + 1]; + const cx = (p0.x + p1.x) * 0.5; + ctx.bezierCurveTo(cx, p0.y, cx, p1.y, p1.x, p1.y); + } + } + } + + // Control points for segment i (pts[i] -> pts[i+1]), matching strokeCurve + function segmentControls(pts, i) { + const p0 = pts[Math.max(i - 1, 0)]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[Math.min(i + 2, pts.length - 1)]; + + if (root.curveSmoothing && pts.length > 2) { + const k = 1 / 6; + let c1x = p1.x + (p2.x - p0.x) * k; + let c1y = p1.y + (p2.y - p0.y) * k; + let c2x = p2.x - (p3.x - p1.x) * k; + let c2y = p2.y - (p3.y - p1.y) * k; + + const minY = Math.min(p1.y, p2.y); + const maxY = Math.max(p1.y, p2.y); + c1y = Math.max(minY, Math.min(maxY, c1y)); + c2y = Math.max(minY, Math.min(maxY, c2y)); + + return [c1x, c1y, c2x, c2y]; + } + + const cx = (p1.x + p2.x) * 0.5; + return [cx, p1.y, cx, p2.y]; + } + + // Point on the curve at a given x (x is monotonic per segment) + function pointAtX(pts, targetX) { + let j = pts.length - 2; + for (let i = 0; i < pts.length - 1; i++) { + if (targetX <= pts[i + 1].x) { + j = i; + break; + } + } + + const p1 = pts[j]; + const p2 = pts[j + 1]; + const [c1x, c1y, c2x, c2y] = segmentControls(pts, j); + + let tlo = 0; + let thi = 1; + for (let it = 0; it < 24; it++) { + const tm = (tlo + thi) / 2; + const u = 1 - tm; + const x = u * u * u * p1.x + 3 * u * u * tm * c1x + 3 * u * tm * tm * c2x + tm * tm * tm * p2.x; + if (x < targetX) + tlo = tm; + else + thi = tm; + } + + const t = (tlo + thi) / 2; + const u = 1 - t; + const y = u * u * u * p1.y + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * p2.y; + return { x: targetX, y }; + } + + // Part of the curve left/right of a cut x, including the cut point + function cutLeft(pts, targetX) { + if (targetX <= pts[0].x) + return []; + if (targetX >= pts[pts.length - 1].x) + return pts.slice(); + + let j = pts.length - 2; + for (let i = 0; i < pts.length - 1; i++) { + if (targetX <= pts[i + 1].x) { + j = i; + break; + } + } + + const left = pts.slice(0, j + 1); + if (targetX > pts[j].x) + left.push(pointAtX(pts, targetX)); + return left; + } + + function cutRight(pts, targetX) { + if (targetX >= pts[pts.length - 1].x) + return []; + if (targetX <= pts[0].x) + return pts.slice(); + + let j = pts.length - 2; + for (let i = 0; i < pts.length - 1; i++) { + if (targetX <= pts[i + 1].x) { + j = i; + break; + } + } + + const right = pts.slice(j + 1); + if (targetX < pts[j + 1].x) + right.unshift(pointAtX(pts, targetX)); + return right; + } + + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + + const W = width; + const H = height; + if (W < 2 || H < 2) + return; + + const padLeft = root.labelFontSize * 3.4; + const padRight = 2; + const padTop = 2; + const padBottom = root.labelFontSize + 8; + const plot = { + x: padLeft, + y: padTop, + w: W - padLeft - padRight, + h: H - padTop - padBottom + }; + if (plot.w < 2 || plot.h < 2) + return; + + // Auto-fit: NaN yMin/yMax derive from the data, snapped to the + // horizontal step grid, honoring minHorizontalLines + let lo = root.yMin; + let hi = root.yMax; + const loAuto = Number.isNaN(lo); + const hiAuto = Number.isNaN(hi); + + if (loAuto || hiAuto) { + let dmin = Infinity; + let dmax = -Infinity; + for (const v of root.values) { + if (v == null) + continue; + if (v < dmin) + dmin = v; + if (v > dmax) + dmax = v; + } + if (dmin === Infinity) { + dmin = 0; + dmax = 1; + } + + const step = root.horizontalLineStep; + const minSteps = root.minHorizontalLines > 1 ? root.minHorizontalLines - 1 : 0; + + if (step > 0) { + if (hiAuto) { + const baseLo = loAuto ? Math.floor(dmin / step) * step : lo; + const span = Math.max(Math.ceil((dmax - baseLo) / step), minSteps, 1); + lo = baseLo; + hi = baseLo + span * step; + } else { + const span = Math.max(Math.ceil((hi - dmin) / step), minSteps, 1); + lo = hi - span * step; + } + } else { + if (loAuto) + lo = dmin; + if (hiAuto) + hi = dmax; + if (hi <= lo) + hi = lo + 1; + } + } + + const scaleBottom = lo - root.yPaddingBottom; + const scaleTop = hi + root.yPaddingTop; + const range = scaleTop - scaleBottom; + if (range <= 0 || root.xSteps < 1) + return; + + let series = root.values; + if (root.smoothing > 0 && series.length > 0) { + const radius = Math.round(root.smoothing); + const raw = series; + series = raw.map((v, i) => { + if (v == null) + return null; + + let sum = 0; + let n = 0; + for (let j = -radius; j <= radius; j++) { + const u = raw[i + j]; + if (u != null) { + sum += u; + n++; + } + } + return n > 0 ? sum / n : null; + }); + } + + const xAt = i => plot.x + (i / root.xSteps) * plot.w; + const yAt = v => plot.y + (1 - (v - scaleBottom) / range) * plot.h; + const crisp = v => Math.floor(v) + 0.5; + + // 1. Grid lines + ctx.strokeStyle = Qt.alpha(root.gridColor, root.gridAlpha); + ctx.lineWidth = 1; + + if (root.verticalLineStep > 0) { + for (let i = 0; i <= root.xSteps; i += root.verticalLineStep) { + if (i === 0 && !root.firstXLine) + continue; + if (i === root.xSteps && !root.lastXLine) + continue; + + const x = crisp(xAt(i)); + ctx.beginPath(); + ctx.moveTo(x, plot.y); + ctx.lineTo(x, H); + ctx.stroke(); + } + } + + // Lines extend down into yPaddingBottom, snapped to the grid + const vStart = lo - (root.yPaddingBottom > 0 ? Math.floor(root.yPaddingBottom / root.horizontalLineStep) * root.horizontalLineStep : 0); + + if (root.horizontalLineStep > 0) { + for (let v = vStart; v <= hi + 0.001; v += root.horizontalLineStep) { + const y = crisp(yAt(v)); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(plot.x + plot.w, y); + ctx.stroke(); + } + } + + // 2. Labels + ctx.fillStyle = Qt.alpha(root.labelColor, root.labelAlpha); + ctx.font = root.labelFontSize + "px sans-serif"; + + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + if (root.verticalLineStep > 0 && root.xLabel) { + for (let i = 0; i <= root.xSteps; i += root.verticalLineStep) { + if (i === 0 && !root.firstXLine) + continue; + if (i === root.xSteps && !root.lastXLine) + continue; + + const text = root.xLabel(i); + if (text) + ctx.fillText(text, xAt(i) + 4, plot.y + plot.h + 4); + } + } + + ctx.textBaseline = "bottom"; + if (root.horizontalLineStep > 0 && root.yLabel) { + for (let v = vStart; v <= hi + 0.001; v += root.horizontalLineStep) { + const text = root.yLabel(v); + if (text) + ctx.fillText(text, 2, yAt(v) - 2); + } + } + + // 3. Curve, split into segments at null gaps + const segments = []; + let current = null; + for (let i = 0; i < series.length; i++) { + if (series[i] == null) { + if (current) + segments.push(current); + current = null; + continue; + } + const pt = { + x: xAt(i), + y: yAt(series[i]) + }; + if (!current) + current = []; + current.push(pt); + } + if (current) + segments.push(current); + + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + const hasMarker = root.markerX >= 0 && root.markerX <= root.xSteps; + const markerPx = hasMarker ? xAt(root.markerX) : 0; + let gapPx = 0; + if (hasMarker) { + // markerGap is measured from the dot edge, perpendicular to the + // curve; project onto x via the local curve slope + let slope = 0; + const seg = segments.find(s => s[0].x <= markerPx && markerPx <= s[s.length - 1].x); + if (seg && seg.length > 2 && markerPx - 1 >= seg[0].x && markerPx + 1 <= seg[seg.length - 1].x) + slope = (pointAtX(seg, markerPx + 1).y - pointAtX(seg, markerPx - 1).y) / 2; + gapPx = (root.markerDotRadius + root.markerGap) / Math.sqrt(1 + slope * slope); + } + + for (const seg of segments) { + if (seg.length < 2) + continue; + + // Fill stays continuous under the marker + ctx.fillStyle = Qt.alpha(root.lineColor, root.fillAlpha); + strokeCurve(ctx, seg); + ctx.lineTo(seg[seg.length - 1].x, plot.y + plot.h); + ctx.lineTo(seg[0].x, plot.y + plot.h); + ctx.closePath(); + ctx.fill(); + + // Stroke has a gap around the marker + const pieces = hasMarker ? [cutLeft(seg, markerPx - gapPx), cutRight(seg, markerPx + gapPx)] : [seg]; + + for (const pts of pieces) { + if (pts.length < 2) + continue; + + ctx.strokeStyle = root.lineColor; + ctx.lineWidth = root.lineWidth; + strokeCurve(ctx, pts); + ctx.stroke(); + } + } + + // 4. Marker: dashed vertical + dot + if (root.markerX >= 0 && root.markerX <= root.xSteps) { + const x = markerPx; + + ctx.setLineDash([4, 4]); + ctx.strokeStyle = Qt.alpha(root.markerColor, 0.6); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(crisp(x), plot.y); + ctx.lineTo(crisp(x), H); + ctx.stroke(); + ctx.setLineDash([]); + + const i0 = Math.floor(root.markerX); + const v0 = series[i0]; + const v1 = series[i0 + 1]; + let y = null; + + if (v0 != null && v1 != null) + y = yAt(v0 + (v1 - v0) * (root.markerX - i0)); + else if (v0 != null) + y = yAt(v0); + + if (y != null) { + ctx.fillStyle = root.markerColor; + ctx.beginPath(); + ctx.arc(x, y, root.markerDotRadius, 0, Math.PI * 2); + ctx.fill(); + } + } + } + } + + onValuesChanged: canvas.requestPaint() + onXStepsChanged: canvas.requestPaint() + onYMinChanged: canvas.requestPaint() + onYMaxChanged: canvas.requestPaint() + onYPaddingTopChanged: canvas.requestPaint() + onYPaddingBottomChanged: canvas.requestPaint() + onVerticalLineStepChanged: canvas.requestPaint() + onHorizontalLineStepChanged: canvas.requestPaint() + onFirstXLineChanged: canvas.requestPaint() + onLastXLineChanged: canvas.requestPaint() + onMinHorizontalLinesChanged: canvas.requestPaint() + onXLabelChanged: canvas.requestPaint() + onYLabelChanged: canvas.requestPaint() + onSmoothingChanged: canvas.requestPaint() + onCurveSmoothingChanged: canvas.requestPaint() + onMarkerXChanged: canvas.requestPaint() + onMarkerGapChanged: canvas.requestPaint() + onLineColorChanged: canvas.requestPaint() + onGridColorChanged: canvas.requestPaint() + onMarkerColorChanged: canvas.requestPaint() + onLabelColorChanged: canvas.requestPaint() + onGridAlphaChanged: canvas.requestPaint() + onLabelAlphaChanged: canvas.requestPaint() + onFillAlphaChanged: canvas.requestPaint() + onLineWidthChanged: canvas.requestPaint() + onMarkerDotRadiusChanged: canvas.requestPaint() + onLabelFontSizeChanged: canvas.requestPaint() +} diff --git a/Components/ScreenState.qml b/Components/ScreenState.qml new file mode 100644 index 0000000..663dfe1 --- /dev/null +++ b/Components/ScreenState.qml @@ -0,0 +1,37 @@ +import QtQuick +import Quickshell + +PersistentProperties { + required property ShellScreen modelData + + reloadableId: "screenState" + + // Drawer visibilities + property bool bar + property bool clipboard + property bool dashboard + property bool dock + property bool isDrawing + property bool launcher + property bool osd + property bool resources + property bool settings + property bool sidebar + + // Dashboard state + property int dashboardTab + property date dashboardDate: new Date() + + // Sidebar state + property int sidebarTab + property list expandedNotifs: [] + + // Utilities state + property string recordingConfirmDelete + property bool recordingListExpanded + property string recordingMode + + // Drawing state + property color penColor: "white" + property int penWidth: 4 +} diff --git a/Components/StateLayer.qml b/Components/StateLayer.qml index 88d5f0b..523f6e0 100644 --- a/Components/StateLayer.qml +++ b/Components/StateLayer.qml @@ -2,7 +2,7 @@ import QtQuick import QtQuick.Shapes import ZShell import ZShell.Config -import qs.Helpers +import qs.Services import qs.Services MouseArea { @@ -52,7 +52,7 @@ MouseArea { anchors.fill: parent cursorShape: !enabled ? undefined : Qt.PointingHandCursor - enabled: parent.enabled && !Visibilities.getForActive()?.isDrawing + enabled: parent.enabled && !ShellState.forActive()?.isDrawing hoverEnabled: true Behavior on stateOpacity { diff --git a/Daemons/NotifServer.qml b/Daemons/NotifServer.qml index 0f8f305..3360c8b 100644 --- a/Daemons/NotifServer.qml +++ b/Daemons/NotifServer.qml @@ -10,6 +10,7 @@ import ZShell import qs.Components import qs.Modules import qs.Helpers +import qs.Services import qs.Paths import ZShell.Config @@ -25,7 +26,7 @@ Singleton { property alias server: server function shouldThrottle(appName: string): bool { - if (props.dnd || [...Visibilities.screens.values()].some(v => v.sidebar)) + if (props.dnd || ShellState.anySidebarOpen()) return false; const key = (appName || "unknown").trim().toLowerCase(); diff --git a/Drawers/Drawing.qml b/Drawers/Drawing.qml index 9b45c31..ac27301 100644 --- a/Drawers/Drawing.qml +++ b/Drawers/Drawing.qml @@ -3,28 +3,23 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick import ZShell.Internal +import qs.Components Item { id: root readonly property alias content: contentLoader.item - readonly property PersistentProperties drawingState: PersistentProperties { - property color penColor: "white" - property int penWidth: 4 - - reloadableId: "drawingState" - } - required property PersistentProperties visibilities + required property ScreenState screenState Loader { id: contentLoader - active: root.visibilities.isDrawing + active: root.screenState.isDrawing anchors.fill: parent sourceComponent: StrokeCanvas { - penColor: root.drawingState.penColor - penWidth: root.drawingState.penWidth + penColor: root.screenState.penColor + penWidth: root.screenState.penWidth } } } diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index 85dd0da..a8a1bbc 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -19,7 +19,7 @@ Item { required property ShellScreen screen property bool singleGestureTriggered: false property bool utilitiesShortcutActive - required property PersistentProperties visibilities + required property ScreenState screenState function inBottomPanel(panel: Item, x: real, y: real): bool { const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); @@ -63,7 +63,7 @@ Item { cursorShape: (active && centroid.pressPosition.y < root.bar.implicitHeight) ? Qt.ClosedHandCursor : undefined dragThreshold: 0 - enabled: !root.visibilities.isDrawing + enabled: !root.screenState.isDrawing grabPermissions: PointerHandler.CanTakeOverFromHandlersOfSameType | PointerHandler.ApprovesTakeOverByAnything maximumPointCount: 1 minimumPointCount: 1 @@ -76,48 +76,60 @@ Item { onCentroidChanged: { const x = centroid.position.x; const y = centroid.position.y; - const dragX = x - centroid.pressPosition.x; - const dragY = y - centroid.pressPosition.y; + const pressX = centroid.pressPosition.x; + const pressY = centroid.pressPosition.y; + const dragX = x - pressX; + const dragY = y - pressY; - if (centroid.pressPosition.y >= root.screen.height - Config.bar.border && centroid.pressPosition.x > root.screen.width / 5 && dragY < -Config.launcher.dragThreshold) - root.visibilities.launcher = true; + if (pressY >= root.screen.height - Config.bar.border && pressX > root.screen.width / 5 && dragY < -Config.launcher.dragThreshold) + root.screenState.launcher = true; if (root.singleGestureTriggered) return; - if (root.geometry.insetTop(root.borderThickness) > centroid.pressPosition.y) { - if (dragY > 20) { - root.visibilities.settings = true; - root.singleGestureTriggered = true; - } else if (dragY < -20) { - root.visibilities.settings = false; - root.singleGestureTriggered = true; - } - } + // if (root.geometry.insetTop(root.borderThickness) > pressY) { + // if (dragY > 20) { + // root.screenState.settings = true; + // root.singleGestureTriggered = true; + // } else if (dragY < -20) { + // root.screenState.settings = false; + // root.singleGestureTriggered = true; + // } + // } - if (centroid.pressPosition.y > root.screen.height - root.geometry.insetBottom(root.borderThickness) && centroid.pressPosition.x < root.screen.width / 5 && dragY < -50) - root.visibilities.clipboard = true; + if (pressY > root.screen.height - root.geometry.insetBottom(root.borderThickness) && pressX < root.screen.width / 5 && dragY < -50) + root.screenState.clipboard = true; - if (!Config.dock.hoverToReveal && centroid.pressPosition.y > root.screen.height - root.geometry.insetTop(root.borderThickness) && centroid.pressPosition.x > root.screen.width / 5 && !root.visibilities.launcher) + if (!Config.dock.hoverToReveal && pressY > root.screen.height - root.geometry.insetTop(root.borderThickness) && pressX > root.screen.width / 5 && !root.screenState.launcher) if (dragY < -10) { - root.visibilities.dock = true; + root.screenState.dock = true; root.singleGestureTriggered = true; } - if (centroid.pressPosition.x > root.screen.width - Config.bar.border && centroid.pressPosition.y < (root.screen.height / 2) && dragX < -Config.sidebar.dragThreshold) { - root.visibilities.sidebar = true; + if (pressX > root.screen.width - Config.bar.border && pressY < (root.screen.height / 2) && dragX < -Config.sidebar.dragThreshold) { + root.screenState.sidebar = true; root.singleGestureTriggered = true; } - if (centroid.pressPosition.x >= root.screen.width - Config.bar.border && centroid.pressPosition.y > (root.screen.height / 2) && dragX < -20) { + if (pressX >= root.screen.width - Config.bar.border && pressY > (root.screen.height / 2) && dragX < -20) { Hypr.dispatch(`hl.dsp.focus({ workspace = 'r+1', on_current_monitor = true })`); root.singleGestureTriggered = true; } - if (centroid.pressPosition.x <= Config.bar.border && dragX > 20) { + if (pressX <= Config.bar.border && dragX > 20) { Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`); root.singleGestureTriggered = true; } + + if (root.inTopPanel(root.panels.dashboardWrapper, pressX, pressY) && root.withinPanelWidth(root.panels.dashboardWrapper, x, y)) { + if (dragY > Config.dashboard.dragThreshold) { + root.screenState.dashboard = true; + root.singleGestureTriggered = true; + } else if (dragY < -Config.dashboard.dragThreshold) { + root.screenState.dashboard = false; + root.singleGestureTriggered = true; + } + } } } @@ -127,7 +139,7 @@ Item { property bool setInitialPoint: false acceptedButtons: Qt.LeftButton | Qt.RightButton - enabled: root.visibilities.isDrawing && (!root.inLeftPanel(root.panels.drawing, hoverHandler.point.position.x, hoverHandler.point.position.y) || !root.panels.drawing.expanded) + enabled: root.screenState.isDrawing && (!root.inLeftPanel(root.panels.drawing, hoverHandler.point.position.x, hoverHandler.point.position.y) || !root.panels.drawing.expanded) onActiveChanged: { if (!active) { @@ -178,12 +190,12 @@ Item { HoverHandler { id: hoverHandler - cursorShape: root.visibilities.isDrawing && !root.inLeftPanel(root.panels.drawing, point.position.x, point.position.y) ? Qt.BlankCursor : undefined + cursorShape: root.screenState.isDrawing && !root.inLeftPanel(root.panels.drawing, point.position.x, point.position.y) ? Qt.BlankCursor : undefined onHoveredChanged: { if (!hovered) { if (!root.osdShortcutActive) { - root.visibilities.osd = false; + root.screenState.osd = false; root.panels.osd.hovered = false; } @@ -193,13 +205,16 @@ Item { if (Config.bar.autoHide || root.bar.fullscreen) root.bar.isHovered = false; + + if (root.screenState.dashboard && Config.dashboard.showOnHover) + root.screenState.dashboard = false; } } onPointChanged: { const x = point.position.x; const y = point.position.y; - if (root.visibilities.isDrawing) { + if (root.screenState.isDrawing) { if (root.inLeftPanel(root.panels.drawing, x, y) && !(drawingHandler.point.pressedButtons & Qt.LeftButton)) { root.panels.drawing.expand(); root.drawing.content.hideHover(); @@ -210,7 +225,7 @@ Item { return; } - if (!root.visibilities.bar && Config.bar.autoHide && root.geometry.barContains(x, y, true)) + if (!root.screenState.bar && Config.bar.autoHide && root.geometry.barContains(x, y, true)) root.bar.isHovered = true; if (root.bar.fullscreen && !root.bar.isHovered) @@ -228,7 +243,7 @@ Item { const showOsd = outOfSidebar && root.inRightPanel(root.panels.osdWrapper, x, y); if (!root.osdShortcutActive) { - root.visibilities.osd = showOsd; + root.screenState.osd = showOsd; root.panels.osd.hovered = showOsd; } else if (showOsd) { root.osdShortcutActive = false; @@ -236,8 +251,13 @@ Item { } } - if (Config.dock.enable && Config.dock.hoverToReveal && !root.visibilities.dock && !root.visibilities.launcher && root.inBottomPanel(root.panels.dock, x, y)) - root.visibilities.dock = true; + if (Config.dock.enable && Config.dock.hoverToReveal && !root.screenState.dock && !root.screenState.launcher && root.inBottomPanel(root.panels.dock, x, y)) + root.screenState.dock = true; + + const inDashboard = root.inTopPanel(root.panels.dashboardWrapper, x, y); + + if (Config.dashboard.showOnHover) + root.screenState.dashboard = inDashboard; if (root.geometry.barContains(x, y)) root.bar.checkPopout(root.geometry.axisPos(x, y)); @@ -246,16 +266,16 @@ Item { Connections { function onDashboardChanged() { - if (root.visibilities.dashboard) { + if (root.screenState.dashboard) { const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY); if (!inDashboardArea) { root.dashboardShortcutActive = true; } if (root.panels.launcher.x + root.panels.launcher.width > root.panels.dashboardWrapper.x) - root.visibilities.launcher = false; + root.screenState.launcher = false; - root.visibilities.settings = false; - root.visibilities.sidebar = false; + root.screenState.settings = false; + root.screenState.sidebar = false; root.popouts.hasCurrent = false; } else { root.dashboardShortcutActive = false; @@ -263,12 +283,12 @@ Item { } function onIsDrawingChanged() { - if (!root.visibilities.isDrawing) + if (!root.screenState.isDrawing) root.drawing.clear(); } function onLauncherChanged() { - if (!root.visibilities.launcher) { + if (!root.screenState.launcher) { root.dashboardShortcutActive = false; root.osdShortcutActive = false; root.utilitiesShortcutActive = false; @@ -276,23 +296,23 @@ Item { const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); if (!inOsdArea) { - root.visibilities.osd = false; + root.screenState.osd = false; root.panels.osd.hovered = false; } } - if (root.visibilities.launcher) { + if (root.screenState.launcher) { if (root.panels.dashboardWrapper.x < root.panels.launcher.x + root.panels.launcher.width) { - root.visibilities.dashboard = false; + root.screenState.dashboard = false; } - root.visibilities.dock = false; - root.visibilities.settings = false; + root.screenState.dock = false; + root.screenState.settings = false; } } function onOsdChanged() { - if (root.visibilities.osd) { + if (root.screenState.osd) { const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY); if (!inOsdArea) { root.osdShortcutActive = true; @@ -303,32 +323,32 @@ Item { } function onResourcesChanged() { - if (root.visibilities.resources && (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates"))) { + if (root.screenState.resources && (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates"))) { root.popouts.hasCurrent = false; } - if (root.visibilities.resources) - root.visibilities.settings = false; + if (root.screenState.resources) + root.screenState.settings = false; } function onSettingsChanged() { - if (root.visibilities.settings) { - root.visibilities.resources = false; - root.visibilities.dashboard = false; - root.visibilities.sidebar = false; + if (root.screenState.settings) { + root.screenState.resources = false; + root.screenState.dashboard = false; + root.screenState.sidebar = false; root.panels.popouts.hasCurrent = false; - root.visibilities.launcher = false; + root.screenState.launcher = false; } } function onSidebarChanged() { - if (root.visibilities.sidebar) { - root.visibilities.dashboard = false; - root.visibilities.settings = false; + if (root.screenState.sidebar) { + root.screenState.dashboard = false; + root.screenState.settings = false; root.popouts.hasCurrent = false; } } - target: root.visibilities + target: root.screenState } } diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 8dd15c3..c6214c4 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -42,7 +42,7 @@ Item { readonly property alias sidebar: sidebar readonly property alias toasts: toasts readonly property alias utilities: utilities - required property PersistentProperties visibilities + required property ScreenState screenState anchors.bottomMargin: geometry.insetBottom(borderThickness) anchors.fill: parent @@ -64,7 +64,7 @@ Item { id: resources position: root.geometry.position - visibilities: root.visibilities + screenState: root.screenState } } @@ -85,7 +85,7 @@ Item { anchors.verticalCenter: parent.verticalCenter screen: root.screen sidebarOrSessionVisible: sidebar.visible - visibilities: root.visibilities + screenState: root.screenState } } @@ -96,7 +96,7 @@ Item { anchors.verticalCenter: parent.verticalCenter drawing: root.drawingItem screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } Popouts.ClipWrapper { @@ -122,7 +122,7 @@ Item { anchors.top: parent.top panels: root sidebarPanel: sidebar - visibilities: root.visibilities + screenState: root.screenState } Launcher.Wrapper { @@ -132,7 +132,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter panels: root screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } Utils.Wrapper { @@ -141,7 +141,7 @@ Item { anchors.bottom: parent.bottom anchors.right: parent.right sidebar: sidebar - visibilities: root.visibilities + screenState: root.screenState } Item { @@ -149,13 +149,11 @@ Item { property real offsetScale: dashboard.shouldBeActive ? 0 : 1 - anchors.bottom: root.geometry.barOnLeft || root.geometry.barOnBottom ? parent.bottom : undefined - anchors.left: root.geometry.barOnLeft ? parent.left : undefined - anchors.right: root.geometry.barOnLeft ? undefined : parent.right - anchors.top: root.geometry.barOnBottom || root.geometry.barOnLeft ? undefined : parent.top + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter clip: true - implicitHeight: root.geometry.horizontal ? dashboard.implicitHeight * (1 - offsetScale) : dashboard.implicitHeight - implicitWidth: root.geometry.horizontal ? dashboard.implicitWidth : dashboard.implicitWidth * (1 - offsetScale) + implicitHeight: dashboard.implicitHeight * (1 - offsetScale) + implicitWidth: dashboard.implicitWidth Behavior on offsetScale { Anim { @@ -168,8 +166,7 @@ Item { id: dashboard offsetScale: dashboardWrapper.offsetScale - position: root.geometry.position - visibilities: root.visibilities + screenState: root.screenState } } @@ -180,7 +177,7 @@ Item { anchors.right: parent.right anchors.top: notifications.bottom panels: root - visibilities: root.visibilities + screenState: root.screenState } Item { @@ -196,7 +193,7 @@ Item { anchors.verticalCenterOffset: (-implicitHeight - 5 - ((root.height - implicitHeight) / 2)) * offsetScale panels: root screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } @@ -207,7 +204,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter panels: root screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } Clipboard.Wrapper { @@ -216,6 +213,6 @@ Item { anchors.bottom: parent.bottom anchors.left: parent.left screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index b2ecfb6..3701ba1 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -50,16 +50,17 @@ CustomWindow { readonly property alias menuRegion: regions.menuPopoutRegion readonly property HyprlandMonitor monitor: Hypr.monitorFor(screen) property var root: Quickshell.shellDir + readonly property ScreenState screenState: ShellState.forScreen(screen) readonly property real sdfBorderOffset: 2 * fsTransitionProg readonly property real shadowOpacity: 0.7 * (1 - (bar.isHovered ? 0 : fsTransitionProg)) property color surfaceColor: Colors.tPalette.m3surface WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.settings || visibilities.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: screenState.launcher || screenState.settings || screenState.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top color: "transparent" contentItem.focus: true - mask: visibilities.isDrawing ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions) + mask: screenState.isDrawing ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions) name: "Bar" Behavior on fsTransitionProg { @@ -71,26 +72,26 @@ CustomWindow { contentItem.Keys.onEscapePressed: { if (Config.bar.autoHide) - visibilities.bar = false; - visibilities.launcher = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - visibilities.dock = false; - visibilities.clipboard = false; + screenState.bar = false; + screenState.launcher = false; + screenState.sidebar = false; + screenState.dashboard = false; + screenState.osd = false; + screenState.settings = false; + screenState.resources = false; + screenState.dock = false; + screenState.clipboard = false; panels.popouts.hasCurrent = false; } onHasFullscreenChanged: { - visibilities.launcher = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - visibilities.clipboard = false; - visibilities.dock = false; + screenState.launcher = false; + screenState.sidebar = false; + screenState.dashboard = false; + screenState.osd = false; + screenState.settings = false; + screenState.resources = false; + screenState.clipboard = false; + screenState.dock = false; panels.popouts.hasCurrent = false; } @@ -142,44 +143,26 @@ CustomWindow { HyprlandFocusGrab { id: focusGrab - active: visibilities.dock || visibilities.resources || visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || visibilities.clipboard || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") && (!Config.bar.tray.showOnHover || (panels.popouts.current as StackView)?.depth > 1)) + active: root.screenState.dock || root.screenState.resources || root.screenState.launcher || root.screenState.sidebar || (!Config.dashboard.showOnHover && root.screenState.dashboard) || root.screenState.settings || root.screenState.clipboard || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") && (!Config.bar.tray.showOnHover || (panels.popouts.current as StackView)?.depth > 1)) windows: [root] onCleared: { - visibilities.launcher = false; - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.osd = false; - visibilities.settings = false; - visibilities.resources = false; - visibilities.clipboard = false; - visibilities.dock = false; + root.screenState.launcher = false; + root.screenState.sidebar = false; + root.screenState.dashboard = false; + root.screenState.osd = false; + root.screenState.settings = false; + root.screenState.resources = false; + root.screenState.clipboard = false; + root.screenState.dock = false; panels.popouts.hasCurrent = false; } } - PersistentProperties { - id: visibilities - - property bool bar - property bool clipboard - property bool dashboard - property bool dock - property bool isDrawing - property bool launcher - property bool notif: NotifServer.popups.length > 0 - property bool osd - property bool resources - property bool settings - property bool sidebar - - Component.onCompleted: Visibilities.load(root.screen, this) - } - Binding { property: "bar" - target: visibilities - value: visibilities.sidebar || visibilities.dashboard || visibilities.osd || (!Config.bar.hideWhenNotif && visibilities.notif) || visibilities.resources || visibilities.settings || bar.isHovered + target: root.screenState + value: root.screenState.sidebar || root.screenState.dashboard || root.screenState.osd || (!Config.bar.hideWhenNotif && NotifServer.popups.length > 0) || root.screenState.resources || root.screenState.settings || bar.isHovered when: Config.bar.autoHide } @@ -345,7 +328,7 @@ CustomWindow { anchors.fill: parent layer.enabled: true - visibilities: visibilities + screenState: root.screenState z: 2 layer.effect: MultiEffect { @@ -386,7 +369,7 @@ CustomWindow { panels: panels popouts: panels.popouts screen: root.screen - visibilities: visibilities + screenState: root.screenState Panels { id: panels @@ -396,7 +379,7 @@ CustomWindow { drawingItem: drawing geometry: geometry screen: root.screen - visibilities: visibilities + screenState: root.screenState clipboard.transform: Matrix4x4 { matrix: clipboardBg.deformMatrix @@ -439,14 +422,14 @@ CustomWindow { anchors.bottom: geometry.barOnBottom ? parent.bottom : undefined anchors.left: parent.left anchors.top: geometry.barOnBottom ? undefined : parent.top - enabled: !visibilities.isDrawing + enabled: !screenState.isDrawing fullscreen: root.hasFullscreen height: geometry.horizontal ? implicitHeight : parent.height popouts: panels.popouts popoutsWrapper: panels.popoutsWrapper position: geometry.position screen: root.screen - visibilities: visibilities + screenState: root.screenState width: geometry.horizontal ? parent.width : implicitWidth } } diff --git a/Greeter/WeatherInfo.qml b/Greeter/WeatherInfo.qml index d7f0f1c..60cda3a 100644 --- a/Greeter/WeatherInfo.qml +++ b/Greeter/WeatherInfo.qml @@ -119,7 +119,8 @@ ColumnLayout { length: count }, () => null); - return forecast.slice(0, count); + const start = Math.max(0, Weather.currentHourIndex); + return forecast.slice(start, start + count); } ColumnLayout { diff --git a/Helpers/Strings.qml b/Helpers/Strings.qml index f1980b5..0e9061b 100644 --- a/Helpers/Strings.qml +++ b/Helpers/Strings.qml @@ -5,6 +5,15 @@ import Quickshell Singleton { property var _regexCache: ({}) + function percent(value: int): string { + // TRANSLATORS: %1 = a number + return qsTr("%1%").arg(value); + } + + function percentOne(value: real): string { + return percent(Math.round(value * 100)); + } + function testRegexList(filterList: list, target: string): bool { const regexChecker = /^\^.*\$$/; for (const filter of filterList) { diff --git a/Helpers/Visibilities.qml b/Helpers/Visibilities.qml deleted file mode 100644 index fe22047..0000000 --- a/Helpers/Visibilities.qml +++ /dev/null @@ -1,16 +0,0 @@ -pragma Singleton - -import Quickshell - -Singleton { - property var bars: new Map() - property var screens: new Map() - - function getForActive(): PersistentProperties { - return screens.get(Hypr.focusedMonitor); - } - - function load(screen: ShellScreen, visibilities: var): void { - screens.set(Hypr.monitorFor(screen), visibilities); - } -} diff --git a/Helpers/Weather.qml b/Helpers/Weather.qml index ecf5e80..0b9b535 100644 --- a/Helpers/Weather.qml +++ b/Helpers/Weather.qml @@ -3,25 +3,36 @@ pragma Singleton import QtQuick import Quickshell import ZShell +import ZShell.Internal import ZShell.Config Singleton { id: root readonly property var cachedCities: new Map() - property var cc - property string city readonly property string description: cc?.weatherDesc ?? qsTr("No weather") readonly property string feelsLike: formatTemp(cc?.feelsLikeC) - property list forecast - property list hourlyForecast readonly property int humidity: cc?.humidity ?? 0 readonly property string icon: cc ? Icons.getWeatherIcon(cc.weatherCode) : "cloud_alert" - property string loc readonly property string sunrise: cc ? Qt.formatDateTime(new Date(cc.sunrise), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" readonly property string sunset: cc ? Qt.formatDateTime(new Date(cc.sunset), Config.services.useTwelveHourClock ? "h:mm A" : "h:mm") : "--:--" readonly property string temp: formatTemp(cc?.tempC) readonly property real windSpeed: cc?.windSpeed ?? 0 + readonly property CircularBuffer precipitationForecast: precipitationBuffer + + property var cc + property string city + property list forecast + property list hourlyForecast + readonly property int currentHourIndex: { + const now = new Date(); + for (let i = 0; i < hourlyForecast.length; i++) { + if (new Date(hourlyForecast[i].timestamp.replace("T", " ")) >= now) + return i; + } + return -1; + } + property string loc function fetchCityFromCoords(coords: string): void { if (cachedCities.has(coords)) { @@ -112,11 +123,12 @@ Singleton { forecast = forecastList; const hourlyList = []; - const now = new Date(); + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); for (let i = 0; i < json.hourly.time.length; i++) { const time = new Date(json.hourly.time[i].replace("T", " ")); - if (time < now) + if (time < startOfToday) continue; hourlyList.push({ @@ -129,6 +141,10 @@ Singleton { }); } hourlyForecast = hourlyList; + precipitationBuffer.clear(); + + for (const hour of hourlyList) + precipitationBuffer.push(hour.precipChance); }); } @@ -258,6 +274,11 @@ Singleton { onLocChanged: fetchWeatherData() + CircularBuffer { + id: precipitationBuffer + capacity: 24 + } + Connections { function onWeatherLocationChanged(): void { root.reload(); diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index fe33739..f0f5fb0 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -4,6 +4,7 @@ import Quickshell import QtQuick import QtQuick.Layouts import ZShell.Config +import qs.Components import qs.Modules.Bar.Components import qs.Modules.Bar.Popouts @@ -17,7 +18,7 @@ GridLayout { required property ClipWrapper popoutsWrapper required property ShellScreen screen readonly property int vPadding: 6 - required property PersistentProperties visibilities + required property ScreenState screenState function axisCenterOf(item: Item): real { const c = item.mapToItem(root, item.implicitWidth / 2, item.implicitHeight / 2); @@ -34,7 +35,7 @@ GridLayout { return; } - if (visibilities.sidebar || visibilities.dashboard || visibilities.resources || visibilities.settings) + if (screenState.sidebar || screenState.dashboard || screenState.resources || screenState.settings) return; if (id === "statusIcons" && Config.bar.popouts.statusIcons) { @@ -149,7 +150,7 @@ GridLayout { delegate: EntryWrapper { Resources { horizontal: root.horizontal - visibilities: root.visibilities + screenState: root.screenState visible: !root.fullscreen || root.isHovered } } @@ -173,7 +174,7 @@ GridLayout { NotifBell { horizontal: root.horizontal popouts: root.popouts - visibilities: root.visibilities + screenState: root.screenState visible: !root.fullscreen || root.isHovered } } @@ -187,7 +188,7 @@ GridLayout { horizontal: root.horizontal loader: root popouts: root.popouts - visibilities: root.visibilities + screenState: root.screenState visible: !root.fullscreen || root.isHovered } } diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index 5fd3a70..41e52fb 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -21,9 +21,9 @@ Item { required property ClipWrapper popoutsWrapper required property string position required property ShellScreen screen - readonly property bool shouldBeVisible: (!fullscreen && (!Config.bar.autoHide || visibilities.bar)) || isHovered + readonly property bool shouldBeVisible: (!fullscreen && (!Config.bar.autoHide || screenState.bar)) || isHovered readonly property int vPadding: 6 - required property PersistentProperties visibilities + required property ScreenState screenState function checkPopout(pos: real): void { (content.item as Bar)?.checkPopout(pos); @@ -85,7 +85,7 @@ Item { popouts: root.popouts popoutsWrapper: root.popoutsWrapper screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Bar/Border.qml b/Modules/Bar/Border.qml index c67fa45..95503f6 100644 --- a/Modules/Bar/Border.qml +++ b/Modules/Bar/Border.qml @@ -11,7 +11,7 @@ Item { id: root required property Item bar - required property PersistentProperties visibilities + required property ScreenState screenState anchors.fill: parent diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index dbe05b9..4e3033e 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -13,7 +13,7 @@ import qs.Services WidgetBase { id: root - readonly property color contentColor: visibilities.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + readonly property color contentColor: screenState.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface readonly property string effectiveFormat: { let fmt = Config.general.dateFormat; if (!root.horizontal) @@ -45,9 +45,9 @@ WidgetBase { required property GridLayout loader required property Wrapper popouts readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.medium * 2 : verticalColumn.implicitHeight + Tokens.padding.medium * 2 - required property PersistentProperties visibilities + required property ScreenState screenState - color: visibilities.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer + color: screenState.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer implicitHeight: horizontal ? baseSize : size implicitWidth: horizontal ? size : baseSize radius: Tokens.rounding.full @@ -147,7 +147,7 @@ WidgetBase { color: root.contentColor onClicked: { - root.visibilities.dashboard = !root.visibilities.dashboard; + root.screenState.dashboard = !root.screenState.dashboard; } } } diff --git a/Modules/Bar/Components/NotifBell.qml b/Modules/Bar/Components/NotifBell.qml index c255deb..34b4c68 100644 --- a/Modules/Bar/Components/NotifBell.qml +++ b/Modules/Bar/Components/NotifBell.qml @@ -11,17 +11,17 @@ WidgetBase { id: root required property Wrapper popouts - required property PersistentProperties visibilities + required property ScreenState screenState - color: visibilities.sidebar ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer + color: screenState.sidebar ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer radius: Tokens.rounding.full MaterialIcon { id: notificationCenterIcon anchors.centerIn: parent - color: root.visibilities.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - fill: root.visibilities.sidebar ? 1 : 0 + color: root.screenState.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + fill: root.screenState.sidebar ? 1 : 0 font.pointSize: Tokens.font.size.larger text: NotifServer.list.length ? "\uf4fe" : "\ue7f4" @@ -36,10 +36,10 @@ WidgetBase { } StateLayer { - color: root.visibilities.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: root.screenState.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface onClicked: { - root.visibilities.sidebar = !root.visibilities.sidebar; + root.screenState.sidebar = !root.screenState.sidebar; } } } diff --git a/Modules/Bar/Components/Resources.qml b/Modules/Bar/Components/Resources.qml index 56cb3d9..32bd1f2 100644 --- a/Modules/Bar/Components/Resources.qml +++ b/Modules/Bar/Components/Resources.qml @@ -12,18 +12,18 @@ import qs.Components WidgetBase { id: root - required property PersistentProperties visibilities + required property ScreenState screenState clip: true - color: visibilities.resources ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer + color: screenState.resources ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer implicitHeight: horizontal ? baseSize : gridLayout.implicitHeight + Tokens.padding.medium * 2 implicitWidth: horizontal ? gridLayout.implicitWidth + Tokens.padding.medium * 2 : baseSize radius: Tokens.rounding.full StateLayer { - color: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - onClicked: root.visibilities.resources = !root.visibilities.resources + onClicked: root.screenState.resources = !root.screenState.resources } GridLayout { @@ -53,8 +53,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "memory" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary percentage: Cpu.percentage warningThreshold: 95 } @@ -64,8 +64,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "memory_alt" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3secondary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3secondary percentage: Memory.percentage warningThreshold: 80 } @@ -75,8 +75,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "gamepad" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3tertiary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3tertiary percentage: Gpu.percentage } @@ -85,8 +85,8 @@ WidgetBase { Layout.fillWidth: !root.horizontal horizontal: root.horizontal icon: "developer_board" - iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary + iconColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + mainColor: root.screenState.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary percentage: Gpu.memoryUsed / Gpu.memoryTotal } } diff --git a/Modules/Bar/Components/Tray/TrayItem.qml b/Modules/Bar/Components/Tray/TrayItem.qml index d7b8fc1..9a1e228 100644 --- a/Modules/Bar/Components/Tray/TrayItem.qml +++ b/Modules/Bar/Components/Tray/TrayItem.qml @@ -62,10 +62,10 @@ Item { root.popouts.currentName = `traymenu${root.ind}`; root.popouts.currentCenter = Qt.binding(() => root.mapToItem(root.loader, root.implicitWidth / 2, 0).x); root.popouts.hasCurrent = true; - if (visibilities.sidebar || visibilities.dashboard || visibilities.settings) { - visibilities.sidebar = false; - visibilities.dashboard = false; - visibilities.settings = false; + if (screenState.sidebar || screenState.dashboard || screenState.settings) { + screenState.sidebar = false; + screenState.dashboard = false; + screenState.settings = false; } } } diff --git a/Modules/Clipboard/Content.qml b/Modules/Clipboard/Content.qml index 4be55ea..dd5d6a3 100644 --- a/Modules/Clipboard/Content.qml +++ b/Modules/Clipboard/Content.qml @@ -14,7 +14,7 @@ Item { readonly property int itemHeight: Config.clipboard.sizes.itemHeight required property ShellScreen screen - required property PersistentProperties visibilities + required property ScreenState screenState implicitHeight: search.implicitHeight + entries.anchors.topMargin + ((view.spacing + itemHeight) * Config.clipboard.maxEntriesShown) - view.spacing implicitWidth: Config.clipboard.sizes.width + preview.width + preview.anchors.leftMargin @@ -49,7 +49,7 @@ Item { Keys.onUpPressed: view.decrementCurrentIndex() onAccepted: { ClipHistory.copy(view.currentItem.modelData); - root.visibilities.clipboard = false; + root.screenState.clipboard = false; } } } diff --git a/Modules/Clipboard/Wrapper.qml b/Modules/Clipboard/Wrapper.qml index 1a0a8f7..cfd60a3 100644 --- a/Modules/Clipboard/Wrapper.qml +++ b/Modules/Clipboard/Wrapper.qml @@ -11,8 +11,8 @@ Item { property int contentHeight property real offsetScale: shouldBeActive ? 0 : 1 required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.clipboard && Config.clipboard.enabled - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.clipboard && Config.clipboard.enabled + required property ScreenState screenState anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight + Tokens.padding.small * 2 @@ -35,7 +35,7 @@ Item { sourceComponent: Content { screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Dashboard/Content.qml b/Modules/Dashboard/Content.qml index 13e8e49..20c418a 100644 --- a/Modules/Dashboard/Content.qml +++ b/Modules/Dashboard/Content.qml @@ -5,78 +5,177 @@ import Quickshell.Widgets import QtQuick import QtQuick.Layouts import ZShell.Config +import ZShell import qs.Components Item { id: root - required property PersistentProperties dashState - readonly property real nonAnimHeight: view.implicitHeight + viewWrapper.anchors.margins * 2 readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 - required property PersistentProperties visibilities + readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins * 2 + + required property ScreenState screenState + + readonly property var dashboardTabs: { + const allTabs = [ + { + component: dashComponent, + iconName: "dashboard", + text: qsTr("Dashboard"), + enabled: true + }, + { + component: weatherComponent, + iconName: "cloud", + text: qsTr("Weather"), + enabled: true + }, + { + component: mediaComponent, + iconName: "queue_music", + text: qsTr("Media"), + enabled: true + }, + // { + // component: testingComponent, + // iconName: "speed", + // text: qsTr("Testing"), + // enabled: true + // }, + ]; + return allTabs.filter(tab => tab.enabled); + } - implicitHeight: nonAnimHeight implicitWidth: nonAnimWidth + implicitHeight: nonAnimHeight Behavior on implicitHeight { - Anim { - } + Anim {} } Behavior on implicitWidth { - Anim { - } + Anim {} + } + + Tabs { + id: tabs + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: ZUtils.clamp(anchors.margins - Config.bar.border, 0, anchors.margins) + anchors.margins: Tokens.padding.large + + nonAnimWidth: root.nonAnimWidth - anchors.margins * 2 + screenState: root.screenState + tabs: root.dashboardTabs } ClippingRectangle { id: viewWrapper - anchors.bottom: parent.bottom + anchors.top: tabs.bottom anchors.left: parent.left - anchors.margins: Tokens.padding.small anchors.right: parent.right - anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: Tokens.padding.small color: "transparent" radius: Tokens.rounding.extraLarge - anchors.margins - Item { + Flickable { id: view - readonly property int currentIndex: root.dashState?.currentTab - readonly property Item currentItem: row.children[currentIndex] + readonly property int currentIndex: root.screenState.dashboardTab + readonly property Item currentItem: { + repeater.count; + return repeater.itemAt(currentIndex); + } anchors.fill: parent - implicitHeight: currentItem.implicitHeight - implicitWidth: currentItem.implicitWidth + + implicitWidth: currentItem?.implicitWidth ?? 0 + implicitHeight: currentItem?.implicitHeight ?? 0 + + contentX: currentItem?.x ?? 0 + contentWidth: row.implicitWidth + contentHeight: row.implicitHeight + + interactive: false + + onContentXChanged: { + if (!moving || !currentItem) + return; + + const x = contentX - currentItem.x; + if (x > currentItem.implicitWidth / 2) + root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, tabs.count - 1); + else if (x < -currentItem.implicitWidth / 2) + root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); + } RowLayout { id: row - Pane { - index: 0 + Repeater { + id: repeater - sourceComponent: Dash { - dashState: root.dashState - visibilities: root.visibilities + model: ScriptModel { + values: root.dashboardTabs } + + delegate: Loader { + id: paneLoader + + required property int index + required property var modelData + + Layout.alignment: Qt.AlignTop + + sourceComponent: modelData.component + + Component.onCompleted: active = Qt.binding(() => { + if (index === view.currentIndex) + return true; + const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); + const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); + return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); + }) + } + } + } + + Component { + id: dashComponent + + Dash { + screenState: root.screenState + } + } + + Component { + id: weatherComponent + + WeatherTab {} + } + + Component { + id: mediaComponent + + Media { + screenState: root.screenState + } + } + + // Component { + // id: testingComponent + // + // WeatherTest {} + // } + + Behavior on contentX { + Anim { + type: Anim.DefaultEffects } } } } - - component Pane: Loader { - id: pane - - required property int index - - Layout.alignment: Qt.AlignTop - - Component.onCompleted: active = Qt.binding(() => { - // Always keep current tab loaded - if (pane.index === view.currentIndex) - return true; - const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); - const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); - return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); - }) - } } diff --git a/Modules/Dashboard/Dash.qml b/Modules/Dashboard/Dash.qml index bf7bc2a..54d6e51 100644 --- a/Modules/Dashboard/Dash.qml +++ b/Modules/Dashboard/Dash.qml @@ -11,10 +11,9 @@ import qs.Services GridLayout { id: root - required property PersistentProperties dashState - readonly property bool dashboardVisible: visibilities.dashboard + readonly property bool dashboardVisible: screenState.dashboard property int radius: Tokens.rounding.medium - required property PersistentProperties visibilities + required property ScreenState screenState columnSpacing: Tokens.spacing.small rowSpacing: Tokens.spacing.small @@ -61,7 +60,7 @@ GridLayout { User { id: user - dashState: root.dashState + screenState: root.screenState } } @@ -72,8 +71,7 @@ GridLayout { Layout.row: 0 radius: root.radius - Weather { - } + Weather {} } Rect { @@ -87,7 +85,7 @@ GridLayout { Calendar { id: calendar - dashState: root.dashState + screenState: root.screenState } } @@ -104,20 +102,6 @@ GridLayout { } } - CustomClippingRect { - Layout.column: 0 - Layout.columnSpan: 5 - Layout.fillWidth: true - Layout.preferredHeight: media.implicitHeight - Layout.row: 2 - color: Colors.tPalette.m3surfaceContainer - radius: root.radius - - Media { - id: media - } - } - component Rect: CustomRect { color: Colors.tPalette.m3surfaceContainer } diff --git a/Modules/Dashboard/Dash/Calendar.qml b/Modules/Dashboard/Dash/Calendar.qml index 8161957..9d198ef 100644 --- a/Modules/Dashboard/Dash/Calendar.qml +++ b/Modules/Dashboard/Dash/Calendar.qml @@ -12,12 +12,12 @@ CustomMouseArea { id: root property int activeGrid: 1 - required property PersistentProperties dashState + required property ScreenState screenState property bool initialized: false property int month1 property int month2 - readonly property int realCurrMonth: dashState.currentDate.getMonth() - readonly property int realCurrYear: dashState.currentDate.getFullYear() + readonly property int realCurrMonth: screenState.dashboardDate.getMonth() + readonly property int realCurrYear: screenState.dashboardDate.getFullYear() property int year1 property int year2 @@ -43,9 +43,9 @@ CustomMouseArea { function onWheel(event: WheelEvent): void { if (event.angleDelta.y > 0) - root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1); + root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1); else if (event.angleDelta.y < 0) - root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1); + root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1); } acceptedButtons: Qt.MiddleButton @@ -61,7 +61,7 @@ CustomMouseArea { initialized = true; } - onClicked: root.dashState.currentDate = new Date() + onClicked: root.screenState.dashboardDate = new Date() onRealCurrMonthChanged: handleDateChange() onRealCurrYearChanged: handleDateChange() @@ -135,7 +135,7 @@ CustomMouseArea { padding: Tokens.padding.extraSmall type: IconButton.Text - onClicked: root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1) + onClicked: root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth - 1, 1) } Item { @@ -158,7 +158,7 @@ CustomMouseArea { } } - onClicked: root.dashState.currentDate = new Date() + onClicked: root.screenState.dashboardDate = new Date() } Item { @@ -206,7 +206,7 @@ CustomMouseArea { padding: Tokens.padding.extraSmall type: IconButton.Text - onClicked: root.dashState.currentDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1) + onClicked: root.screenState.dashboardDate = new Date(root.realCurrYear, root.realCurrMonth + 1, 1) } } diff --git a/Modules/Dashboard/Dash/Media.qml b/Modules/Dashboard/Dash/Media.qml deleted file mode 100644 index 53cda5a..0000000 --- a/Modules/Dashboard/Dash/Media.qml +++ /dev/null @@ -1,329 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import Quickshell.Services.Mpris -import QtQuick -import QtQuick.Layouts -import QtQuick.Shapes -import QtQuick.Effects -import ZShell.Components -import ZShell.Services -import ZShell.Config -import qs.Modules.Dashboard.Dash.Media -import qs.Daemons -import qs.Components -import qs.Helpers -import qs.Services - -Item { - id: root - readonly property bool hasUnknownLength: (Players.active?.length ?? 0) > 2147483647 - property real playerProgress: { - const active = Players.active; - return active?.length ? (active.position % active.length) / active.length : 0; - } - property int rowHeight: Tokens.padding.large + Config.dashboard.sizes.mediaProgressThickness + Tokens.spacing.small - - anchors.left: parent.left - anchors.right: parent.right - implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 - function lengthStr(length: int): string { - if (length < 0) - return "-1:-1"; - - const hours = Math.floor(length / 3600); - const mins = Math.floor((length % 3600) / 60); - const secs = Math.floor(length % 60).toString().padStart(2, "0"); - - if (hours > 0) - return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; - return `${mins}:${secs}`; - } - - Behavior on playerProgress { - Anim { - duration: Tokens.anim.durations.large - } - } - - Timer { - running: Players.active?.isPlaying ?? false - interval: Config.dashboard.mediaUpdateInterval - triggeredOnStart: true - repeat: true - onTriggered: Players.active?.positionChanged() - } - - ServiceRef { - service: Audio.cava - } - - BackgroundShapes { - anchors.fill: parent - } - - Shape { - id: visualizer - - readonly property int bars: Config.services.visualizerBars - property color color: Qt.alpha(Colors.palette.m3primary, 0.5) - property color fillColor: Qt.alpha(color, 0.1) - - anchors.fill: parent - anchors.leftMargin: -(shape.strokeWidth / 2) - layer.enabled: true - asynchronous: true - preferredRendererType: Shape.CurveRenderer - - ShapePath { - id: shape - strokeColor: visualizer.color - fillColor: visualizer.fillColor - strokeWidth: 2 - - PathSvg { - path: shape.curvePath - } - - property string curvePath: { - const values = Audio.cava.values; - const n = values.length; - - if (n < 2) - return ""; - - const h = root.height + shape.strokeWidth / 2; - const w = root.width + shape.strokeWidth; - - function x(i) { - return i * w / (n - 1); - } - - function y(i) { - return h - (values[i] * Config.dashboard.sizes.mediaVisualizerSize) / 2; - } - - let d = `M 0 ${h} `; - d += `L ${x(0)} ${y(0)} `; - - for (let i = 0; i < n - 1; ++i) { - const x0 = x(i); - const y0 = y(i); - - const x1 = x(i + 1); - const y1 = y(i + 1); - - const prev = Math.max(0, i - 1); - const next = Math.min(n - 1, i + 2); - - const c1x = x0 + (x1 - x(prev)) / 6; - const c1y = y0 + (y1 - y(prev)) / 6; - - const c2x = x1 - (x(next) - x0) / 6; - const c2y = y1 - (y(next) - y0) / 6; - - d += `C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x1} ${y1} `; - } - - d += `L ${w} ${h} `; - d += `L 0 ${h} Z`; - - return d; - } - } - } - - CustomRect { - anchors.fill: visualizer - - color: Qt.alpha(Colors.palette.m3shadow, 0.2) - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: visualizer - } - } - - RowLayout { - id: layout - - anchors.fill: parent - anchors.margins: Tokens.padding.large - anchors.leftMargin: Tokens.padding.extraLarge - - CoverVisualizer { - Layout.fillHeight: true - implicitWidth: Config.dashboard.sizes.mediaCoverArtSize - } - - ColumnLayout { - Layout.fillWidth: true - Layout.leftMargin: Tokens.spacing.largeIncreased - Layout.rightMargin: Tokens.spacing.largeIncreased - - spacing: Tokens.spacing.small - - MarqueeText { - id: title - - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - color: Colors.palette.m3primary - font.pointSize: Tokens.font.size.normal - horizontalAlignment: Text.AlignHCenter - pauseMs: 4000 - text: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") - implicitWidth: parent.width - Tokens.padding.large * 4 - } - - MarqueeText { - id: album - - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - color: Colors.palette.m3outline - font.pointSize: Tokens.font.size.small - horizontalAlignment: Text.AlignHCenter - pauseMs: 4000 - text: (Players.active?.trackAlbum ?? qsTr("No media")) || qsTr("Unknown album") - implicitWidth: parent.width - Tokens.padding.large * 4 - } - - MarqueeText { - id: artist - - Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter - color: Colors.palette.m3secondary - horizontalAlignment: Text.AlignHCenter - pauseMs: 4000 - text: (Players.active?.trackArtist ?? qsTr("No media")) || qsTr("Unknown artist") - implicitWidth: parent.width - Tokens.padding.large * 4 - } - - RowLayout { - id: positionSliderLayout - Layout.topMargin: Tokens.spacing.large - Layout.fillWidth: true - spacing: Tokens.spacing.small - - TextMetrics { - id: timeMetrics - - text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00" - } - - CustomText { - id: positionLabel - - Layout.preferredWidth: timeMetrics.width - text: root.lengthStr(Players.active?.position ?? -1) - color: Colors.palette.m3onSurfaceVariant - font: timeMetrics.font - horizontalAlignment: Text.AlignHCenter - } - - CustomSlider { - id: positionSlider - - Layout.fillWidth: true - value: Players.active ? Players.active.position / (Players.active.length || 1) : 0 - enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength - wavy: true - animateWave: Players.active?.isPlaying ?? false - waveFrequency: 5 - waveDuration: 2000 - interactionOnMove: false - onInteraction: value => { - const active = Players.active; - if (active?.canSeek && active?.positionSupported) - active.position = value * active.length; - } - Binding { - target: positionLabel - property: "text" - value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0)) - when: positionSlider.dragging - } - } - - CustomText { - Layout.preferredWidth: timeMetrics.width - text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1) - color: Colors.palette.m3onSurfaceVariant - font: timeMetrics.font - horizontalAlignment: Text.AlignHCenter - } - } - ButtonRow { - Layout.topMargin: Tokens.spacing.largeIncreased - Layout.fillWidth: true - spacing: Tokens.spacing.extraSmall - Layout.leftMargin: positionSliderLayout.implicitWidth / 4 - Layout.rightMargin: positionSliderLayout.implicitWidth / 4 - - IconButton { - type: IconButton.Tonal - icon: "shuffle" - isRound: true - shapeMorph: true - checked: Players.active?.shuffle ?? false - enabled: Players.active?.shuffleSupported - onClicked: Players.active.shuffle = !Players.active?.shuffle - } - - IconButton { - id: previousBtn - - type: IconButton.Tonal - icon: "skip_previous" - isRound: true - shapeMorph: true - enabled: Players.active?.canGoPrevious - onClicked: Players.active?.previous() - font.pointSize: Tokens.font.size.large - } - - IconButton { - id: playPauseBtn - - icon: Players.active?.isPlaying ? "pause" : "play_arrow" - isRound: true - shapeMorph: true - fillWidth: true - checked: Players.active?.isPlaying ?? false - enabled: Players.active?.canTogglePlaying - onClicked: Players.active?.togglePlaying() - font.pointSize: Tokens.font.size.large - } - - IconButton { - id: nextBtn - - type: IconButton.Tonal - icon: "skip_next" - isRound: true - shapeMorph: true - enabled: Players.active?.canGoNext - onClicked: Players.active?.next() - font.pointSize: Tokens.font.size.large - } - - IconButton { - type: IconButton.Tonal - icon: Players.active?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat" - isRound: true - shapeMorph: true - checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist - enabled: Players.active?.loopSupported - onClicked: { - const state = Players.active.loopState; - if (state === MprisLoopState.None) - Players.active.loopState = MprisLoopState.Track; - else if (state === MprisLoopState.Track) - Players.active.loopState = MprisLoopState.Playlist; - else - Players.active.loopState = MprisLoopState.None; - } - } - } - } - } -} diff --git a/Modules/Dashboard/Dash/User.qml b/Modules/Dashboard/Dash/User.qml index 0c6e0c8..1d37230 100644 --- a/Modules/Dashboard/Dash/User.qml +++ b/Modules/Dashboard/Dash/User.qml @@ -9,7 +9,7 @@ import qs.Helpers Row { id: root - required property PersistentProperties dashState + required property ScreenState screenState padding: 20 spacing: 12 diff --git a/Modules/Dashboard/Media.qml b/Modules/Dashboard/Media.qml new file mode 100644 index 0000000..f1f98ee --- /dev/null +++ b/Modules/Dashboard/Media.qml @@ -0,0 +1,152 @@ +import QtQuick +import QtQuick.Layouts +import M3Shapes +import ZShell.Config +import qs.Modules.Dashboard.Media +import qs.Components +import qs.Services +import qs.Helpers + +Item { + id: root + + property ScreenState screenState + + implicitWidth: 1000 + implicitHeight: 320 + + BackgroundShapes { + anchors.fill: parent + } + + RowLayout { + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.extraLarge + + CoverVisualizer { + Layout.fillHeight: true + implicitWidth: 250 + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + state: Players.active ? "" : "noMedia" + + states: State { + name: "noMedia" + + PropertyChanges { + noMedia.opacity: 1 + content.opacity: 0 + } + } + + transitions: [ + Transition { + from: "" + + SequentialAnimation { + Anim { + target: content + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + target: noMedia + property: "opacity" + type: Anim.SlowEffects + } + } + }, + Transition { + to: "" + + SequentialAnimation { + Anim { + target: noMedia + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + target: content + property: "opacity" + type: Anim.SlowEffects + } + } + } + ] + + Loader { + id: noMedia + + anchors.centerIn: parent + anchors.horizontalCenterOffset: -Tokens.padding.extraLarge * 2 + asynchronous: true + active: opacity > 0 + opacity: 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.small + + MaterialShape { + Layout.topMargin: (pathBounds().height - implicitSize) / 2 + Layout.bottomMargin: (pathBounds().height - implicitSize) / 2 + Tokens.spacing.small + Layout.alignment: Qt.AlignHCenter + color: Colors.palette.m3primaryContainer + implicitSize: icon.implicitHeight + Tokens.padding.extraLarge * 2 + shape: MaterialShape.ClamShell + + Behavior on color { + CAnim {} + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: "queue_music" + font.pointSize: Tokens.font.size.large + color: Colors.palette.m3onPrimaryContainer + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Nothing playing") + font.pointSize: Tokens.font.size.normal + } + + CustomText { + text: qsTr("Play something for it to show up here!") + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.large + } + } + } + + Loader { + id: content + + anchors.fill: parent + asynchronous: true + active: opacity > 0 + + sourceComponent: RowLayout { + spacing: Tokens.spacing.extraLarge + + Details { + Layout.fillWidth: true + } + + LyricsAndSelector { + Layout.fillHeight: true + implicitWidth: 300 + } + } + } + } + } +} diff --git a/Modules/Dashboard/Dash/Media/BackgroundShapes.qml b/Modules/Dashboard/Media/BackgroundShapes.qml similarity index 100% rename from Modules/Dashboard/Dash/Media/BackgroundShapes.qml rename to Modules/Dashboard/Media/BackgroundShapes.qml diff --git a/Modules/Dashboard/Dash/Media/CoverArt.qml b/Modules/Dashboard/Media/CoverArt.qml similarity index 100% rename from Modules/Dashboard/Dash/Media/CoverArt.qml rename to Modules/Dashboard/Media/CoverArt.qml diff --git a/Modules/Dashboard/Dash/Media/CoverVisualizer.qml b/Modules/Dashboard/Media/CoverVisualizer.qml similarity index 63% rename from Modules/Dashboard/Dash/Media/CoverVisualizer.qml rename to Modules/Dashboard/Media/CoverVisualizer.qml index b7476a0..171db11 100644 --- a/Modules/Dashboard/Dash/Media/CoverVisualizer.qml +++ b/Modules/Dashboard/Media/CoverVisualizer.qml @@ -11,8 +11,8 @@ Item { id: cover anchors.centerIn: parent - implicitHeight: Config.dashboard.sizes.mediaCoverArtSize - implicitWidth: Config.dashboard.sizes.mediaCoverArtSize + implicitHeight: 200 + implicitWidth: 200 shape.shape: MaterialShape.Cookie9Sided } } diff --git a/Modules/Dashboard/Media/Details.qml b/Modules/Dashboard/Media/Details.qml new file mode 100644 index 0000000..39ba295 --- /dev/null +++ b/Modules/Dashboard/Media/Details.qml @@ -0,0 +1,194 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Mpris +import ZShell.Components +import ZShell.Config +import qs.Components +import qs.Services +import qs.Helpers + +ColumnLayout { + id: root + + readonly property bool hasUnknownLength: (Players.active?.length ?? 0) > 2147483647 + + function lengthStr(length: int): string { + if (length < 0) + return "-1:-1"; + + const hours = Math.floor(length / 3600); + const mins = Math.floor((length % 3600) / 60); + const secs = Math.floor(length % 60).toString().padStart(2, "0"); + + if (hours > 0) + return `${hours}:${mins.toString().padStart(2, "0")}:${secs}`; + return `${mins}:${secs}`; + } + + spacing: Tokens.spacing.extraSmall + + Timer { + running: Players.active?.isPlaying ?? false + interval: Config.dashboard.mediaUpdateInterval + triggeredOnStart: true + repeat: true + onTriggered: Players.active?.positionChanged() + } + + CustomText { + Layout.fillWidth: true + text: Players.active?.trackTitle ?? "" + font.pointSize: Tokens.font.size.large + elide: Text.ElideRight + animate: true + } + + CustomText { + Layout.fillWidth: true + text: Players.active?.trackArtist || qsTr("Unknown artist") + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + elide: Text.ElideRight + animate: true + } + + CustomText { + Layout.fillWidth: true + text: Players.active?.trackAlbum || qsTr("Unknown album") + color: Colors.palette.m3secondary + font.pointSize: Tokens.font.size.normal + elide: Text.ElideRight + animate: true + } + + RowLayout { + Layout.topMargin: Tokens.spacing.extraLargeIncreased + Layout.fillWidth: true + spacing: Tokens.spacing.small + + TextMetrics { + id: timeMetrics + + text: Players.active ? root.lengthStr(Math.max(Players.active.position, root.hasUnknownLength ? 0 : Players.active.length)).replace(/[1-9]/g, "0") : "00:00" + font.pointSize: Tokens.font.size.normal + } + + CustomText { + id: positionLabel + + Layout.preferredWidth: timeMetrics.width + text: root.lengthStr(Players.active?.position ?? -1) + color: Colors.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + } + + CustomSlider { + id: positionSlider + + Layout.fillWidth: true + value: Players.active ? Players.active.position / (Players.active.length || 1) : 0 + enabled: (Players.active?.canSeek ?? false) && !root.hasUnknownLength + wavy: true + animateWave: Players.active?.isPlaying ?? false + waveFrequency: 5 + waveDuration: 2000 + interactionOnMove: false + onInteraction: value => { + const active = Players.active; + if (active?.canSeek && active?.positionSupported) + active.position = value * active.length; + } + + Binding { + target: positionLabel + property: "text" + value: root.lengthStr(positionSlider.pos * (Players.active?.length ?? 0)) + when: positionSlider.dragging + } + } + + CustomText { + Layout.preferredWidth: timeMetrics.width + text: root.hasUnknownLength ? "--:--" : root.lengthStr(Players.active?.length ?? -1) + color: Colors.palette.m3onSurfaceVariant + font: timeMetrics.font + horizontalAlignment: Text.AlignHCenter + } + } + + ButtonRow { + Layout.topMargin: Tokens.spacing.largeIncreased + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + IconButton { + type: IconButton.Tonal + icon: "shuffle" + isRound: true + shapeMorph: true + checked: Players.active?.shuffle ?? false + font.pointSize: Tokens.font.size.large + enabled: Players.active?.shuffleSupported + onClicked: Players.active.shuffle = !Players.active?.shuffle + implicitWidth: Math.round(implicitHeight * 0.9) + } + + IconButton { + id: previousBtn + + type: IconButton.Tonal + icon: "skip_previous" + isRound: true + shapeMorph: true + font.pointSize: Tokens.font.size.extraLarge + enabled: Players.active?.canGoPrevious + onClicked: Players.active?.previous() + } + + IconButton { + id: playPauseBtn + + icon: Players.active?.isPlaying ? "pause" : "play_arrow" + isRound: true + shapeMorph: true + fillWidth: true + checked: Players.active?.isPlaying ?? false + font.pointSize: Tokens.font.size.extraLarge + enabled: Players.active?.canTogglePlaying + onClicked: Players.active?.togglePlaying() + } + + IconButton { + id: nextBtn + + type: IconButton.Tonal + icon: "skip_next" + isRound: true + shapeMorph: true + font.pointSize: Tokens.font.size.extraLarge + enabled: Players.active?.canGoNext + onClicked: Players.active?.next() + } + + IconButton { + type: IconButton.Tonal + icon: Players.active?.loopState === MprisLoopState.Track ? "repeat_one" : "repeat" + isRound: true + shapeMorph: true + checked: Players.active?.loopState === MprisLoopState.Track || Players.active?.loopState === MprisLoopState.Playlist + font.pointSize: Tokens.font.size.large + enabled: Players.active?.loopSupported + onClicked: { + const state = Players.active.loopState; + if (state === MprisLoopState.None) + Players.active.loopState = MprisLoopState.Track; + else if (state === MprisLoopState.Track) + Players.active.loopState = MprisLoopState.Playlist; + else + Players.active.loopState = MprisLoopState.None; + } + implicitWidth: Math.round(implicitHeight * 0.9) + } + } +} diff --git a/Modules/Dashboard/Media/LyricList.qml b/Modules/Dashboard/Media/LyricList.qml new file mode 100644 index 0000000..a90aaa2 --- /dev/null +++ b/Modules/Dashboard/Media/LyricList.qml @@ -0,0 +1,317 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Effects +import qs.Services +import qs.Helpers + +Item { + id: root + + // Funny binding hack to make lyrics update + readonly property var _: { + const p = Players.active; + if (p) + Lyrics.setTrack(p.trackArtist, p.trackTitle, p.trackAlbum, p.length); + else + Lyrics.clearTrack(); + } + + readonly property real fadeAmount: 0.1 + property list lyricList: Lyrics.lyrics + + layer.enabled: true + layer.effect: Mask { + maskSource: mask + + Rectangle { + id: mask + + layer.enabled: true + visible: false + implicitWidth: root.width + implicitHeight: root.height + + gradient: Gradient { + orientation: Gradient.Vertical + + GradientStop { + color: Qt.alpha("black", 0) + position: 0 + } + GradientStop { + color: Qt.alpha("black", 1) + position: root.fadeAmount + } + GradientStop { + color: Qt.alpha("black", 1) + position: 1 - root.fadeAmount + } + GradientStop { + color: Qt.alpha("black", 0) + position: 1 + } + } + } + } + + state: { + if (Lyrics.hasLyrics) + return "hasLyrics"; + if (Lyrics.loading) + return "loading"; + return "noLyrics"; + } + + states: [ + State { + name: "loading" + + PropertyChanges { + loadingIndicator.opacity: 1 + lyrics.opacity: 0 + noLyrics.opacity: 0 + } + }, + State { + name: "hasLyrics" + + PropertyChanges { + loadingIndicator.opacity: 0 + lyrics.opacity: 1 + noLyrics.opacity: 0 + } + }, + State { + name: "noLyrics" + + PropertyChanges { + loadingIndicator.opacity: 0 + lyrics.opacity: 0 + noLyrics.opacity: 1 + } + } + ] + + transitions: [ + Transition { + from: "loading" + + SequentialAnimation { + Anim { + target: loadingIndicator + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [lyrics, noLyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + }, + Transition { + from: "hasLyrics" + + SequentialAnimation { + Anim { + target: lyrics + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [loadingIndicator, noLyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + }, + Transition { + from: "noLyrics" + + SequentialAnimation { + Anim { + target: noLyrics + property: "opacity" + type: Anim.DefaultEffects + } + Anim { + targets: [loadingIndicator, lyrics] + property: "opacity" + type: Anim.SlowEffects + } + } + } + ] + + Loader { + id: loadingIndicator + + anchors.centerIn: parent + asynchronous: true + active: opacity > 0 + opacity: 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.large + + CustomRect { + Layout.alignment: Qt.AlignHCenter + implicitWidth: shape.implicitSize + Tokens.padding.medium * 2 + implicitHeight: shape.implicitSize + Tokens.padding.medium * 2 + color: Colors.palette.m3primaryContainer + radius: Tokens.rounding.full + + LoadingIndicator { + id: shape + + anchors.centerIn: parent + implicitSize: Math.round(300 / 5) + containsIcon: true // This removes the pentagon, which is not centered + } + } + + CustomText { + text: qsTr("Loading lyrics...") + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + } + } + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + + Loader { + id: noLyrics + + anchors.centerIn: parent + asynchronous: true + active: opacity > 0 + opacity: 0 + + sourceComponent: ColumnLayout { + spacing: Tokens.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "sentiment_sad" + font.pointSize: Tokens.font.size.extraLarge + color: Colors.palette.m3outline + } + + CustomText { + text: qsTr("No lyrics found") + color: Colors.palette.m3outline + font.pointSize: Tokens.font.size.normal + } + } + } + + CustomListView { + id: lyrics + + anchors.fill: parent + anchors.topMargin: parent.height * root.fadeAmount / 2 + anchors.bottomMargin: parent.height * root.fadeAmount / 2 + + displayMarginBeginning: anchors.topMargin + displayMarginEnd: anchors.bottomMargin + + model: root.lyricList + Component.onCompleted: { + currentIndex = Qt.binding(() => { + model; // Force update when lyrics change + return Lyrics.indexForTime(Players.active?.position ?? 0); + }); + positionViewAtIndex(currentIndex, ListView.Center); + } + onModelChanged: Qt.callLater(() => positionViewAtIndex(currentIndex, ListView.Center)) + + highlightRangeMode: ListView.ApplyRange + highlightMoveDuration: Tokens.anim.durations.large + highlightMoveVelocity: -1 + preferredHighlightBegin: (height - (currentItem?.implicitHeight ?? 0)) / 2 + preferredHighlightEnd: (height + (currentItem?.implicitHeight ?? 0)) / 2 + + spacing: Tokens.spacing.small + opacity: 0 + enabled: opacity > 0 + + delegate: CustomText { + id: lyric + + required property string modelData + required property int index + property real effectScale: ListView.isCurrentItem ? 1 : 0 + + anchors.left: lyrics.contentItem.left + anchors.right: lyrics.contentItem.right + + text: modelData || ". . ." + color: ListView.isCurrentItem ? Colors.palette.m3primary : mouse.containsMouse ? Colors.palette.m3onSurface : Colors.palette.m3outline + font.pointSize: Tokens.font.size.medium + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + layer.enabled: effectScale > 0 + layer.effect: MultiEffect { + shadowEnabled: true + shadowColor: Colors.palette.m3primary + shadowOpacity: 0.5 * lyric.effectScale + shadowBlur: 0.6 * lyric.effectScale + blur: 0.4 * lyric.effectScale + } + + Behavior on effectScale { + Anim { + type: Anim.SlowEffects + } + } + + MouseArea { + id: mouse + + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: { + const p = Players.active; + if (p) + p.position = Lyrics.timeForIndex(lyric.index); + } + } + } + + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } + } + + Behavior on lyricList { + SequentialAnimation { + Anim { + target: lyrics + property: "opacity" + to: 0 + type: Anim.DefaultEffects + } + PropertyAction {} + Anim { + target: lyrics + property: "opacity" + to: 1 + type: Anim.SlowEffects + } + } + } +} diff --git a/Modules/Dashboard/Media/LyricsAndSelector.qml b/Modules/Dashboard/Media/LyricsAndSelector.qml new file mode 100644 index 0000000..453ed2c --- /dev/null +++ b/Modules/Dashboard/Media/LyricsAndSelector.qml @@ -0,0 +1,79 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Mpris +import ZShell.Config +import qs.Components +import qs.Helpers +import qs.Services + +Item { + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.leftMargin: Tokens.padding.medium + spacing: Tokens.spacing.medium + + RowLayout { + Layout.bottomMargin: -Tokens.spacing.medium + spacing: Tokens.spacing.medium + z: 1 + + MaterialIcon { + Layout.topMargin: Math.round(fontInfo.pointSize * 0.12) + text: "lyrics" + font.pointSize: Tokens.font.size.large + } + + CustomText { + Layout.fillWidth: true + text: qsTr("Lyrics") + font.pointSize: Tokens.font.size.large + } + + LyricsInfo {} + } + + LyricList { + Layout.fillWidth: true + Layout.fillHeight: true + } + + SplitButton { + Layout.alignment: Qt.AlignHCenter + + type: SplitButton.Tonal + enabled: Players.list.length + active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null + menu.onItemSelected: item => Players.manualActive = (item as PlayerItem).modelData + + menuItems: playerList.instances + fallbackIcon: "music_off" + fallbackText: qsTr("No players", "no media players active") + + minLeftWidth: layout.width - expandBtn.implicitWidth - spacing + label.Layout.maximumWidth: minLeftWidth - iconLabel.implicitWidth - textRow.spacing - textRow.anchors.horizontalCenterOffset / 2 - horizontalPadding * 2 + label.elide: Text.ElideRight + + stateLayer.enabled: true + menuOnTop: true + + Variants { + id: playerList + + model: Players.list + + PlayerItem {} + } + } + } + + component PlayerItem: MenuItem { + required property MprisPlayer modelData + + icon: modelData === Players.active ? "check" : "" + text: Players.getIdentity(modelData) + activeIcon: "animated_images" + } +} diff --git a/Modules/Dashboard/Media/LyricsInfo.qml b/Modules/Dashboard/Media/LyricsInfo.qml new file mode 100644 index 0000000..2e4437c --- /dev/null +++ b/Modules/Dashboard/Media/LyricsInfo.qml @@ -0,0 +1,212 @@ +import QtQuick +import QtQuick.Layouts +import ZShell +import ZShell.Blobs +import ZShell.Config +import ZShell.Services +import qs.Components +import qs.Services + +Item { + id: root + + property bool open + readonly property real padding: Tokens.padding.large + + implicitWidth: btn.implicitWidth * 0.9 + implicitHeight: btn.implicitHeight * 0.9 + + BlobGroup { + id: blobGroup + + color: Colors.palette.m3surfaceContainerHighest + smoothing: Tokens.rounding.medium + cornerFill: false + + Behavior on color { + CAnim {} + } + } + + BlobRect { + id: btnRect + + anchors.fill: parent + anchors.margins: !btn.pressed && btn.containsMouse ? -Tokens.padding.extraSmall : 0 + group: blobGroup + radius: Tokens.rounding.medium + + Behavior on anchors.margins { + Anim {} + } + } + + BlobRect { + id: rect + + anchors.right: parent.right + anchors.top: parent.top + + implicitWidth: parent.width + implicitHeight: parent.height + + group: blobGroup + radius: Tokens.rounding.medium + deformScale: 0.00001 + + states: State { + name: "open" + when: root.open + + PropertyChanges { + rect.anchors.rightMargin: root.width - Tokens.spacing.small + rect.anchors.topMargin: -Tokens.padding.medium + rect.implicitWidth: Math.max(layout.implicitWidth, placeholder.implicitWidth) + root.padding * 2 + rect.implicitHeight: Math.max(layout.implicitHeight, placeholder.implicitHeight) + root.padding * 2 + content.opacity: 1 + } + } + + transitions: Transition { + Anim { + properties: "rightMargin,implicitWidth" + } + Anim { + properties: "topMargin,implicitHeight" + easing: Tokens.anim.expressiveFastSpatial + } + Anim { + property: "opacity" + type: Anim.DefaultEffects + } + } + + Behavior on implicitWidth { + Anim {} + } + + Behavior on implicitHeight { + Anim {} + } + + Item { + id: content + + anchors.fill: parent + clip: true + opacity: 0 + state: Lyrics.loading || !Lyrics.hasLyrics ? "" : "hasLyrics" + + states: State { + name: "hasLyrics" + + PropertyChanges { + layout.opacity: 1 + placeholder.opacity: 0 + } + } + + transitions: [ + Transition { + from: "hasLyrics" + + SequentialAnimation { + Anim { + target: layout + property: "opacity" + type: Anim.FastEffects + } + Anim { + target: placeholder + property: "opacity" + type: Anim.DefaultEffects + } + } + }, + Transition { + to: "hasLyrics" + + SequentialAnimation { + Anim { + target: placeholder + property: "opacity" + type: Anim.FastEffects + } + Anim { + target: layout + property: "opacity" + type: Anim.DefaultEffects + } + } + } + ] + + ColumnLayout { + id: layout + + anchors.centerIn: parent + spacing: Tokens.spacing.extraSmall + opacity: 0 + + CustomText { + // TRANSLATORS: %1 = lyrics backend name, e.g. LRCLIB + text: qsTr("Backend: %1").arg(ZUtils.enumToString(Lyrics, "backend")) + color: Colors.palette.m3onSurfaceVariant + animate: true + } + + CustomText { + Layout.maximumWidth: 1000 / 2 + // TRANSLATORS: %1/%2/%3 = matched track title, artist and album + text: qsTr("Selected candidate: %1 | %2 | %3").arg(Lyrics.selectedCandidate.title).arg(Lyrics.selectedCandidate.artist).arg(Lyrics.selectedCandidate.album) + color: Colors.palette.m3onSurfaceVariant + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + animate: true + } + + CustomText { + // TRANSLATORS: %1 = lyric timing offset; ms is the millisecond unit, leave untranslated + text: qsTr("Offset: %1 ms").arg(Lyrics.offset) + color: Colors.palette.m3onSurfaceVariant + animate: true + } + } + + Item { + id: placeholder + + anchors.centerIn: parent + implicitWidth: placeholderText.implicitWidth + implicitHeight: placeholderText.implicitHeight + + CustomText { + id: placeholderText + + text: Lyrics.loading ? qsTr("Loading...") : qsTr("No lyrics found") + color: Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.normal + animate: true + } + } + } + } + + MouseArea { + id: btn + + anchors.centerIn: parent + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + Tokens.padding.extraSmall * 2 + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: root.open = !root.open + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: "more_vert" + font.pointSize: Tokens.font.size.large + } + } +} diff --git a/Modules/Dashboard/Tabs.qml b/Modules/Dashboard/Tabs.qml index 8a0bccf..ce04b93 100644 --- a/Modules/Dashboard/Tabs.qml +++ b/Modules/Dashboard/Tabs.qml @@ -1,23 +1,24 @@ pragma ComponentBehavior: Bound -import qs.Components -import ZShell.Config -import qs.Helpers -import qs.Modules -import Quickshell -import Quickshell.Widgets import QtQuick -import QtQuick.Controls +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 real nonAnimWidth - required property PersistentProperties state + required property ScreenState screenState + required property var tabs - implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight + readonly property alias count: bar.count + readonly property int tabIndicatorSpacing: Tokens.spacing.small + + implicitHeight: bar.implicitHeight + bar.anchors.topMargin + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight TabBar { id: bar @@ -25,85 +26,99 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top + anchors.topMargin: root.tabIndicatorSpacing + + currentIndex: root.screenState.dashboardTab + onCurrentIndexChanged: root.screenState.dashboardTab = currentIndex + + implicitHeight: contentHeight background: null - currentIndex: root.state.currentTab + contentItem: RowLayout { + spacing: 0 - onCurrentIndexChanged: root.state.currentTab = currentIndex - - Tab { - iconName: "dashboard" - text: qsTr("Dashboard") + Repeater { + model: bar.contentModel + } } - Tab { - iconName: "queue_music" - text: qsTr("Media") - } + Repeater { + model: ScriptModel { + values: root.tabs + } - Tab { - iconName: "speed" - text: qsTr("Performance") - } + delegate: Tab { + required property var modelData - Tab { - iconName: "cloud" - text: qsTr("Weather") + iconName: modelData.iconName + text: modelData.text + } } - - // Tab { - // iconName: "workspaces" - // text: qsTr("Workspaces") - // } } Item { id: indicator anchors.top: bar.bottom - clip: true - implicitHeight: 40 - implicitWidth: bar.currentItem.implicitWidth + anchors.topMargin: root.tabIndicatorSpacing + + implicitWidth: { + const tab = bar.currentItem; + if (tab) + return tab.implicitWidth; + const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count; + return width; + } + implicitHeight: 3 + 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; + const tabWidth = tab?.implicitWidth ?? width; + return width * bar.currentIndex + (width - tabWidth) / 2; + } + + clip: true + + CustomRect { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: parent.implicitHeight * 2 + + color: Colors.palette.m3primary + radius: Tokens.rounding.full + } + + Behavior on x { + Anim {} } 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 + Anim {} } } CustomRect { id: separator + anchors.top: indicator.bottom anchors.left: parent.left anchors.right: parent.right - anchors.top: indicator.bottom - color: Colors.palette.m3outlineVariant + implicitHeight: 1 + color: Colors.palette.m3outlineVariant } component Tab: TabButton { id: tab - readonly property bool current: TabBar.tabBar.currentItem === this required property string iconName + readonly property bool current: TabBar.tabBar.currentItem === this + Layout.fillWidth: true + Layout.preferredWidth: 1 // Uniform width across all tabs + implicitWidth: implicitContentWidth + implicitHeight: implicitContentHeight background: null contentItem: CustomMouseArea { @@ -111,117 +126,47 @@ Item { function onWheel(event: WheelEvent): void { if (event.angleDelta.y < 0) - root.state.currentTab = Math.min(root.state.currentTab + 1, bar.count - 1); + root.screenState.dashboardTab = Math.min(root.screenState.dashboardTab + 1, bar.count - 1); else if (event.angleDelta.y > 0) - root.state.currentTab = Math.max(root.state.currentTab - 1, 0); + root.screenState.dashboardTab = Math.max(root.screenState.dashboardTab - 1, 0); } - cursorShape: Qt.PointingHandCursor - implicitHeight: icon.height + label.height implicitWidth: Math.max(icon.width, label.width) + implicitHeight: icon.height + label.height - onPressed: event => { - root.state.currentTab = tab.TabBar.index; + hoverEnabled: true + cursorShape: Qt.PointingHandCursor - const stateY = stateWrapper.y; - rippleAnim.x = event.x; - rippleAnim.y = event.y - stateY; + onPressed: root.screenState.dashboardTab = tab.TabBar.index - const dist = (ox, oy) => ox * ox + oy * oy; - rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y + stateY), dist(event.x, stateWrapper.height - event.y), dist(width - event.x, event.y + stateY), dist(width - event.x, stateWrapper.height - event.y))); - - rippleAnim.restart(); - } - - SequentialAnimation { - id: rippleAnim - - property real radius - property real x - property real y - - PropertyAction { - property: "x" - target: ripple - value: rippleAnim.x - } - - PropertyAction { - property: "y" - target: ripple - value: rippleAnim.y - } - - PropertyAction { - property: "opacity" - target: ripple - value: 0.08 - } - - Anim { - from: 0 - properties: "implicitWidth,implicitHeight" - target: ripple - to: rippleAnim.radius * 2 - } - - Anim { - easing.type: Easing.BezierSpline - property: "opacity" - target: ripple - to: 0 - } - } - - ClippingRectangle { - id: stateWrapper + StateLayer { + id: stateLayer + anchors.fill: undefined anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - color: "transparent" - implicitHeight: parent.height + 8 * 2 - radius: Tokens.rounding.small + implicitHeight: parent.height + root.tabIndicatorSpacing * 2 - CustomRect { - id: stateLayer - - anchors.fill: parent - color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface - opacity: mouse.pressed ? 0.1 : tab.hovered ? 0.08 : 0 - - Behavior on opacity { - Anim { - } - } - } - - CustomRect { - id: ripple - - color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface - opacity: 0 - radius: Tokens.rounding.full - - transform: Translate { - x: -ripple.width / 2 - y: -ripple.height / 2 - } - } + radius: Tokens.rounding.medium + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurface + onClicked: root.screenState.dashboardTab = tab.TabBar.index } MaterialIcon { id: icon - anchors.bottom: label.top anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: label.top + + text: tab.iconName color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant fill: tab.current ? 1 : 0 - font.pointSize: 18 - text: tab.iconName + font.pointSize: Tokens.font.size.large Behavior on fill { Anim { + type: Anim.DefaultEffects } } } @@ -229,10 +174,11 @@ Item { CustomText { id: label - anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter - color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + anchors.bottom: parent.bottom + text: tab.text + color: tab.current ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant } } } diff --git a/Modules/Dashboard/Weather/Graphs.qml b/Modules/Dashboard/Weather/Graphs.qml new file mode 100644 index 0000000..ff0e0df --- /dev/null +++ b/Modules/Dashboard/Weather/Graphs.qml @@ -0,0 +1,145 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services +import qs.Helpers + +RowLayout { + id: root + + spacing: Tokens.spacing.largeIncreased + + readonly property list forecastToday: Weather.hourlyForecast.slice(0, 25) + + property var now: new Date() + property bool useTwelveHourClock: Config.services.useTwelveHourClock + property bool curveSmoothing: true + property real smoothing: 2 + property real markerGap: Math.min(Math.max(Tokens.spacing.medium / 2, 0), 6) + + readonly property real markerX: { + const d = root.now; + return d.getHours() + d.getMinutes() / 60; + } + + function formatHour(h) { + if (root.useTwelveHourClock) { + const hr = h % 12 === 0 ? 12 : h % 12; + return hr + (h < 12 ? " AM" : " PM"); + } + return h; + } + + Timer { + interval: 60000 + repeat: true + running: true + + onTriggered: root.now = new Date() + } + + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: root.width / 2 + + RowLayout { + Layout.fillWidth: true + + MaterialIcon { + text: "device_thermostat" + font.pointSize: Tokens.font.size.large + color: Colors.palette.m3secondary + } + + CustomText { + text: qsTr("Temperature") + } + } + + Item { + id: temperature + + implicitHeight: 150 + Layout.fillWidth: true + + LineGraph { + id: tempGraph + + anchors.fill: parent + values: root.forecastToday.map(e => e.tempC) + verticalLineStep: 3 + xSteps: 24 + horizontalLineStep: 10 + minHorizontalLines: 4 + yPaddingTop: horizontalLineStep + yPaddingBottom: horizontalLineStep + xLabel: i => root.formatHour(i) + yLabel: v => Math.round(v) + "°" + + markerX: root.markerX + smoothing: root.smoothing + curveSmoothing: root.curveSmoothing + markerGap: root.markerGap + + lineColor: Colors.palette.m3secondary + gridColor: Colors.palette.m3onSurface + markerColor: Colors.palette.m3secondary + labelColor: Colors.palette.m3onSurfaceVariant + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.preferredWidth: root.width / 2 + + RowLayout { + Layout.fillWidth: true + + MaterialIcon { + text: "water_drop" + font.pointSize: Tokens.font.size.large + color: Colors.palette.m3tertiary + } + + CustomText { + text: qsTr("Chance of precipitation") + } + } + + Item { + id: precipitation + + Layout.fillWidth: true + implicitHeight: 150 + + LineGraph { + id: graph + + anchors.fill: parent + + values: root.forecastToday.map(e => e?.precipChance ?? null) + xSteps: 24 + yMin: 0 + yMax: 100 + yPaddingTop: 20 + verticalLineStep: 3 + lastXLine: false + horizontalLineStep: 20 + xLabel: i => root.formatHour(i) + yLabel: v => v + "%" + + markerX: root.markerX + smoothing: root.smoothing + curveSmoothing: root.curveSmoothing + markerGap: root.markerGap + + lineColor: Colors.palette.m3tertiary + gridColor: Colors.palette.m3onSurface + markerColor: Colors.palette.m3tertiary + labelColor: Colors.palette.m3onSurfaceVariant + } + } + } +} diff --git a/Modules/Dashboard/WeatherTab.qml b/Modules/Dashboard/WeatherTab.qml new file mode 100644 index 0000000..fbd44bc --- /dev/null +++ b/Modules/Dashboard/WeatherTab.qml @@ -0,0 +1,259 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Modules.Dashboard.Weather +import qs.Services +import qs.Helpers + +Item { + id: root + + readonly property var today: Weather.forecast && Weather.forecast.length > 0 ? Weather.forecast[0] : null + + implicitWidth: layout.implicitWidth > 800 ? layout.implicitWidth : 840 + implicitHeight: layout.implicitHeight + Component.onCompleted: Weather.reload() + + ColumnLayout { + id: layout + + anchors.fill: parent + spacing: Tokens.spacing.medium + + RowLayout { + Layout.leftMargin: Tokens.padding.large + Layout.rightMargin: Tokens.padding.large + Layout.fillWidth: true + + Column { + spacing: Tokens.spacing.extraSmall + + CustomText { + text: Weather.city || qsTr("Loading...") + font.pointSize: Tokens.font.size.large + color: Colors.palette.m3onSurface + } + + CustomText { + text: new Date().toLocaleDateString(Qt.locale("en_US"), "dddd, MMMM d") + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3onSurfaceVariant + } + } + + Item { + Layout.fillWidth: true + } + + Row { + spacing: Tokens.spacing.largeIncreased + + WeatherStat { + icon: "wb_twilight" + label: qsTr("Sunrise") + value: Weather.sunrise + colour: Colors.palette.m3tertiary + } + + WeatherStat { + icon: "bedtime" + label: qsTr("Sunset") + value: Weather.sunset + colour: Colors.palette.m3tertiary + } + } + } + + CustomRect { + Layout.fillWidth: true + implicitHeight: bigInfoRow.implicitHeight + bigInfoRow.anchors.margins * 2 + + radius: Tokens.rounding.large + color: Colors.tPalette.m3surfaceContainer + + Graphs { + id: bigInfoRow + anchors.fill: parent + anchors.margins: Tokens.padding.medium + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.medium + + DetailCard { + icon: Weather.icon + label: qsTr("Temperature") + value: Weather.temp + colour: Colors.palette.m3primary + } + DetailCard { + icon: "thermostat" + label: qsTr("Feels like", "apparent temperature") + value: Weather.feelsLike + colour: Colors.palette.m3primary + } + DetailCard { + icon: "water_drop" + label: qsTr("Humidity") + value: Strings.percent(Weather.humidity) + colour: Colors.palette.m3secondary + } + DetailCard { + icon: "air" + label: qsTr("Wind") + value: Weather.windSpeed ? qsTr("%1 km/h").arg(Weather.windSpeed) : "--" + colour: Colors.palette.m3tertiary + } + } + + CustomText { + Layout.topMargin: Tokens.spacing.medium + Layout.leftMargin: Tokens.padding.medium + visible: forecastRepeater.count > 0 + text: qsTr("7-day forecast") + font.pointSize: Tokens.font.size.normal + color: Colors.palette.m3onSurface + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.medium + + Repeater { + id: forecastRepeater + + model: Weather.forecast + + CustomRect { + id: forecastItem + + required property int index + required property var modelData + + Layout.fillWidth: true + implicitHeight: forecastItemColumn.implicitHeight + Tokens.padding.medium * 2 + + radius: Tokens.rounding.large + color: Colors.tPalette.m3surfaceContainer + + ColumnLayout { + id: forecastItemColumn + + anchors.centerIn: parent + spacing: Tokens.spacing.small + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: forecastItem.index === 0 ? qsTr("Today", "forecast column") : new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale("en_US"), "ddd") + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3primary + } + + CustomText { + Layout.topMargin: -Tokens.spacing.extraSmall + Layout.alignment: Qt.AlignHCenter + text: new Date(forecastItem.modelData.date).toLocaleDateString(Qt.locale("en_US"), "MMM d") + font.pointSize: Tokens.font.size.small + opacity: 0.7 + color: Colors.palette.m3onSurfaceVariant + } + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: forecastItem.modelData.icon + font.pointSize: Tokens.font.size.extraLarge + color: Colors.palette.m3secondary + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: { + const min = Weather.formatTemp(forecastItem.modelData.minTempC, true); + const max = Weather.formatTemp(forecastItem.modelData.maxTempC, true); + return qsTr("%1 / %2", "min/max temperature").arg(min).arg(max); + } + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3tertiary + } + } + } + } + } + } + + component DetailCard: CustomRect { + id: detailRoot + + property string icon + property string label + property string value + property color colour + + Layout.fillWidth: true + Layout.preferredHeight: 60 + radius: Tokens.rounding.medium + color: Colors.tPalette.m3surfaceContainer + + Row { + anchors.centerIn: parent + spacing: Tokens.spacing.medium + + MaterialIcon { + text: detailRoot.icon + color: detailRoot.colour + font.pointSize: Tokens.font.size.large + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + CustomText { + text: detailRoot.label + font.pointSize: Tokens.font.size.small + opacity: 0.7 + horizontalAlignment: Text.AlignLeft + } + CustomText { + text: detailRoot.value + font.pointSize: Tokens.font.size.small + horizontalAlignment: Text.AlignLeft + } + } + } + } + + component WeatherStat: Row { + id: weatherStat + + property string icon + property string label + property string value + property color colour + + spacing: Tokens.spacing.small + + MaterialIcon { + text: weatherStat.icon + font.pointSize: Tokens.font.size.extraLarge + color: weatherStat.colour + } + + Column { + CustomText { + text: weatherStat.label + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3onSurfaceVariant + } + CustomText { + text: weatherStat.value + font.pointSize: Tokens.font.size.small + color: Colors.palette.m3onSurface + } + } + } +} diff --git a/Modules/Dashboard/Wrapper.qml b/Modules/Dashboard/Wrapper.qml index c78a97f..b2e0f4b 100644 --- a/Modules/Dashboard/Wrapper.qml +++ b/Modules/Dashboard/Wrapper.qml @@ -8,24 +8,13 @@ import ZShell.Config Item { id: root - readonly property PersistentProperties dashState: PersistentProperties { - property date currentDate: new Date() - property int currentTab - - reloadableId: "dashboardState" - } readonly property real nonAnimHeight: state === "visible" ? (content.item?.nonAnimHeight ?? 0) : 0 required property real offsetScale - required property string position - readonly property bool shouldBeActive: root.visibilities.dashboard && Config.dashboard.enabled - required property PersistentProperties visibilities + readonly property bool shouldBeActive: root.screenState.dashboard && Config.dashboard.enabled + required property ScreenState screenState - anchors.bottom: position === "bottom" ? parent.bottom : undefined - anchors.bottomMargin: position === "bottom" ? (-implicitHeight - 5) * offsetScale : 0 - anchors.left: position === "left" ? parent.left : undefined - anchors.leftMargin: position === "left" ? (-implicitWidth - 5) * offsetScale : 0 - anchors.top: position === "top" ? parent.top : undefined - anchors.topMargin: position === "top" ? (-implicitHeight - 5) * offsetScale : 0 + anchors.top: parent.top + anchors.topMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth || 854 opacity: 1 - offsetScale @@ -35,15 +24,11 @@ Item { id: content active: root.shouldBeActive || root.visible - anchors.bottom: root.position === "top" ? parent.bottom : undefined - anchors.horizontalCenter: root.position !== "left" ? parent.horizontalCenter : undefined - anchors.right: root.position !== "left" ? undefined : parent.right - anchors.top: root.position === "bottom" ? parent.top : undefined - anchors.verticalCenter: root.position === "left" ? undefined : parent.verticalCenter + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter sourceComponent: Content { - dashState: root.dashState - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/DesktopIcons/BackgroundContextMenu.qml b/Modules/DesktopIcons/BackgroundContextMenu.qml index 88e9bc0..24f0217 100644 --- a/Modules/DesktopIcons/BackgroundContextMenu.qml +++ b/Modules/DesktopIcons/BackgroundContextMenu.qml @@ -8,6 +8,7 @@ import ZShell.Config import qs.Paths import qs.Helpers import qs.Services +import qs.Services Item { id: root @@ -97,8 +98,8 @@ Item { anchors.fill: parent onClicked: { - const visibilities = Visibilities.getForActive(); - visibilities.settings = true; + const state = ShellState.forActive(); + state.settings = true; root.close() } } diff --git a/Modules/Dock/Content.qml b/Modules/Dock/Content.qml index 5fda10a..1cb257c 100644 --- a/Modules/Dock/Content.qml +++ b/Modules/Dock/Content.qml @@ -27,7 +27,7 @@ Item { property var pendingCommitIds: [] readonly property int rounding: Tokens.rounding.largeIncreased required property ShellScreen screen - required property PersistentProperties visibilities + required property ScreenState screenState property var visualIds: [] function beginVisualDrag(appId, modelData, item) { @@ -198,7 +198,7 @@ Item { anchors.centerIn: parent appListRoot: root appToplevel: modelData - visibilities: root.visibilities + screenState: root.screenState visible: root.draggedAppId !== slot.appId } @@ -314,7 +314,7 @@ Item { appListRoot: root appToplevel: root.draggedModelData enabled: false - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Dock/Parts/DockAppButton.qml b/Modules/Dock/Parts/DockAppButton.qml index ab9164b..d6ca2f0 100644 --- a/Modules/Dock/Parts/DockAppButton.qml +++ b/Modules/Dock/Parts/DockAppButton.qml @@ -19,7 +19,7 @@ CustomRect { property real iconSize: implicitHeight - 20 readonly property bool isSeparator: appToplevel?.appId === "__dock_separator__" property int lastFocused: -1 - required property PersistentProperties visibilities + required property ScreenState screenState implicitHeight: Config.dock.height implicitWidth: isSeparator ? 1 : implicitHeight @@ -62,12 +62,12 @@ CustomRect { onClicked: { if (appToplevel?.toplevels.length === 0) { root.desktopEntry?.execute(); - root.visibilities.dock = false; + root.screenState.dock = false; return; } lastFocused = (lastFocused + 1) % appToplevel?.toplevels.length; appToplevel?.toplevels[lastFocused].activate(); - root.visibilities.dock = false; + root.screenState.dock = false; } } diff --git a/Modules/Dock/Wrapper.qml b/Modules/Dock/Wrapper.qml index a9897a5..df41f2e 100644 --- a/Modules/Dock/Wrapper.qml +++ b/Modules/Dock/Wrapper.qml @@ -12,8 +12,8 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 required property var panels required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.dock && Config.dock.enable - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.dock && Config.dock.enable + required property ScreenState screenState anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight @@ -39,7 +39,7 @@ Item { sourceComponent: Content { panels: root.panels screen: root.screen - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Drawing/Content.qml b/Modules/Drawing/Content.qml index 5fe6eea..c689836 100644 --- a/Modules/Drawing/Content.qml +++ b/Modules/Drawing/Content.qml @@ -12,7 +12,7 @@ Item { readonly property var colors1: ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4"] readonly property var colors2: ["#3b82f6", "#a855f7", "#ec4899", "#ffffff", "#000000"] required property var drawing - required property var visibilities + required property ScreenState screenState required property Wrapper wrapper function syncFromPenColor() { @@ -20,17 +20,17 @@ Item { return; if (!saturationSlider.pressed) - saturationSlider.value = drawing.drawingState.penColor.hsvSaturation; + saturationSlider.value = drawing.screenState.penColor.hsvSaturation; if (!brightnessSlider.pressed) - brightnessSlider.value = drawing.drawingState.penColor.hsvValue; + brightnessSlider.value = drawing.screenState.penColor.hsvValue; } function updatePenColorFromHsv() { if (!drawing) return; - drawing.drawingState.penColor = Qt.hsva(huePicker.currentHue, saturationSlider.value, brightnessSlider.value, drawing.drawingState.penColor.a); + drawing.screenState.penColor = Qt.hsva(huePicker.currentHue, saturationSlider.value, brightnessSlider.value, drawing.screenState.penColor.a); } implicitHeight: column.height + Tokens.padding.small * 2 @@ -43,7 +43,7 @@ Item { root.syncFromPenColor(); } - target: root.drawing.drawingState + target: root.drawing.screenState } Column { @@ -133,9 +133,9 @@ Item { multiplier: 1 orientation: Qt.Horizontal to: 45 - value: root.drawing.drawingState.penWidth + value: root.drawing.screenState.penWidth - onMoved: root.drawing.drawingState.penWidth = value + onMoved: root.drawing.screenState.penWidth = value } ButtonRow { @@ -153,7 +153,7 @@ Item { shapeMorph: true text: "Exit" - onClicked: root.visibilities.isDrawing = false + onClicked: root.screenState.isDrawing = false } IconTextButton { @@ -203,6 +203,6 @@ Item { shapeMorph: true shapeMorphExpansion: pressed ? 12 : 0 - onClicked: root.drawing.drawingState.penColor = modelData + onClicked: root.drawing.screenState.penColor = modelData } } diff --git a/Modules/Drawing/Wrapper.qml b/Modules/Drawing/Wrapper.qml index 4389b06..e7835d1 100644 --- a/Modules/Drawing/Wrapper.qml +++ b/Modules/Drawing/Wrapper.qml @@ -15,8 +15,8 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 property bool pinned: false required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.isDrawing - required property var visibilities + readonly property bool shouldBeActive: screenState.isDrawing + required property ScreenState screenState function collapse(): void { if (pinned) @@ -98,7 +98,7 @@ Item { } sourceComponent: Content { drawing: root.drawing - visibilities: root.visibilities + screenState: root.screenState wrapper: root } } diff --git a/Modules/Launcher/AppList.qml b/Modules/Launcher/AppList.qml index 72025b9..5ff38f6 100644 --- a/Modules/Launcher/AppList.qml +++ b/Modules/Launcher/AppList.qml @@ -15,7 +15,7 @@ CustomListView { property string displayText readonly property string requestedState: stateForText(search.text) required property SearchBar search - required property PersistentProperties visibilities + required property ScreenState screenState function resultsForText(text: string): var { switch (stateForText(text)) { @@ -46,7 +46,7 @@ CustomListView { } function syncDisplayText(): void { - if (visibilities.launcher && requestedState === displayState) + if (screenState.launcher && requestedState === displayState) displayText = search.text; } @@ -57,7 +57,7 @@ CustomListView { preferredHighlightBegin: 0 preferredHighlightEnd: height spacing: Tokens.spacing.small - state: visibilities.launcher ? requestedState : displayState + state: screenState.launcher ? requestedState : displayState verticalLayoutDirection: ListView.BottomToTop CustomScrollBar.vertical: CustomScrollBar { @@ -245,7 +245,7 @@ CustomListView { id: appItem AppItem { - visibilities: root.visibilities + screenState: root.screenState } } @@ -294,6 +294,6 @@ CustomListView { root.syncDisplayText(); } - target: root.visibilities + target: root.screenState } } diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml index e1aeb80..3db4299 100755 --- a/Modules/Launcher/CarouselView.qml +++ b/Modules/Launcher/CarouselView.qml @@ -13,14 +13,14 @@ CarouselView { readonly property var screen: QsWindow.window?.screen required property SearchBar search readonly property var values: Wallpapers.query(root.search.text.split(" ").slice(1).join(" ")) - required property var visibilities + required property ScreenState screenState currentIndex: { const idx = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent); return idx >= 0 ? idx : 0; } delegateProperties: ({ - visibilities: root.visibilities + screenState: root.screenState }) focalWidth: Config.launcher.sizes.wallpaperWidth glideDuration: 250 @@ -32,7 +32,7 @@ CarouselView { return 0; const barMargins = Config.bar.border * 2; let margins = Math.round(screen.width / 6); - if (visibilities.utilities || visibilities.sidebar) + if (screenState.utilities || screenState.sidebar) margins = Config.sidebar.sizes.width; return screen.width - (barMargins + margins) * 2 - Config.bar.rounding * 4; } diff --git a/Modules/Launcher/Content.qml b/Modules/Launcher/Content.qml index 3d86f0a..4db1508 100644 --- a/Modules/Launcher/Content.qml +++ b/Modules/Launcher/Content.qml @@ -15,7 +15,7 @@ Item { readonly property int padding: Tokens.padding.largeIncreased / 4 required property var panels readonly property int rounding: Tokens.rounding.largeIncreased - required property PersistentProperties visibilities + required property ScreenState screenState implicitHeight: search.height + listWrapper.height + padding * 2 implicitWidth: listWrapper.width + padding * 2 @@ -38,7 +38,7 @@ Item { panels: root.panels rounding: root.rounding search: search - visibilities: root.visibilities + screenState: root.screenState } } @@ -55,7 +55,7 @@ Item { Component.onCompleted: forceActiveFocus() Keys.onDownPressed: list.currentList?.decrementCurrentIndex() - Keys.onEscapePressed: root.visibilities.launcher = false + Keys.onEscapePressed: root.screenState.launcher = false Keys.onPressed: event => { if (event.modifiers & Qt.ControlModifier) { if (event.key === Qt.Key_J) { @@ -81,7 +81,7 @@ Item { if (Colors.scheme === "dynamic" && currentItem.modelData.path !== Wallpapers.actualCurrent) Wallpapers.previewColorLock = true; Wallpapers.setWallpaper(currentItem.modelData.path); - root.visibilities.launcher = false; + root.screenState.launcher = false; } else if (text.startsWith(Config.launcher.actionPrefix)) { if (text.startsWith(`${Config.launcher.actionPrefix}calc `)) currentItem.onClicked(); @@ -91,18 +91,18 @@ Item { currentItem.modelData.onClicked(list.currentList); } else { Apps.launch(currentItem.modelData); - root.visibilities.launcher = false; + root.screenState.launcher = false; } } } Connections { function onLauncherChanged(): void { - if (!root.visibilities.launcher) + if (!root.screenState.launcher) search.text = ""; } - target: root.visibilities + target: root.screenState } } } diff --git a/Modules/Launcher/ContentList.qml b/Modules/Launcher/ContentList.qml index 0081914..76bfeed 100644 --- a/Modules/Launcher/ContentList.qml +++ b/Modules/Launcher/ContentList.qml @@ -20,7 +20,7 @@ Item { required property int rounding required property SearchBar search readonly property bool showWallpapers: search.text.startsWith(`${Config.launcher.actionPrefix}wallpaper `) - required property PersistentProperties visibilities + required property ScreenState screenState anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter @@ -28,7 +28,7 @@ Item { state: showWallpapers ? "wallpapers" : "apps" Behavior on implicitHeight { - enabled: root.visibilities.launcher + enabled: root.screenState.launcher Anim { duration: Tokens.anim.durations.small @@ -36,7 +36,7 @@ Item { } } Behavior on implicitWidth { - enabled: root.visibilities.launcher + enabled: root.screenState.launcher Anim { duration: Tokens.anim.durations.small @@ -99,7 +99,7 @@ Item { sourceComponent: AppList { search: root.search - visibilities: root.visibilities + screenState: root.screenState } } @@ -115,7 +115,7 @@ Item { sourceComponent: CarouselView { panels: root.panels search: root.search - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Modules/Launcher/Items/AppItem.qml b/Modules/Launcher/Items/AppItem.qml index 932f69a..fc8ed82 100644 --- a/Modules/Launcher/Items/AppItem.qml +++ b/Modules/Launcher/Items/AppItem.qml @@ -12,7 +12,7 @@ Item { id: root required property DesktopEntry modelData - required property PersistentProperties visibilities + required property ScreenState screenState anchors.left: parent?.left anchors.right: parent?.right @@ -23,7 +23,7 @@ Item { onClicked: { Apps.launch(root.modelData); - root.visibilities.launcher = false; + root.screenState.launcher = false; } } diff --git a/Modules/Launcher/Items/CalcItem.qml b/Modules/Launcher/Items/CalcItem.qml index 282f361..63a9ef0 100644 --- a/Modules/Launcher/Items/CalcItem.qml +++ b/Modules/Launcher/Items/CalcItem.qml @@ -16,7 +16,7 @@ Item { function onClicked(): void { Quickshell.execDetached(["wl-copy", Qalculator.eval(math, false)]); - root.list.visibilities.launcher = false; + root.list.screenState.launcher = false; } anchors.left: parent?.left @@ -81,7 +81,7 @@ Item { onClicked: { Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.terminal, "fish", "-C", `exec qalc -i '${root.math}'`]); - root.list.visibilities.launcher = false; + root.list.screenState.launcher = false; } } diff --git a/Modules/Launcher/Items/FilesItem.qml b/Modules/Launcher/Items/FilesItem.qml index 4c95075..ab3b3ba 100644 --- a/Modules/Launcher/Items/FilesItem.qml +++ b/Modules/Launcher/Items/FilesItem.qml @@ -18,7 +18,7 @@ Item { implicitHeight: Config.launcher.sizes.itemHeight function onClicked(): void { - root.list.visibilities.launcher = false; + root.list.screenState.launcher = false; if (root.modelData?.path) Files.launch(["xdg-open", root.modelData.path]); } @@ -60,7 +60,7 @@ Item { } onClicked: { - root.list.visibilities.launcher = false; + root.list.screenState.launcher = false; if (root.modelData?.path) Files.showInFolder(root.modelData.path); } diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index 806f89e..b4a1091 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -12,7 +12,7 @@ Item { required property var modelData // FileSystemEntry-like {path, relativePath} required property real tileHeight required property real tileWidth - required property PersistentProperties visibilities + required property ScreenState screenState signal requestActivate diff --git a/Modules/Launcher/Services/Actions.qml b/Modules/Launcher/Services/Actions.qml index 80df3fc..135dbc3 100644 --- a/Modules/Launcher/Services/Actions.qml +++ b/Modules/Launcher/Services/Actions.qml @@ -42,10 +42,10 @@ Searcher { if (command[0] === "autocomplete" && command.length > 1) { list.search.text = `${Config.launcher.actionPrefix}${command[1]} `; } else if (command[0] === "setMode" && command.length > 1) { - list.visibilities.launcher = false; + list.screenState.launcher = false; Colors.setMode(command[1]); } else { - list.visibilities.launcher = false; + list.screenState.launcher = false; Quickshell.execDetached(command); } } diff --git a/Modules/Launcher/Services/SchemeVariants.qml b/Modules/Launcher/Services/SchemeVariants.qml index 08a754f..5488706 100644 --- a/Modules/Launcher/Services/SchemeVariants.qml +++ b/Modules/Launcher/Services/SchemeVariants.qml @@ -79,7 +79,7 @@ Searcher { required property string variant function onClicked(list: AppList): void { - list.visibilities.launcher = false; + list.screenState.launcher = false; Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--scheme", variant]); Config.colors.schemeType = variant; } diff --git a/Modules/Launcher/Wrapper.qml b/Modules/Launcher/Wrapper.qml index e5ae86c..1fa0c24 100644 --- a/Modules/Launcher/Wrapper.qml +++ b/Modules/Launcher/Wrapper.qml @@ -12,7 +12,7 @@ Item { property int contentHeight readonly property real maxHeight: { let max = screen.height - Tokens.spacing.largeIncreased * 2; - if (visibilities.resources && panels.resourcesWrapper.x + panels.resourcesWrapper.width > root.x) + if (screenState.resources && panels.resourcesWrapper.x + panels.resourcesWrapper.width > root.x) max -= panels.resources.nonAnimHeight; if (panels.popouts.hasCurrent) if (panels.popouts.current?.x + panels.popouts.current?.width > root.x && panels.popouts.current?.x < root.x + root.width) @@ -22,8 +22,8 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 required property var panels required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.launcher - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.launcher + required property ScreenState screenState anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight @@ -57,7 +57,7 @@ Item { sourceComponent: Content { maxHeight: root.maxHeight panels: root.panels - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Lock/Weather/Forecast.qml b/Modules/Lock/Weather/Forecast.qml index 07632b3..467879b 100644 --- a/Modules/Lock/Weather/Forecast.qml +++ b/Modules/Lock/Weather/Forecast.qml @@ -60,7 +60,7 @@ CustomRect { MaterialShape { Layout.alignment: Qt.AlignHCenter - color: Qt.alpha(Colors.palette.m3primary, hour.index === 0 ? 1 : 0) + color: Qt.alpha(Colors.palette.m3primary, hour.index === Weather.currentHourIndex ? 1 : 0) implicitSize: temp.implicitHeight + Tokens.padding.small * 2 shape: MaterialShape.Cookie4Sided @@ -73,7 +73,7 @@ CustomRect { id: temp anchors.centerIn: parent - color: hour.index === 0 ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: hour.index === Weather.currentHourIndex ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface font.pointSize: Tokens.font.size.normal text: Weather.formatTemp(hour.cond.tempC).slice(0, -1) // Remove C/F } diff --git a/Modules/Notifications/Content.qml b/Modules/Notifications/Content.qml index 46477a9..bad138a 100644 --- a/Modules/Notifications/Content.qml +++ b/Modules/Notifications/Content.qml @@ -13,7 +13,7 @@ Item { readonly property int padding: Tokens.padding.smaller required property Item panels - required property PersistentProperties visibilities + required property ScreenState screenState anchors.bottom: parent.bottom anchors.right: parent.right @@ -27,16 +27,16 @@ Item { for (let i = 0; i < count; i++) height += (list.itemAtIndex(i) as NotifWrapper)?.nonAnimHeight ?? 0; - if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x || visibilities.dashboard) + if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x || screenState.dashboard) return 0; - if (visibilities.osd) { + if (screenState.osd) { const h = panels.osd.y - Tokens.spacing.small * 2 - padding * 2; if (height > h) height = h; } - if (visibilities.session) { + if (screenState.session) { const h = panels.session.y - Tokens.spacing.small * 2 - padding * 2; if (height > h) height = h; diff --git a/Modules/Notifications/Sidebar/Chat/ChatState.qml b/Modules/Notifications/Sidebar/Chat/ChatState.qml index df83c8e..0240a94 100644 --- a/Modules/Notifications/Sidebar/Chat/ChatState.qml +++ b/Modules/Notifications/Sidebar/Chat/ChatState.qml @@ -3,7 +3,7 @@ pragma Singleton import QtQuick import Quickshell import ZShell.Llm -import qs.Helpers +import qs.Services Singleton { id: root @@ -20,7 +20,7 @@ Singleton { property bool narrowSidebarExpanded: false property bool windowIsWide: false property ShellScreen screen - property bool sidebarVisible: Visibilities.getForActive().sidebar + property bool sidebarVisible: ShellState.forActive().sidebar onSidebarVisibleChanged: if (!sidebarVisible) fabExpanded = false diff --git a/Modules/Notifications/Sidebar/Chat/Detach.qml b/Modules/Notifications/Sidebar/Chat/Detach.qml index c0ab82d..5e978a6 100644 --- a/Modules/Notifications/Sidebar/Chat/Detach.qml +++ b/Modules/Notifications/Sidebar/Chat/Detach.qml @@ -3,7 +3,7 @@ pragma Singleton import Quickshell import QtQuick import ZShell.Config -import qs.Helpers +import qs.Services import qs.Services Singleton { @@ -11,7 +11,7 @@ Singleton { function create(parent: Item, props: var): void { chatComp.createObject(parent ?? dummy, props); - Visibilities.getForActive().sidebar = false; + ShellState.forActive().sidebar = false; ChatState.isWindow = true; } diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml index 4e89694..1a6cea8 100644 --- a/Modules/Notifications/Sidebar/Content.qml +++ b/Modules/Notifications/Sidebar/Content.qml @@ -10,13 +10,12 @@ import qs.Services Item { id: root - required property Props props - required property var visibilities + required property ScreenState screenState Connections { function onIsWindowChanged(): void { if (ChatState.isWindow) - root.props.currentTab = 0; + root.screenState.sidebarTab = 0; } target: ChatState @@ -31,7 +30,7 @@ Item { Tabs { Layout.fillWidth: true Layout.preferredHeight: ChatState.isWindow || !Config.llm.enabled ? 0 : implicitHeight - dashState: root.props + screenState: root.screenState nonAnimWidth: layout.width visible: height > 0 } @@ -47,9 +46,9 @@ Item { anchors.bottom: parent.bottom anchors.top: parent.top implicitWidth: parent.width - opacity: root.props.currentTab === 0 ? 1 : 0 + opacity: root.screenState.sidebarTab === 0 ? 1 : 0 visible: opacity > 0 - x: root.props.currentTab === 0 ? 0 : -root.width + x: root.screenState.sidebarTab === 0 ? 0 : -root.width Behavior on opacity { Anim {} @@ -64,8 +63,7 @@ Item { radius: Tokens.rounding.small NotifDock { - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } @@ -82,9 +80,9 @@ Item { sourceComponent: Item { id: chatPage - opacity: root.props.currentTab === 1 ? 1 : 0 + opacity: root.screenState.sidebarTab === 1 ? 1 : 0 visible: opacity > 0 - x: root.props.currentTab === 0 ? root.width : 0 + x: root.screenState.sidebarTab === 0 ? root.width : 0 z: 1 Behavior on opacity { diff --git a/Modules/Notifications/Sidebar/Notif.qml b/Modules/Notifications/Sidebar/Notif.qml index 4f3c13c..ae7a016 100644 --- a/Modules/Notifications/Sidebar/Notif.qml +++ b/Modules/Notifications/Sidebar/Notif.qml @@ -15,8 +15,7 @@ CustomRect { required property bool expanded required property NotifServer.Notif modelData readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + Tokens.padding.medium * 2 : summaryHeightMetrics.height - required property Props props - required property PersistentProperties visibilities + required property ScreenState screenState color: { const c = root.modelData?.urgency === "critical" ? Colors.palette.m3secondaryContainer : Colors.layer(Colors.palette.m3surfaceContainerHigh, 2); @@ -137,7 +136,7 @@ CustomRect { onLinkActivated: link => { Qt.openUrlExternally(link); - root.visibilities.sidebar = false; + root.screenState.sidebar = false; } } diff --git a/Modules/Notifications/Sidebar/NotifDock.qml b/Modules/Notifications/Sidebar/NotifDock.qml index 5cd56cc..6792807 100644 --- a/Modules/Notifications/Sidebar/NotifDock.qml +++ b/Modules/Notifications/Sidebar/NotifDock.qml @@ -14,8 +14,7 @@ Item { id: root readonly property int notifCount: NotifServer.list.reduce((acc, n) => n.closed ? acc : acc + 1, 0) - required property Props props - required property var visibilities + required property ScreenState screenState anchors.fill: parent anchors.margins: Tokens.padding.small @@ -120,8 +119,7 @@ Item { id: notifList container: view - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/NotifDockList.qml b/Modules/Notifications/Sidebar/NotifDockList.qml index 597a7ba..759e6d9 100644 --- a/Modules/Notifications/Sidebar/NotifDockList.qml +++ b/Modules/Notifications/Sidebar/NotifDockList.qml @@ -12,8 +12,7 @@ LazyListView { id: root required property Flickable container - required property Props props - required property PersistentProperties visibilities + required property ScreenState screenState anchors.left: parent?.left anchors.right: parent?.right @@ -118,8 +117,7 @@ LazyListView { container: root.container modelData: notif.modelData - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/NotifGroup.qml b/Modules/Notifications/Sidebar/NotifGroup.qml index bf6a376..faa67eb 100644 --- a/Modules/Notifications/Sidebar/NotifGroup.qml +++ b/Modules/Notifications/Sidebar/NotifGroup.qml @@ -16,7 +16,7 @@ CustomRect { readonly property list activeNotifs: notifs.filter(n => !n.closed) readonly property string appIcon: activeNotifs.find(n => n.appIcon.length > 0)?.appIcon ?? "" required property Flickable container - readonly property bool expanded: props.expandedNotifs.includes(modelData) + readonly property bool expanded: screenState.expandedNotifs.includes(modelData) readonly property string image: activeNotifs.find(n => n.image.length > 0)?.image ?? "" required property string modelData readonly property int nonAnimHeight: { @@ -26,7 +26,7 @@ CustomRect { } readonly property int notifCount: activeNotifs.length readonly property list notifs: NotifServer.list.filter(n => n.appName === modelData) - required property Props props + required property ScreenState screenState readonly property int urgency: { if (activeNotifs.find(n => n.urgency === NotificationUrgency.Critical)) return NotificationUrgency.Critical; @@ -34,14 +34,13 @@ CustomRect { return NotificationUrgency.Normal; return NotificationUrgency.Low; } - required property PersistentProperties visibilities function toggleExpand(expand: bool): void { if (expand) { if (!expanded) - props.expandedNotifs.push(modelData); + screenState.expandedNotifs.push(modelData); } else if (expanded) { - props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1); + screenState.expandedNotifs.splice(screenState.expandedNotifs.indexOf(modelData), 1); } } @@ -59,7 +58,7 @@ CustomRect { Component.onDestruction: { if (notifCount === 0 && expanded) - props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1); + screenState.expandedNotifs.splice(screenState.expandedNotifs.indexOf(modelData), 1); } RowLayout { @@ -233,8 +232,7 @@ CustomRect { container: root.container expanded: root.expanded notifs: root.notifs - props: root.props - visibilities: root.visibilities + screenState: root.screenState onRequestToggleExpand: expand => root.toggleExpand(expand) } diff --git a/Modules/Notifications/Sidebar/NotifGroupList.qml b/Modules/Notifications/Sidebar/NotifGroupList.qml index 904a787..b047878 100644 --- a/Modules/Notifications/Sidebar/NotifGroupList.qml +++ b/Modules/Notifications/Sidebar/NotifGroupList.qml @@ -14,8 +14,7 @@ LazyListView { required property Flickable container required property bool expanded required property list notifs - required property Props props - required property PersistentProperties visibilities + required property ScreenState screenState signal requestToggleExpand(expand: bool) @@ -122,8 +121,7 @@ LazyListView { anchors.fill: parent expanded: root.expanded modelData: notif.modelData - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/Props.qml b/Modules/Notifications/Sidebar/Props.qml deleted file mode 100644 index dcb4eb0..0000000 --- a/Modules/Notifications/Sidebar/Props.qml +++ /dev/null @@ -1,9 +0,0 @@ -import Quickshell -import ZShell.Llm - -PersistentProperties { - property int currentTab: 0 - property list expandedNotifs: [] - - reloadableId: "sidebar" -} diff --git a/Modules/Notifications/Sidebar/Tabs.qml b/Modules/Notifications/Sidebar/Tabs.qml index 2a9bfb8..09b6f77 100644 --- a/Modules/Notifications/Sidebar/Tabs.qml +++ b/Modules/Notifications/Sidebar/Tabs.qml @@ -12,7 +12,7 @@ Item { id: root readonly property alias count: bar.count - required property PersistentProperties dashState + required property ScreenState screenState required property real nonAnimWidth implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight @@ -24,7 +24,7 @@ Item { anchors.right: parent.right anchors.top: parent.top background: null - currentIndex: root.dashState.currentTab + currentIndex: root.screenState.sidebarTab implicitHeight: contentHeight contentItem: RowLayout { @@ -35,7 +35,7 @@ Item { } } - onCurrentIndexChanged: root.state.currentTab = currentIndex + onCurrentIndexChanged: root.screenState.sidebarTab = currentIndex Tab { iconName: "notifications" @@ -110,7 +110,7 @@ Item { color: tab.current ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface radius: Tokens.rounding.small - onClicked: root.dashState.currentTab = tab.TabBar.index + onClicked: root.screenState.sidebarTab = tab.TabBar.index } MaterialIcon { diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Record.qml b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml index 6731cbe..0f90322 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Record.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Record.qml @@ -11,8 +11,8 @@ import qs.Services CustomRect { id: root - required property var props - required property PersistentProperties visibilities + required property ScreenState screenState + Layout.fillWidth: true color: Colors.tPalette.m3surfaceContainer @@ -72,7 +72,7 @@ CustomRect { } SplitButton { - active: menuItems.find(m => root.props.recordingMode === m.icon + m.text) ?? menuItems[0] + active: menuItems.find(m => root.screenState.recordingMode === m.icon + m.text) ?? menuItems[0] enabled: !Recorder.running menuOnTop: true @@ -107,7 +107,7 @@ CustomRect { } ] - menu.onItemSelected: item => root.props.recordingMode = item.icon + item.text + menu.onItemSelected: item => root.screenState.recordingMode = item.icon + item.text } } @@ -190,8 +190,7 @@ CustomRect { id: recordingList RecordingList { - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml index cea59cc..4222414 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/RecordingList.qml @@ -14,8 +14,8 @@ import qs.Services ColumnLayout { id: root - required property var props - required property PersistentProperties visibilities + required property ScreenState screenState + spacing: 0 @@ -23,7 +23,7 @@ ColumnLayout { Layout.fillWidth: true cursorShape: Qt.PointingHandCursor - onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded + onClicked: root.screenState.recordingListExpanded = !root.screenState.recordingListExpanded RowLayout { spacing: Tokens.spacing.small @@ -42,11 +42,11 @@ ColumnLayout { } IconButton { - icon: root.props.recordingListExpanded ? "unfold_less" : "unfold_more" + icon: root.screenState.recordingListExpanded ? "unfold_less" : "unfold_more" label.animate: true type: IconButton.Text - onClicked: root.props.recordingListExpanded = !root.props.recordingListExpanded + onClicked: root.screenState.recordingListExpanded = !root.screenState.recordingListExpanded } } } @@ -57,7 +57,7 @@ ColumnLayout { Layout.fillWidth: true Layout.rightMargin: -Tokens.spacing.small clip: true - implicitHeight: (Tokens.font.size.larger + Tokens.padding.extraSmall) * (root.props.recordingListExpanded ? 10 : 3) + implicitHeight: (Tokens.font.size.larger + Tokens.padding.extraSmall) * (root.screenState.recordingListExpanded ? 10 : 3) CustomScrollBar.vertical: CustomScrollBar { flickable: list @@ -109,7 +109,7 @@ ColumnLayout { type: IconButton.Text onClicked: { - root.visibilities.sidebar = false; + root.screenState.sidebar = false; if (Config.launcher.uwsm) Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); else @@ -122,7 +122,7 @@ ColumnLayout { type: IconButton.Text onClicked: { - root.visibilities.sidebar = false; + root.screenState.sidebar = false; if (Config.launcher.uwsm) Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); else @@ -174,11 +174,11 @@ ColumnLayout { MaterialIcon { Layout.alignment: Qt.AlignHCenter - Layout.preferredHeight: root.props.recordingListExpanded ? implicitHeight : 0 + Layout.preferredHeight: root.screenState.recordingListExpanded ? implicitHeight : 0 color: Colors.palette.m3outline font.pointSize: Tokens.font.size.extraLarge - opacity: root.props.recordingListExpanded ? 1 : 0 - scale: root.props.recordingListExpanded ? 1 : 0 + opacity: root.screenState.recordingListExpanded ? 1 : 0 + scale: root.screenState.recordingListExpanded ? 1 : 0 text: "scan_delete" Behavior on Layout.preferredHeight { @@ -197,10 +197,10 @@ ColumnLayout { MaterialIcon { Layout.alignment: Qt.AlignHCenter - Layout.preferredWidth: !root.props.recordingListExpanded ? implicitWidth : 0 + Layout.preferredWidth: !root.screenState.recordingListExpanded ? implicitWidth : 0 color: Colors.palette.m3outline - opacity: !root.props.recordingListExpanded ? 1 : 0 - scale: !root.props.recordingListExpanded ? 1 : 0 + opacity: !root.screenState.recordingListExpanded ? 1 : 0 + scale: !root.screenState.recordingListExpanded ? 1 : 0 text: "scan_delete" Behavior on Layout.preferredWidth { diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml index 4fbf307..60c24a9 100644 --- a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml +++ b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml @@ -39,7 +39,7 @@ CustomRect { }); } readonly property int splitIndex: Math.ceil(quickToggles.length / 2) - required property PersistentProperties visibilities + required property ScreenState screenState color: Colors.tPalette.m3surfaceContainer implicitHeight: layout.implicitHeight + Tokens.padding.extraLargeIncreased @@ -166,8 +166,8 @@ CustomRect { isToggle: false onClicked: { - root.visibilities.sidebar = false; - root.visibilities.settings = true; + root.screenState.sidebar = false; + root.screenState.settings = true; } } } diff --git a/Modules/Notifications/Sidebar/Utils/Content.qml b/Modules/Notifications/Sidebar/Utils/Content.qml index 1bdf03d..1a66fc0 100644 --- a/Modules/Notifications/Sidebar/Utils/Content.qml +++ b/Modules/Notifications/Sidebar/Utils/Content.qml @@ -1,14 +1,14 @@ import Quickshell import QtQuick import QtQuick.Layouts +import qs.Components import qs.Modules.Notifications.Sidebar.Utils.Cards import ZShell.Config Item { id: root - required property PersistentProperties props - required property var visibilities + required property ScreenState screenState implicitHeight: layout.implicitHeight implicitWidth: layout.implicitWidth @@ -22,14 +22,13 @@ Item { IdleInhibit {} Record { - props: root.props - visibilities: root.visibilities + screenState: root.screenState z: 1 } Toggles { Layout.fillWidth: true - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/Utils/Wrapper.qml b/Modules/Notifications/Sidebar/Utils/Wrapper.qml index fa10251..5144b3b 100644 --- a/Modules/Notifications/Sidebar/Utils/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Utils/Wrapper.qml @@ -9,16 +9,9 @@ Item { id: root property real offsetScale: shouldBeActive ? 0 : 1 - readonly property PersistentProperties props: PersistentProperties { - property string recordingConfirmDelete - property bool recordingListExpanded: false - property string recordingMode - - reloadableId: "utilities" - } - readonly property bool shouldBeActive: visibilities.sidebar && !sidebar.chatActive + readonly property bool shouldBeActive: screenState.sidebar && !sidebar.chatActive required property Item sidebar - required property var visibilities + required property ScreenState screenState anchors.bottomMargin: (-implicitHeight - 5) * offsetScale implicitHeight: content.implicitHeight + Tokens.padding.small * 2 @@ -43,8 +36,7 @@ Item { sourceComponent: Content { implicitWidth: root.implicitWidth - 8 * 2 - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Sidebar/Wrapper.qml b/Modules/Notifications/Sidebar/Wrapper.qml index dc92aa7..9a079cf 100644 --- a/Modules/Notifications/Sidebar/Wrapper.qml +++ b/Modules/Notifications/Sidebar/Wrapper.qml @@ -13,11 +13,9 @@ Item { property real offsetScale: shouldBeActive ? 0 : 1 required property var panels - 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 + readonly property bool shouldBeActive: root.screenState.sidebar && Config.sidebar.enabled + readonly property bool chatActive: screenState.sidebarTab === 1 + required property ScreenState screenState anchors.rightMargin: (-implicitWidth - 5) * offsetScale implicitWidth: Config.sidebar.sizes.width @@ -54,8 +52,7 @@ Item { sourceComponent: Content { implicitWidth: Config.sidebar.sizes.width - Tokens.padding.small * 2 - props: root.props - visibilities: root.visibilities + screenState: root.screenState } } } diff --git a/Modules/Notifications/Wrapper.qml b/Modules/Notifications/Wrapper.qml index 03131f8..e010b09 100644 --- a/Modules/Notifications/Wrapper.qml +++ b/Modules/Notifications/Wrapper.qml @@ -7,7 +7,7 @@ Item { required property Item panels required property Item sidebarPanel - required property var visibilities + required property ScreenState screenState implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth @@ -17,6 +17,6 @@ Item { id: content panels: root.panels - visibilities: root.visibilities + screenState: root.screenState } } diff --git a/Modules/Osd/Content.qml b/Modules/Osd/Content.qml index d1b8b35..e1ac3d9 100644 --- a/Modules/Osd/Content.qml +++ b/Modules/Osd/Content.qml @@ -16,7 +16,7 @@ Item { required property bool muted required property bool sourceMuted required property real sourceVolume - required property var visibilities + required property ScreenState screenState required property real volume implicitHeight: layout.implicitHeight + Tokens.padding.smaller * 2 @@ -53,7 +53,7 @@ Item { // Microphone volume WrappedLoader { - shouldBeActive: Config.osd.enableMicrophone && (!Config.osd.enableBrightness || !root.visibilities.session) + shouldBeActive: Config.osd.enableMicrophone && (!Config.osd.enableBrightness || !root.screenState.session) sourceComponent: CustomMouseArea { function onWheel(event: WheelEvent) { diff --git a/Modules/Osd/Wrapper.qml b/Modules/Osd/Wrapper.qml index e879a4c..e388beb 100644 --- a/Modules/Osd/Wrapper.qml +++ b/Modules/Osd/Wrapper.qml @@ -16,16 +16,16 @@ Item { property bool muted property real offsetScale: shouldBeActive ? 0 : 1 required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.osd && Config.osd.enabled && !(visibilities.utilities && Config.utilities.enabled) + readonly property bool shouldBeActive: screenState.osd && Config.osd.enabled && !(screenState.utilities && Config.utilities.enabled) property real sidebarOffset: sidebarOrSessionVisible ? 12 : 0 required property bool sidebarOrSessionVisible property bool sourceMuted property real sourceVolume - required property var visibilities + required property ScreenState screenState property real volume function show(): void { - visibilities.osd = true; + screenState.osd = true; timer.restart(); } @@ -90,7 +90,7 @@ Item { onTriggered: { if (!root.hovered) - root.visibilities.osd = false; + root.screenState.osd = false; } } @@ -107,7 +107,7 @@ Item { muted: root.muted sourceMuted: root.sourceMuted sourceVolume: root.sourceVolume - visibilities: root.visibilities + screenState: root.screenState volume: root.volume } } diff --git a/Modules/Resources/Wrapper.qml b/Modules/Resources/Wrapper.qml index 9004694..99adbf0 100644 --- a/Modules/Resources/Wrapper.qml +++ b/Modules/Resources/Wrapper.qml @@ -11,8 +11,8 @@ Item { readonly property real nonAnimHeight: content.item?.nonAnimHeight ?? 0 property real offsetScale: shouldBeActive ? 0 : 1 required property string position - readonly property bool shouldBeActive: root.visibilities.resources - required property PersistentProperties visibilities + readonly property bool shouldBeActive: root.screenState.resources + required property ScreenState screenState anchors.bottom: position === "bottom" ? parent.bottom : undefined anchors.bottomMargin: position === "bottom" ? (-implicitHeight - 5) * offsetScale : 0 diff --git a/Modules/Settings/Common/TextFieldRow.qml b/Modules/Settings/Common/TextFieldRow.qml new file mode 100644 index 0000000..38dd9a8 --- /dev/null +++ b/Modules/Settings/Common/TextFieldRow.qml @@ -0,0 +1,85 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Services + +ConnectedRect { + id: root + + property alias label: label.text + property string subtext + property string value + property alias placeholderText: input.placeholderText + property string errorText + property alias maximumLength: input.maximumLength + property alias validate: input.validate + property alias validator: input.validator + property bool smallField + readonly property alias field: input + + signal valueEdited(value: string) + signal editingFinished(value: string) + + function clear(): void { + input.clear(); + } + + Component.onDestruction: { + if (value !== input.text) + editingFinished(input.text); + } + + Layout.fillWidth: true + implicitHeight: rowLayout.implicitHeight + Tokens.padding.medium + Math.max(0, Tokens.padding.large - input.verticalPadding) * 2 + + RowLayout { + id: rowLayout + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + CustomText { + id: label + + Layout.fillWidth: true + font: Tokens.font.body.small + elide: Text.ElideRight + } + + CustomText { + Layout.fillWidth: true + visible: root.subtext + text: input.isError && root.errorText ? root.errorText : root.subtext + color: input.isError && root.errorText ? Colors.palette.m3error : Colors.palette.m3outline + font.pointSize: Tokens.font.size.small + elide: Text.ElideRight + animate: root.errorText + } + } + + CustomTextField { + id: input + + Layout.preferredWidth: root.smallField ? Tokens.sizes.nexus.smallTextFieldWidth : Tokens.sizes.nexus.textFieldWidth + Layout.maximumWidth: root.width / 2 + Layout.alignment: Qt.AlignVCenter + verticalPadding: Tokens.padding.small + + text: root.value + + onTextEdited: root.valueEdited(text) + onEditingFinished: root.editingFinished(text) + } + } +} diff --git a/Modules/Settings/Pages/Panels/DashboardPanel.qml b/Modules/Settings/Pages/Panels/DashboardPanel.qml index 7657761..af8beb8 100644 --- a/Modules/Settings/Pages/Panels/DashboardPanel.qml +++ b/Modules/Settings/Pages/Panels/DashboardPanel.qml @@ -33,5 +33,33 @@ PageBase { onToggled: Config.dashboard.enabled = checked } + + // Behavior + SectionHeader { + text: qsTr("Behavior") + } + + ToggleRow { + checked: Config.dashboard.showOnHover + first: true + settingAnchor: "dashboard-showonhover" + text: qsTr("Show on hover") + subtext: qsTr("Dashboard %1 be shown when hovering the middle-top part of the shell").arg(checked ? "will" : "will not") + + onToggled: Config.dashboard.showOnHover = checked + } + + SpinRow { + from: 30 + settingAnchor: "dashboard-drag-threshold" + stepSize: 20 + enabled: !Config.dashboard.showOnHover + subtext: qsTr("How far you need to drag before the panel opens") + text: qsTr("Drag threshold") + to: 100 + value: Config.dashboard.dragThreshold + + onMoved: v => Config.dashboard.dragThreshold = v + } } } diff --git a/Modules/Settings/Wrapper.qml b/Modules/Settings/Wrapper.qml index 6de8131..a096857 100644 --- a/Modules/Settings/Wrapper.qml +++ b/Modules/Settings/Wrapper.qml @@ -12,8 +12,8 @@ Item { required property var panels property string queuedMode: "wallpaper" required property ShellScreen screen - readonly property bool shouldBeActive: visibilities.settings - required property PersistentProperties visibilities + readonly property bool shouldBeActive: screenState.settings + required property ScreenState screenState implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth diff --git a/Modules/Shortcuts.qml b/Modules/Shortcuts.qml index 68c6662..ca227ca 100644 --- a/Modules/Shortcuts.qml +++ b/Modules/Shortcuts.qml @@ -3,6 +3,7 @@ import Quickshell import Quickshell.Io import qs.Components import qs.Helpers +import qs.Services Scope { id: root @@ -17,8 +18,8 @@ Scope { onPressed: { root.launcherInterrupted = false; if (!root.launcherInterrupted && !root.hasFullscreen) { - const visibilities = Visibilities.getForActive(); - visibilities.launcher = !visibilities.launcher; + const state = ShellState.forActive(); + state.launcher = !state.launcher; } root.launcherInterrupted = false; } @@ -28,8 +29,8 @@ Scope { name: "toggle-drawing" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.isDrawing = !visibilities.isDrawing; + const state = ShellState.forActive(); + state.isDrawing = !state.isDrawing; } } @@ -37,8 +38,8 @@ Scope { name: "toggle-nc" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.sidebar = !visibilities.sidebar; + const state = ShellState.forActive(); + state.sidebar = !state.sidebar; } } @@ -46,8 +47,8 @@ Scope { name: "show-osd" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.osd = !visibilities.osd; + const state = ShellState.forActive(); + state.osd = !state.osd; } } @@ -55,8 +56,8 @@ Scope { name: "toggle-settings" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.settings = !visibilities.settings; + const state = ShellState.forActive(); + state.settings = !state.settings; } } @@ -64,8 +65,8 @@ Scope { name: "toggle-clipboard" onPressed: { - const visibilities = Visibilities.getForActive(); - visibilities.clipboard = !visibilities.clipboard; + const state = ShellState.forActive(); + state.clipboard = !state.clipboard; } } } diff --git a/Plugins/ZShell/Config/CMakeLists.txt b/Plugins/ZShell/Config/CMakeLists.txt index dfe9d59..ae992f3 100644 --- a/Plugins/ZShell/Config/CMakeLists.txt +++ b/Plugins/ZShell/Config/CMakeLists.txt @@ -1,5 +1,6 @@ qml_module(ZShell-config URI ZShell.Config + INCLUDE_PREFIX config SOURCES confignode.hpp confignode.cpp configobject.hpp configobject.cpp @@ -16,6 +17,7 @@ qml_module(ZShell-config dashboard.hpp display.hpp dock.hpp + enums.hpp general.hpp launcher.hpp llm.hpp diff --git a/Plugins/ZShell/Config/dashboard.hpp b/Plugins/ZShell/Config/dashboard.hpp index 97471b8..271c6f5 100644 --- a/Plugins/ZShell/Config/dashboard.hpp +++ b/Plugins/ZShell/Config/dashboard.hpp @@ -48,6 +48,8 @@ class Dashboard : public ConfigObject { QML_ANONYMOUS CFG_PROPERTY(bool, enabled, true) + CFG_PROPERTY(bool, showOnHover, true) + CFG_PROPERTY(int, dragThreshold, 50) CFG_PROPERTY(int, mediaUpdateInterval, 2000) CONFIG_SUBOBJECT(Performance, performance) CFG_PROPERTY(int, resourceUpdateInterval, 1000) diff --git a/Plugins/ZShell/Config/enums.hpp b/Plugins/ZShell/Config/enums.hpp new file mode 100644 index 0000000..9ba61a0 --- /dev/null +++ b/Plugins/ZShell/Config/enums.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace ZShell::config { + +#define ENUM(Name, ...) \ + namespace Name { \ + \ + Q_NAMESPACE \ + QML_ELEMENT \ + \ + enum Enum : quint8 { __VA_ARGS__ }; \ + Q_ENUM_NS(Enum) \ + }; + +ENUM(BarWorkspaceDisplay, Shapes, Text, Icons) +ENUM(BarWorkspaceCapitalisation, Preserve, Upper, Lower) +ENUM(LyricsBackend, Auto, Local, LRCLIB, NetEase) +ENUM(GpuType, Auto, Nvidia, Generic, None) +ENUM(NotifsFullscreen, On, Off) +ENUM(TemperatureUnit, Auto, Celsius, Fahrenheit, Kelvin) +ENUM(DataUnit, Binary, Decimal) +ENUM(ClockFormat, Auto, TwelveHour, TwentyFourHour) + +#undef ENUM + +} // namespace ZShell::config diff --git a/Plugins/ZShell/Config/general.hpp b/Plugins/ZShell/Config/general.hpp index 558ff82..6e46beb 100644 --- a/Plugins/ZShell/Config/general.hpp +++ b/Plugins/ZShell/Config/general.hpp @@ -1,6 +1,7 @@ #pragma once #include "configlist.hpp" #include "configobject.hpp" +#include #include namespace ZShell::config { @@ -118,6 +119,10 @@ class General : public ConfigObject { CFG_PROPERTY(QString, dateFormat, QStringLiteral("ddd d MMM - hh:mm:ss")) CFG_PROPERTY(bool, desktopIcons, true) CONFIG_SUBOBJECT(Idle, idle) + CFG_PROPERTY( + QString, + lyricsDir, + QStandardPaths::writableLocation(QStandardPaths::MusicLocation) + QStringLiteral("/Lyrics/")) CFG_PROPERTY(QString, logo, QString()) CFG_PROPERTY(bool, showOverFullscreen, true) CFG_PROPERTY( diff --git a/Plugins/ZShell/Config/services.hpp b/Plugins/ZShell/Config/services.hpp index 45d2c2e..00e0ea5 100644 --- a/Plugins/ZShell/Config/services.hpp +++ b/Plugins/ZShell/Config/services.hpp @@ -1,6 +1,7 @@ #pragma once #include "configlist.hpp" #include "configobject.hpp" +#include "enums.hpp" namespace ZShell::config { @@ -31,6 +32,7 @@ class Services : public ConfigObject { CFG_PROPERTY(bool, ddcutilService, true) CFG_PROPERTY(QString, defaultPlayer, QStringLiteral("Spotify")) CFG_PROPERTY(QString, gpuType, QStringLiteral("NVIDIA")) + CFG_PROPERTY(LyricsBackend::Enum, lyricsBackend, LyricsBackend::Auto) CFG_PROPERTY(qreal, maxVolume, 1) CFG_PROPERTY(qreal, minBrightness, 0.01) CONFIG_LIST( diff --git a/Plugins/ZShell/Internal/sparklineitem.cpp b/Plugins/ZShell/Internal/sparklineitem.cpp index c4f2314..7c0b2f2 100644 --- a/Plugins/ZShell/Internal/sparklineitem.cpp +++ b/Plugins/ZShell/Internal/sparklineitem.cpp @@ -1,9 +1,11 @@ #include "sparklineitem.hpp" #include +#include #include #include #include +#include namespace ZShell::internal { @@ -35,9 +37,19 @@ void SparklineItem::drawLine( const int len = buffer->count(); if (len < 2 || m_maxValue <= 0.0) return; - const qreal stepX = w / static_cast(m_historyLength - 1); - const qreal startX = - w - (len - 1) * stepX - stepX * m_slideProgress + stepX; + qreal stepX = 0.0; + + if (m_sliding) + stepX = w / static_cast(m_historyLength - 1); + else + stepX = w / static_cast(len - 1); + + qreal startX = 0.0; + + if (m_sliding) + startX = w - (len - 1) * stepX - stepX * m_slideProgress + stepX; + else + startX = 0; const qreal strokePad = qCeil(m_lineWidth / 2); const qreal curvePad = 3.0; @@ -52,24 +64,75 @@ void SparklineItem::drawLine( points.reserve(len); for (int i = 0; i < len; ++i) { + qreal value = 0.0; + + if (m_smoothing > 0.0) { + const int radius = qRound(m_smoothing); + qreal sum = 0.0; + int count = 0; + + for (int j = -radius; j <= radius; ++j) { + const int index = i + j; + + if (index < 0 || index >= len) continue; + + sum += buffer->at(index); + count++; + } + + value = sum / static_cast(count); + } else { + value = buffer->at(i); + } + + value = qBound(0.0, value, m_maxValue); + const qreal x = startX + i * stepX; - const qreal value = qBound(0.0, buffer->at(i), m_maxValue); const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH; + points.append(QPointF(x, y)); } QPainterPath linePath; linePath.moveTo(points[0]); - for (int i = 0; i < points.size() - 1; ++i) { - const QPointF& p0 = points[i]; - const QPointF& p1 = points[i + 1]; + if (m_curveSmoothing && points.size() > 2) { + // 1.0 = standard uniform Catmull-Rom. Lower (e.g. 0.5) = tighter, less overshoot. + const qreal tension = 1.0; + const qreal k = + tension / 6.0; // Catmull-Rom -> Bezier factor (1/6 for uniform) - const qreal ctrlX = (p0.x() + p1.x()) * 0.5; - const QPointF c1(ctrlX, p0.y()); - const QPointF c2(ctrlX, p1.y()); + const int n = points.size(); - linePath.cubicTo(c1, c2, p1); + for (int i = 0; i < n - 1; ++i) { + // Duplicate endpoints so the first/last segments are well-defined + const QPointF& p0 = points[qMax(i - 1, 0)]; + const QPointF& p1 = points[i]; + const QPointF& p2 = points[i + 1]; + const QPointF& p3 = points[qMin(i + 2, n - 1)]; + + QPointF c1 = p1 + (p2 - p0) * k; + QPointF c2 = p2 - (p3 - p1) * k; + + // Keep control points inside the vertical range of the segment's + // endpoints so we never overshoot (no dips below zero, no + // bulges above a peak). Remember: smaller y = higher value. + const qreal segMinY = qMin(p1.y(), p2.y()); + const qreal segMaxY = qMax(p1.y(), p2.y()); + c1.setY(qBound(segMinY, c1.y(), segMaxY)); + c2.setY(qBound(segMinY, c2.y(), segMaxY)); + + linePath.cubicTo(c1, c2, p2); + } + } else { + // Existing horizontal-tangent (smoothstep-style) curve + for (int i = 0; i < points.size() - 1; ++i) { + const QPointF& p0 = points[i]; + const QPointF& p1 = points[i + 1]; + + const qreal ctrlX = (p0.x() + p1.x()) * 0.5; + linePath.cubicTo(QPointF(ctrlX, p0.y()), QPointF(ctrlX, p1.y()), p1); + } } QPen pen(color, m_lineWidth); @@ -224,4 +287,36 @@ void SparklineItem::setLineWidth(qreal width) { update(); } +void SparklineItem::setSliding(bool sliding) { + if (m_sliding == sliding) return; + m_sliding = sliding; + emit slidingChanged(); +} + +bool SparklineItem::sliding() const { + return m_sliding; +}; + +void SparklineItem::setSmoothing(qreal smoothing) { + smoothing = qMax(0.0, smoothing); + + if (qFuzzyCompare(m_smoothing, smoothing)) return; + m_smoothing = smoothing; + emit smoothingChanged(); +} + +qreal SparklineItem::smoothing() const { + return m_smoothing; +}; + +void SparklineItem::setCurveSmoothing(bool sliding) { + if (m_curveSmoothing == sliding) return; + m_curveSmoothing = sliding; + emit curveSmoothingChanged(); +} + +bool SparklineItem::curveSmoothing() const { + return m_curveSmoothing; +}; + } // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/sparklineitem.hpp b/Plugins/ZShell/Internal/sparklineitem.hpp index 3cea1ed..a79be15 100644 --- a/Plugins/ZShell/Internal/sparklineitem.hpp +++ b/Plugins/ZShell/Internal/sparklineitem.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include "circularbuffer.hpp" @@ -40,6 +41,13 @@ class SparklineItem : public QQuickPaintedItem { Q_PROPERTY( qreal lineWidth READ lineWidth WRITE setLineWidth NOTIFY lineWidthChanged) + Q_PROPERTY(bool sliding READ sliding WRITE setSliding NOTIFY slidingChanged) + Q_PROPERTY( + qreal smoothing READ smoothing WRITE setSmoothing NOTIFY + smoothingChanged) + Q_PROPERTY( + bool curveSmoothing READ curveSmoothing WRITE setCurveSmoothing NOTIFY + curveSmoothingChanged) public: explicit SparklineItem(QQuickItem* parent = nullptr); @@ -76,6 +84,15 @@ class SparklineItem : public QQuickPaintedItem { [[nodiscard]] qreal lineWidth() const; void setLineWidth(qreal width); + [[nodiscard]] bool sliding() const; + void setSliding(bool sliding); + + [[nodiscard]] qreal smoothing() const; + void setSmoothing(qreal smoothing); + + [[nodiscard]] bool curveSmoothing() const; + void setCurveSmoothing(bool curveSmoothing); + signals: void line1Changed(); void line2Changed(); @@ -87,6 +104,9 @@ class SparklineItem : public QQuickPaintedItem { void slideProgressChanged(); void historyLengthChanged(); void lineWidthChanged(); + void slidingChanged(); + void smoothingChanged(); + void curveSmoothingChanged(); private: void drawLine( @@ -106,6 +126,9 @@ class SparklineItem : public QQuickPaintedItem { qreal m_slideProgress = 0.0; int m_historyLength = 30; qreal m_lineWidth = 2.0; + bool m_sliding = true; + qreal m_smoothing = 0.0; + bool m_curveSmoothing = false; }; } // namespace ZShell::internal diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index 2dfe707..4dcae3c 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -18,6 +18,8 @@ qml_module(ZShell-services memory.hpp memory.cpp diskinfo.hpp diskinfo.cpp storage.hpp storage.cpp + lyriccandidate.hpp lyriccandidate.cpp + lyrics.hpp lyrics.cpp LIBRARIES Qt6::Core Qt6::Qml @@ -25,4 +27,7 @@ qml_module(ZShell-services PkgConfig::Aubio PkgConfig::Cava Sensors::Sensors - ) + ZShell-config + DEPENDENCIES + ZShell.Config +) diff --git a/Plugins/ZShell/Services/lyriccandidate.cpp b/Plugins/ZShell/Services/lyriccandidate.cpp new file mode 100644 index 0000000..8678769 --- /dev/null +++ b/Plugins/ZShell/Services/lyriccandidate.cpp @@ -0,0 +1,55 @@ +#include "lyriccandidate.hpp" + +namespace ZShell::services { + +LyricCandidate::LyricCandidate( + LyricsBackend backend, + QString id, + QString title, + QString artist, + QString album, + qreal duration) + : m_backend(backend) + , m_id(std::move(id)) + , m_title(std::move(title)) + , m_artist(std::move(artist)) + , m_album(std::move(album)) + , m_duration(duration) {} + +LyricsBackend LyricCandidate::backend() const { + return m_backend; +} + +QString LyricCandidate::id() const { + return m_id; +} + +QString LyricCandidate::title() const { + return m_title; +} + +QString LyricCandidate::artist() const { + return m_artist; +} + +QString LyricCandidate::album() const { + return m_album; +} + +qreal LyricCandidate::duration() const { + return m_duration; +} + +bool LyricCandidate::isValid() const { + return !m_id.isEmpty(); +} + +bool LyricCandidate::operator==(const LyricCandidate& o) const noexcept { + return m_backend == o.m_backend && m_id == o.m_id; +} + +bool LyricCandidate::operator!=(const LyricCandidate& o) const noexcept { + return !(*this == o); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/lyriccandidate.hpp b/Plugins/ZShell/Services/lyriccandidate.hpp new file mode 100644 index 0000000..f3f0700 --- /dev/null +++ b/Plugins/ZShell/Services/lyriccandidate.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +#include "config/enums.hpp" + +namespace ZShell::services { + +using LyricsBackend = config::LyricsBackend::Enum; + +class LyricCandidate { + Q_GADGET + QML_VALUE_TYPE(lyricCandidate) + + Q_PROPERTY(ZShell::config::LyricsBackend::Enum backend READ backend) + Q_PROPERTY(QString id READ id) + Q_PROPERTY(QString title READ title) + Q_PROPERTY(QString artist READ artist) + Q_PROPERTY(QString album READ album) + Q_PROPERTY(qreal duration READ duration) + + public: + LyricCandidate() = default; + LyricCandidate( + LyricsBackend backend, + QString id, + QString title, + QString artist, + QString album = {}, + qreal duration = 0.0); + + [[nodiscard]] LyricsBackend backend() const; + [[nodiscard]] QString id() const; + [[nodiscard]] QString title() const; + [[nodiscard]] QString artist() const; + [[nodiscard]] QString album() const; + [[nodiscard]] qreal duration() const; + + [[nodiscard]] bool isValid() const; + bool operator==(const LyricCandidate& o) const noexcept; + bool operator!=(const LyricCandidate& o) const noexcept; + + private: + LyricsBackend m_backend = LyricsBackend::Auto; + QString m_id; + QString m_title; + QString m_artist; + QString m_album; + qreal m_duration = 0.0; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/lyrics.cpp b/Plugins/ZShell/Services/lyrics.cpp new file mode 100644 index 0000000..6a85e75 --- /dev/null +++ b/Plugins/ZShell/Services/lyrics.cpp @@ -0,0 +1,1178 @@ +#include "lyrics.hpp" + +#include +#include +#include +#include +#include +#include + +#include "config/config.hpp" +#include "config/general.hpp" +#include "config/services.hpp" + +namespace { + +Q_LOGGING_CATEGORY(lcLyrics, "ZShell.lyrics", QtInfoMsg) + +} // namespace + +namespace ZShell::services { + +using Qt::StringLiterals::operator""_s; +using Qt::StringLiterals::operator""_ba; + +namespace { + +constexpr int k_loadDebounceMs = 50; +constexpr qreal k_indexFudge = 0.1; + +[[nodiscard]] const QHash& netEaseHeaders() { + static const QHash k_h = { + {"User-Agent"_ba, + "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"_ba}, + {"Referer"_ba, "https://music.163.com/"_ba}, + }; + return k_h; +} + +[[nodiscard]] const QHash& lrclibHeaders() { + static const QHash k_h = { + {"User-Agent"_ba, + "ZShell-shell (https://github.com/ZShell-dots/shell)"_ba}, + }; + return k_h; +} + +[[nodiscard]] QString joinArtists(const QString& s) { + return s.trimmed(); +} + +[[nodiscard]] QString sanitizeFilenamePart(const QString& s) { + QString out; + out.reserve(s.size()); + for (const QChar c : s) { + if (c == u'/' || c == u'\0') { + out.append(u'_'); + } else { + out.append(c); + } + } + return out; +} + +[[nodiscard]] bool containsCi(const QString& haystack, const QString& needle) { + return haystack.contains(needle, Qt::CaseInsensitive); +} + +} // namespace + +Lyrics::Lyrics(QObject* parent) + : QObject(parent) + , m_nam(new QNetworkAccessManager(this)) + , m_loadDebounce(new QTimer(this)) { + m_loadDebounce->setSingleShot(true); + m_loadDebounce->setInterval(k_loadDebounceMs); + QObject::connect(m_loadDebounce, &QTimer::timeout, this, &Lyrics::doLoad); + + const auto* cfg = config::Config::instance(); + const auto* svcCfg = cfg->services(); + const auto* genCfg = cfg->general(); + + m_preferredBackend = svcCfg->lyricsBackend(); + + QObject::connect( + svcCfg, + &config::Services::lyricsBackendChanged, + this, + &Lyrics::onPreferredBackendConfigChanged); + QObject::connect( + genCfg, + &config::General::lyricsDirChanged, + this, + &Lyrics::onLyricsDirChanged); + + loadLyricsMap(); +} + +QStringList Lyrics::lyrics() const { + return m_lyrics; +} + +LyricsBackend Lyrics::backend() const { + return m_backend; +} + +LyricsBackend Lyrics::preferredBackend() const { + return m_preferredBackend; +} + +void Lyrics::setPreferredBackend(LyricsBackend value) { + if (m_preferredBackend == value) { + return; + } + m_preferredBackend = value; + emit preferredBackendChanged(); + + config::Config::instance()->services()->set_lyricsBackend(value); + + scheduleLoad(); +} + +QList Lyrics::lyricCandidates() const { + return m_candidates; +} + +LyricCandidate Lyrics::selectedCandidate() const { + return m_selected; +} + +void Lyrics::setSelectedCandidate(const LyricCandidate& value) { + if (m_selected == value) { + return; + } + m_selected = value; + emit selectedCandidateChanged(); + + if (!value.isValid()) { + return; + } + + const auto b = value.backend(); + setBackend(b); + setLoading(true); + + cancelInFlight(); + const int reqId = newRequestId(); + + if (b == LyricsBackend::LRCLIB || b == LyricsBackend::NetEase) { + const QString cached = readCachedLrc(b, value.id()); + if (!cached.isEmpty()) { + const auto lines = parseLrc(cached); + if (!lines.isEmpty()) { + setLines(lines, b); + setLoading(false); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + return; + } + } + } + + if (b == LyricsBackend::LRCLIB) { + fetchLrclibById(value.id(), reqId); + } else if (b == LyricsBackend::NetEase) { + fetchNetEaseLyricsById(value.id(), reqId); + } else if (b == LyricsBackend::Local) { + // For local, the id is the file path. Read directly. + QFile f(value.id()); + if (f.open(QIODevice::ReadOnly)) { + const QString text = QString::fromUtf8(f.readAll()); + setLines(parseLrc(text), LyricsBackend::Local); + setLoading(false); + } else { + qCWarning(lcLyrics) + << "selectedCandidate: cannot open local file" << value.id(); + setLoading(false); + } + } + + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } +} + +bool Lyrics::loading() const { + return m_loading; +} + +bool Lyrics::hasLyrics() const { + return m_hasLyrics; +} + +qreal Lyrics::offset() const { + return m_offset; +} + +void Lyrics::setOffset(qreal value) { + if (qFuzzyCompare(m_offset, value)) { + return; + } + m_offset = value; + emit offsetChanged(); + + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } +} + +QString Lyrics::trackArtist() const { + return m_artist; +} + +QString Lyrics::trackTitle() const { + return m_title; +} + +int Lyrics::indexForTime(qreal time) const { + if (m_lines.isEmpty()) { + return -1; + } + const qreal target = time - m_offset + k_indexFudge; + qsizetype lo = 0; + qsizetype hi = m_lines.size(); + while (lo < hi) { + const qsizetype mid = lo + (hi - lo) / 2; + if (m_lines.at(mid).time <= target) { + lo = mid + 1; + } else { + hi = mid; + } + } + return static_cast(lo - 1); +} + +qreal Lyrics::timeForIndex(int index) const { + if (index < 0 || index >= m_lines.size()) { + return -1.0; + } + return m_lines.at(index).time + m_offset; +} + +void Lyrics::setTrack( + const QString& artist, + const QString& title, + const QString& album, + qreal duration) { + const QString a = artist.trimmed(); + const QString t = title.trimmed(); + + if (a == m_artist && t == m_title && album == m_album && + qFuzzyCompare(duration + 1.0, m_duration + 1.0)) { + return; + } + + m_artist = a; + m_title = t; + m_album = album; + m_duration = duration; + emit trackChanged(); + + scheduleLoad(); +} + +void Lyrics::clearTrack() { + cancelInFlight(); + m_artist.clear(); + m_title.clear(); + m_album.clear(); + m_duration = 0.0; + emit trackChanged(); + + clearCandidates(); + clearLines(); + setLoading(false); +} + +void Lyrics::refresh() { + scheduleLoad(); +} + +void Lyrics::setBackend(LyricsBackend value) { + if (m_backend == value) { + return; + } + m_backend = value; + emit backendChanged(); +} + +void Lyrics::setLoading(bool value) { + if (m_loading == value) { + return; + } + m_loading = value; + emit loadingChanged(); +} + +void Lyrics::setLines(QVector lines, LyricsBackend source) { + std::ranges::sort(lines, [](const LyricLine& a, const LyricLine& b) { + return a.time < b.time; + }); + + m_lines = std::move(lines); + QStringList list; + list.reserve(m_lines.size()); + for (const auto& l : std::as_const(m_lines)) { + list.append(l.text); + } + m_lyrics = std::move(list); + + setBackend(source); + emit lyricsChanged(); + + const auto hasLyrics = !m_lines.isEmpty(); + if (hasLyrics != m_hasLyrics) { + m_hasLyrics = hasLyrics; + emit hasLyricsChanged(); + } +} + +void Lyrics::clearLines() { + if (!m_hasLyrics) { + return; + } + + // Doesn't actually clear lines, set a flag instead so anims can run + m_hasLyrics = false; + emit hasLyricsChanged(); +} + +void Lyrics::appendCandidates(const QList& add) { + if (add.isEmpty()) { + return; + } + bool changed = false; + for (const auto& c : add) { + if (!m_candidates.contains(c)) { + m_candidates.append(c); + changed = true; + } + } + if (changed) { + emit lyricCandidatesChanged(); + } +} + +void Lyrics::clearCandidates() { + if (m_candidates.isEmpty()) { + return; + } + m_candidates.clear(); + emit lyricCandidatesChanged(); +} + +void Lyrics::scheduleLoad() { + m_loadDebounce->start(); +} + +int Lyrics::newRequestId() { + return ++m_currentRequestId; +} + +void Lyrics::cancelInFlight() { + for (auto it = m_pendingReplies.begin(); it != m_pendingReplies.end(); + ++it) { + for (auto& ptr : it.value()) { + if (auto* reply = ptr.data()) { + reply->abort(); + reply->deleteLater(); + } + } + } + m_pendingReplies.clear(); +} + +void Lyrics::trackReply(int reqId, QNetworkReply* reply) { + if (!reply) { + return; + } + m_pendingReplies[reqId].append(QPointer(reply)); +} + +void Lyrics::doLoad() { + if (m_artist.isEmpty() && m_title.isEmpty()) { + clearLines(); + clearCandidates(); + setLoading(false); + return; + } + + cancelInFlight(); + const int reqId = newRequestId(); + + setLoading(true); + clearLines(); + clearCandidates(); + + // Restore per-track prefs (offset, last-selected backend/id) + m_settingFromPrefs = true; + const QJsonObject saved = m_lyricsMap.value(trackKey()).toObject(); + setOffset(saved.value(u"offset"_s).toDouble(0.0)); + LyricCandidate restored; + const QString savedBackendKey = saved.value(u"backend"_s).toString(); + const QString savedId = saved.value(u"id"_s).toString(); + if (!savedBackendKey.isEmpty() && !savedId.isEmpty()) { + restored = LyricCandidate( + backendFromKey(savedBackendKey), + savedId, + m_title, + m_artist, + m_album, + m_duration); + } + m_settingFromPrefs = false; + + // Always populate online candidates for the picker, regardless of preferred backend + searchLrclibCandidates(reqId); + searchNetEaseCandidates(reqId); + + if (restored.isValid()) { + // Honor saved selection for this track + m_settingFromPrefs = true; + setSelectedCandidate(restored); + m_settingFromPrefs = false; + return; + } + + // Primary attempt by preferred backend + switch (m_preferredBackend) { + case LyricsBackend::Local: + tryLocal(reqId); + break; + case LyricsBackend::LRCLIB: + tryLrclib(reqId); + break; + case LyricsBackend::NetEase: + tryNetEase(reqId); + break; + case LyricsBackend::Auto: + default: + tryLocal(reqId); + break; + } +} + +void Lyrics::chainNext(LyricsBackend justFailed, int reqId) { + if (m_preferredBackend != LyricsBackend::Auto) { + // Non-auto modes don't chain + setLoading(false); + return; + } + switch (justFailed) { + case LyricsBackend::Local: + tryLrclib(reqId); + return; + case LyricsBackend::LRCLIB: + tryNetEase(reqId); + return; + case LyricsBackend::NetEase: + default: + setLoading(false); + return; + } +} + +void Lyrics::tryLocal(int reqId) { + if (reqId != m_currentRequestId) { + return; + } + + setBackend(LyricsBackend::Local); + + const QString dir = lyricsDir(); + if (dir.isEmpty()) { + chainNext(LyricsBackend::Local, reqId); + return; + } + + const QString direct = tryReadLocalLrc(dir, m_artist, m_title); + if (!direct.isEmpty()) { + QFile f(direct); + if (f.open(QIODevice::ReadOnly)) { + const QString text = QString::fromUtf8(f.readAll()); + const auto lines = parseLrc(text); + if (!lines.isEmpty()) { + setLines(lines, LyricsBackend::Local); + appendCandidates({LyricCandidate( + LyricsBackend::Local, + direct, + m_title, + m_artist, + m_album, + m_duration)}); + m_selected = LyricCandidate( + LyricsBackend::Local, + direct, + m_title, + m_artist, + m_album, + m_duration); + emit selectedCandidateChanged(); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + setLoading(false); + return; + } + } + } + + const QString recursive = findLocalLrcRecursive(dir, m_artist, m_title); + if (!recursive.isEmpty()) { + QFile f(recursive); + if (f.open(QIODevice::ReadOnly)) { + const QString text = QString::fromUtf8(f.readAll()); + const auto lines = parseLrc(text); + if (!lines.isEmpty()) { + setLines(lines, LyricsBackend::Local); + appendCandidates({LyricCandidate( + LyricsBackend::Local, + recursive, + m_title, + m_artist, + m_album, + m_duration)}); + m_selected = LyricCandidate( + LyricsBackend::Local, + recursive, + m_title, + m_artist, + m_album, + m_duration); + emit selectedCandidateChanged(); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + setLoading(false); + return; + } + } + } + + qCDebug(lcLyrics) << "no local lrc for" << m_artist << "-" << m_title; + chainNext(LyricsBackend::Local, reqId); +} + +void Lyrics::tryLrclib(int reqId) { + if (reqId != m_currentRequestId) { + return; + } + + setBackend(LyricsBackend::LRCLIB); + + QUrl url(u"https://lrclib.net/api/get"_s); + QUrlQuery q; + q.addQueryItem(u"track_name"_s, m_title); + q.addQueryItem(u"artist_name"_s, m_artist); + if (!m_album.isEmpty()) { + q.addQueryItem(u"album_name"_s, m_album); + } + + constexpr qreal k_maxDurationSecs = std::numeric_limits::max(); + if (m_duration > 0 && qIsFinite(m_duration) && + m_duration < k_maxDurationSecs) { + q.addQueryItem(u"duration"_s, QString::number(qRound(m_duration))); + } + url.setQuery(q); + + auto* reply = getJson(url, lrclibHeaders()); + trackReply(reqId, reply); + + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) << "lrclib /get error:" << reply->errorString(); + chainNext(LyricsBackend::LRCLIB, reqId); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonObject obj = doc.object(); + const QString synced = obj.value(u"syncedLyrics"_s).toString(); + const qint64 id = static_cast(obj.value(u"id"_s).toDouble()); + + if (synced.isEmpty()) { + qCDebug(lcLyrics) + << "lrclib: no syncedLyrics for" << m_artist << "-" << m_title; + chainNext(LyricsBackend::LRCLIB, reqId); + return; + } + + const auto lines = parseLrc(synced); + if (lines.isEmpty()) { + chainNext(LyricsBackend::LRCLIB, reqId); + return; + } + + writeCachedLrc(LyricsBackend::LRCLIB, QString::number(id), synced); + setLines(lines, LyricsBackend::LRCLIB); + const LyricCandidate cand( + LyricsBackend::LRCLIB, + QString::number(id), + obj.value(u"trackName"_s).toString(), + obj.value(u"artistName"_s).toString(), + obj.value(u"albumName"_s).toString(), + obj.value(u"duration"_s).toDouble()); + appendCandidates({cand}); + m_selected = cand; + emit selectedCandidateChanged(); + if (!m_settingFromPrefs) { + persistTrackPrefs(); + } + setLoading(false); + }); +} + +void Lyrics::tryNetEase(int reqId) { + if (reqId != m_currentRequestId) { + return; + } + + setBackend(LyricsBackend::NetEase); + + // Reset cookies (LyricsBackend::NetEase rejects requests with stale cookies sometimes) + m_nam->setCookieJar(new QNetworkCookieJar(m_nam)); + + QUrl url(u"https://music.163.com/api/search/get"_s); + QUrlQuery q; + q.addQueryItem(u"s"_s, u"%1 %2"_s.arg(m_title, m_artist)); + q.addQueryItem(u"type"_s, u"1"_s); + q.addQueryItem(u"limit"_s, u"5"_s); + url.setQuery(q); + + auto* reply = getJson(url, netEaseHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) + << "netease /search error:" << reply->errorString(); + chainNext(LyricsBackend::NetEase, reqId); + return; + } + + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonArray songs = doc.object() + .value(u"result"_s) + .toObject() + .value(u"songs"_s) + .toArray(); + + // Find best match by artist substring + qint64 bestId = -1; + for (const auto& v : songs) { + const QJsonObject s = v.toObject(); + const QJsonArray artists = s.value(u"artists"_s).toArray(); + if (artists.isEmpty()) { + continue; + } + const QString sArtist = + artists.first().toObject().value(u"name"_s).toString(); + if (containsCi(m_artist, sArtist) || + containsCi(sArtist, m_artist)) { + bestId = static_cast(s.value(u"id"_s).toDouble()); + break; + } + } + + if (bestId < 0) { + qCDebug(lcLyrics) << "netease: no artist match for" << m_artist + << "-" << m_title; + chainNext(LyricsBackend::NetEase, reqId); + return; + } + + fetchNetEaseLyricsById(QString::number(bestId), reqId); + }); +} + +void Lyrics::searchLrclibCandidates(int reqId) { + QUrl url(u"https://lrclib.net/api/search"_s); + QUrlQuery q; + q.addQueryItem(u"track_name"_s, m_title); + q.addQueryItem(u"artist_name"_s, m_artist); + url.setQuery(q); + + auto* reply = getJson(url, lrclibHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) + << "lrclib /search error:" << reply->errorString(); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonArray arr = doc.array(); + + QList add; + add.reserve(arr.size()); + for (const auto& v : arr) { + const QJsonObject o = v.toObject(); + if (o.value(u"syncedLyrics"_s).isNull() && + o.value(u"plainLyrics"_s).isNull()) { + continue; + } + add.append(LyricCandidate( + LyricsBackend::LRCLIB, + QString::number( + static_cast(o.value(u"id"_s).toDouble())), + o.value(u"trackName"_s).toString(), + o.value(u"artistName"_s).toString(), + o.value(u"albumName"_s).toString(), + o.value(u"duration"_s).toDouble())); + } + appendCandidates(add); + }); +} + +void Lyrics::searchNetEaseCandidates(int reqId) { + m_nam->setCookieJar(new QNetworkCookieJar(m_nam)); + + QUrl url(u"https://music.163.com/api/search/get"_s); + QUrlQuery q; + q.addQueryItem(u"s"_s, u"%1 %2"_s.arg(m_title, m_artist)); + q.addQueryItem(u"type"_s, u"1"_s); + q.addQueryItem(u"limit"_s, u"5"_s); + url.setQuery(q); + + auto* reply = getJson(url, netEaseHeaders()); + trackReply(reqId, reply); + + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, reqId] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCDebug(lcLyrics) + << "netease candidates error:" << reply->errorString(); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QJsonArray songs = doc.object() + .value(u"result"_s) + .toObject() + .value(u"songs"_s) + .toArray(); + + QList add; + add.reserve(songs.size()); + for (const auto& v : songs) { + const QJsonObject s = v.toObject(); + QStringList artistNames; + const QJsonArray artists = s.value(u"artists"_s).toArray(); + artistNames.reserve(artists.size()); + for (const auto& a : artists) { + artistNames.append(a.toObject().value(u"name"_s).toString()); + } + add.append(LyricCandidate( + LyricsBackend::NetEase, + QString::number( + static_cast(s.value(u"id"_s).toDouble())), + s.value(u"name"_s).toString(), + artistNames.join(u", "_s))); + } + appendCandidates(add); + }); +} + +void Lyrics::fetchLrclibById(const QString& id, int reqId) { + const QUrl url(u"https://lrclib.net/api/get/"_s + id); + auto* reply = getJson(url, lrclibHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId, id] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCWarning(lcLyrics) + << "lrclib /get/{id} error:" << reply->errorString(); + setLoading(false); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QString synced = + doc.object().value(u"syncedLyrics"_s).toString(); + if (synced.isEmpty()) { + qCDebug(lcLyrics) << "lrclib /get/{id}: no syncedLyrics"; + setLoading(false); + return; + } + writeCachedLrc(LyricsBackend::LRCLIB, id, synced); + setLines(parseLrc(synced), LyricsBackend::LRCLIB); + setLoading(false); + }); +} + +void Lyrics::fetchNetEaseLyricsById(const QString& id, int reqId) { + QUrl url(u"https://music.163.com/api/song/lyric"_s); + QUrlQuery q; + q.addQueryItem(u"id"_s, id); + q.addQueryItem(u"lv"_s, u"1"_s); + q.addQueryItem(u"kv"_s, u"1"_s); + q.addQueryItem(u"tv"_s, u"-1"_s); + url.setQuery(q); + + auto* reply = getJson(url, netEaseHeaders()); + trackReply(reqId, reply); + + QObject::connect( + reply, &QNetworkReply::finished, this, [this, reply, reqId, id] { + reply->deleteLater(); + if (reqId != m_currentRequestId) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + qCWarning(lcLyrics) + << "netease /lyric error:" << reply->errorString(); + setLoading(false); + return; + } + const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); + const QString lrc = doc.object() + .value(u"lrc"_s) + .toObject() + .value(u"lyric"_s) + .toString(); + if (lrc.isEmpty()) { + qCDebug(lcLyrics) << "netease /lyric: empty for id" << id; + setLoading(false); + return; + } + writeCachedLrc(LyricsBackend::NetEase, id, lrc); + setLines(parseLrc(lrc), LyricsBackend::NetEase); + setLoading(false); + }); +} + +QNetworkReply* Lyrics::getJson( + const QUrl& url, const QHash& headers) { + QNetworkRequest req(url); + req.setAttribute( + QNetworkRequest::CacheLoadControlAttribute, + QNetworkRequest::AlwaysNetwork); + req.setRawHeader("Cache-Control"_ba, "no-cache, no-store"_ba); + req.setRawHeader("Pragma"_ba, "no-cache"_ba); + req.setRawHeader("Connection"_ba, "close"_ba); + req.setRawHeader("Accept"_ba, "application/json"_ba); + for (auto it = headers.constBegin(); it != headers.constEnd(); ++it) { + req.setRawHeader(it.key(), it.value()); + } + return m_nam->get(req); +} + +void Lyrics::onPreferredBackendConfigChanged() { + const LyricsBackend desired = + config::Config::instance()->services()->lyricsBackend(); + if (desired == m_preferredBackend) { + return; + } + m_preferredBackend = desired; + emit preferredBackendChanged(); + scheduleLoad(); +} + +void Lyrics::onLyricsDirChanged() { + scheduleLoad(); +} + +void Lyrics::loadLyricsMap() { + m_lyricsMap = {}; + m_lyricsMapLoaded = false; + + QFile f(lyricsMapPath()); + if (!f.open(QIODevice::ReadOnly)) { + m_lyricsMapLoaded = true; + return; + } + const QByteArray bytes = f.readAll(); + f.close(); + + QJsonParseError err{}; + const QJsonDocument doc = QJsonDocument::fromJson(bytes, &err); + if (err.error != QJsonParseError::NoError) { + qCWarning(lcLyrics) + << "lyrics_map.json parse error:" << err.errorString(); + m_lyricsMapLoaded = true; + return; + } + m_lyricsMap = doc.object(); + m_lyricsMapLoaded = true; +} + +void Lyrics::persistTrackPrefs() { + if (!m_lyricsMapLoaded || trackKey().isEmpty()) { + return; + } + const QString key = trackKey(); + QJsonObject entry = m_lyricsMap.value(key).toObject(); + entry.insert(u"offset"_s, m_offset); + if (m_selected.isValid()) { + entry.insert(u"backend"_s, backendKey(m_selected.backend())); + entry.insert(u"id"_s, m_selected.id()); + } + m_lyricsMap.insert(key, entry); + + QDir().mkpath(stateDir()); + + QSaveFile out(lyricsMapPath()); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + qCWarning(lcLyrics) << "cannot open" << lyricsMapPath() + << "for write:" << out.errorString(); + return; + } + const QByteArray bytes = + QJsonDocument(m_lyricsMap).toJson(QJsonDocument::Compact); + if (out.write(bytes) != bytes.size()) { + qCWarning(lcLyrics) << "short write to" << lyricsMapPath(); + out.cancelWriting(); + return; + } + if (!out.commit()) { + qCWarning(lcLyrics) << "commit failed for" << lyricsMapPath() << ":" + << out.errorString(); + } +} + +QString Lyrics::lyricsDir() { + QString dir = config::Config::instance()->general()->lyricsDir(); + if (dir.isEmpty()) { + return {}; + } + if (dir == u"~"_s) { + dir = QDir::homePath(); + } else if (dir.startsWith(u"~/"_s)) { + dir.replace(0, 1, QDir::homePath()); + } + while (dir.endsWith(u'/') && dir.size() > 1) { + dir.chop(1); + } + return dir; +} + +QString Lyrics::lyricsMapPath() { + return stateDir() + u"/lyrics_map.json"_s; +} + +QString Lyrics::trackKey() const { + if (m_artist.isEmpty() && m_title.isEmpty()) { + return {}; + } + return u"%1 - %2"_s.arg(joinArtists(m_artist), m_title); +} + +QString Lyrics::backendKey(LyricsBackend value) { + switch (value) { + case LyricsBackend::Local: + return u"Local"_s; + case LyricsBackend::LRCLIB: + return u"LRCLIB"_s; + case LyricsBackend::NetEase: + return u"NetEase"_s; + case LyricsBackend::Auto: + default: + return u"Auto"_s; + } +} + +LyricsBackend Lyrics::backendFromKey(const QString& key) { + if (key.compare(u"Local"_s, Qt::CaseInsensitive) == 0) { + return LyricsBackend::Local; + } + if (key.compare(u"LRCLIB"_s, Qt::CaseInsensitive) == 0) { + return LyricsBackend::LRCLIB; + } + if (key.compare(u"NetEase"_s, Qt::CaseInsensitive) == 0) { + return LyricsBackend::NetEase; + } + return LyricsBackend::Auto; +} + +const QString& Lyrics::stateDir() { + static const QString k_dir = [] { + QString state = qEnvironmentVariable("XDG_STATE_HOME"); + if (state.isEmpty()) { + state = QDir::homePath() + u"/.local/state"_s; + } + return state + u"/ZShell/lyrics"_s; + }(); + return k_dir; +} + +const QString& Lyrics::cacheDir() { + static const QString k_dir = [] { + QString cache = qEnvironmentVariable("XDG_CACHE_HOME"); + if (cache.isEmpty()) { + cache = QDir::homePath() + u"/.cache"_s; + } + return cache + u"/ZShell/lyrics"_s; + }(); + return k_dir; +} + +QString Lyrics::cachePathFor(LyricsBackend backend, const QString& id) { + if (id.isEmpty() || backend == LyricsBackend::Auto || + backend == LyricsBackend::Local) { + return {}; + } + return u"%1/%2/%3.lrc"_s.arg( + cacheDir(), backendKey(backend), sanitizeFilenamePart(id)); +} + +QString Lyrics::readCachedLrc(LyricsBackend backend, const QString& id) { + const QString path = cachePathFor(backend, id); + if (path.isEmpty()) { + return {}; + } + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) { + return {}; + } + return QString::fromUtf8(f.readAll()); +} + +void Lyrics::writeCachedLrc( + LyricsBackend backend, const QString& id, const QString& text) { + if (text.isEmpty()) { + return; + } + const QString path = cachePathFor(backend, id); + if (path.isEmpty()) { + return; + } + QDir().mkpath(QFileInfo(path).absolutePath()); + + QSaveFile out(path); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + qCWarning(lcLyrics) + << "cannot open" << path << "for write:" << out.errorString(); + return; + } + const QByteArray bytes = text.toUtf8(); + if (out.write(bytes) != bytes.size()) { + qCWarning(lcLyrics) << "short write to" << path; + out.cancelWriting(); + return; + } + if (!out.commit()) { + qCWarning(lcLyrics) + << "commit failed for" << path << ":" << out.errorString(); + } +} + +QString Lyrics::tryReadLocalLrc( + const QString& dir, const QString& artist, const QString& title) { + if (artist.isEmpty() && title.isEmpty()) { + return {}; + } + const QString flat = u"%1/%2 - %3.lrc"_s.arg( + dir, sanitizeFilenamePart(artist), sanitizeFilenamePart(title)); + return QFile::exists(flat) ? flat : QString(); +} + +QString Lyrics::findLocalLrcRecursive( + const QString& dir, const QString& artist, const QString& title) { + if (dir.isEmpty()) { + return {}; + } + if (artist.isEmpty() && title.isEmpty()) { + return {}; + } + + QDirIterator it( + dir, + QStringList{u"*.lrc"_s}, + QDir::Files | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); + + while (it.hasNext()) { + const QString path = it.next(); + const QString name = it.fileName(); + if ((artist.isEmpty() || containsCi(name, artist)) && + (title.isEmpty() || containsCi(name, title))) { + return path; + } + } + return {}; +} + +QVector Lyrics::parseLrc(const QString& text) { + QVector result; + if (text.isEmpty()) { + return result; + } + + static const QRegularExpression k_timeRegex( + u"\\[(\\d+):(\\d+(?:\\.\\d+)?)\\]"_s); + static const QStringList k_creditKeywords = { + u"作词"_s, + u"作曲"_s, + u"编曲"_s, + u"制作"_s, + u"收录"_s, + u"演奏"_s, + u"词:"_s, + u"曲:"_s, + u"Lyricist"_s, + u"Composer"_s, + u"Arranger"_s, + u"Producer"_s, + u"Mixing"_s, + u"Mastering"_s, + }; + + const QStringList lines = text.split(u'\n'); + for (const QString& line : lines) { + QList matches; + auto it = k_timeRegex.globalMatch(line); + while (it.hasNext()) { + matches.append(it.next()); + } + if (matches.isEmpty()) { + continue; + } + + QString lyric = line; + lyric.replace(k_timeRegex, QString()); + lyric = lyric.trimmed(); + + const qreal firstTime = matches.first().captured(1).toInt() * 60.0 + + matches.first().captured(2).toDouble(); + + if (firstTime < 20.0) { + bool isCredit = false; + for (const QString& k : k_creditKeywords) { + if (lyric.contains(k, Qt::CaseInsensitive)) { + isCredit = true; + break; + } + } + if (isCredit && + (lyric.contains(u':') || lyric.contains(QChar(0xFF1A)) || + lyric.size() < 25)) { + continue; + } + } + + for (const auto& m : matches) { + const qreal t = + m.captured(1).toInt() * 60.0 + m.captured(2).toDouble(); + result.append(LyricLine{.time = t, .text = lyric}); + } + } + + std::ranges::sort(result, [](const LyricLine& a, const LyricLine& b) { + return a.time < b.time; + }); + + return result; +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/lyrics.hpp b/Plugins/ZShell/Services/lyrics.hpp new file mode 100644 index 0000000..864af88 --- /dev/null +++ b/Plugins/ZShell/Services/lyrics.hpp @@ -0,0 +1,162 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "lyriccandidate.hpp" + +namespace ZShell::services { + +struct LyricLine { + qreal time = 0.0; + QString text; +}; + +class Lyrics : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + + Q_PROPERTY(QStringList lyrics READ lyrics NOTIFY lyricsChanged) + Q_PROPERTY( + ZShell::config::LyricsBackend::Enum backend READ backend NOTIFY + backendChanged) + Q_PROPERTY( + ZShell::config::LyricsBackend::Enum preferredBackend READ + preferredBackend WRITE setPreferredBackend NOTIFY + preferredBackendChanged) + Q_PROPERTY( + QList lyricCandidates READ + lyricCandidates NOTIFY lyricCandidatesChanged) + Q_PROPERTY( + ZShell::services::LyricCandidate selectedCandidate READ selectedCandidate + WRITE setSelectedCandidate NOTIFY selectedCandidateChanged) + Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged) + Q_PROPERTY(bool hasLyrics READ hasLyrics NOTIFY hasLyricsChanged) + Q_PROPERTY(qreal offset READ offset WRITE setOffset NOTIFY offsetChanged) + Q_PROPERTY(QString trackArtist READ trackArtist NOTIFY trackChanged) + Q_PROPERTY(QString trackTitle READ trackTitle NOTIFY trackChanged) + + public: + explicit Lyrics(QObject* parent = nullptr); + + [[nodiscard]] QStringList lyrics() const; + [[nodiscard]] LyricsBackend backend() const; + [[nodiscard]] LyricsBackend preferredBackend() const; + void setPreferredBackend(LyricsBackend value); + [[nodiscard]] QList lyricCandidates() const; + [[nodiscard]] LyricCandidate selectedCandidate() const; + void setSelectedCandidate(const LyricCandidate& value); + [[nodiscard]] bool loading() const; + [[nodiscard]] bool hasLyrics() const; + [[nodiscard]] qreal offset() const; + void setOffset(qreal value); + [[nodiscard]] QString trackArtist() const; + [[nodiscard]] QString trackTitle() const; + + [[nodiscard]] Q_INVOKABLE int indexForTime(qreal time) const; + [[nodiscard]] Q_INVOKABLE qreal timeForIndex(int index) const; + Q_INVOKABLE void setTrack( + const QString& artist, + const QString& title, + const QString& album = {}, + qreal duration = 0.0); + Q_INVOKABLE void clearTrack(); + Q_INVOKABLE void refresh(); + + signals: + void lyricsChanged(); + void backendChanged(); + void preferredBackendChanged(); + void lyricCandidatesChanged(); + void selectedCandidateChanged(); + void loadingChanged(); + void hasLyricsChanged(); + void offsetChanged(); + void trackChanged(); + + private: + void setBackend(LyricsBackend value); + void setLoading(bool value); + void setLines(QVector lines, LyricsBackend source); + void clearLines(); + void appendCandidates(const QList& add); + void clearCandidates(); + + void scheduleLoad(); + void doLoad(); + void cancelInFlight(); + int newRequestId(); + + void tryLocal(int reqId); + void tryLrclib(int reqId); + void tryNetEase(int reqId); + void chainNext(LyricsBackend justFailed, int reqId); + + void searchLrclibCandidates(int reqId); + void searchNetEaseCandidates(int reqId); + + void fetchLrclibById(const QString& id, int reqId); + void fetchNetEaseLyricsById(const QString& id, int reqId); + + QNetworkReply* getJson( + const QUrl& url, const QHash& headers = {}); + void trackReply(int reqId, QNetworkReply* reply); + + void onPreferredBackendConfigChanged(); + void onLyricsDirChanged(); + + void loadLyricsMap(); + void persistTrackPrefs(); + + [[nodiscard]] static QString lyricsDir(); + [[nodiscard]] static QString lyricsMapPath(); + [[nodiscard]] QString trackKey() const; + [[nodiscard]] static QString backendKey(LyricsBackend value); + [[nodiscard]] static LyricsBackend backendFromKey(const QString& key); + + [[nodiscard]] static const QString& stateDir(); + [[nodiscard]] static const QString& cacheDir(); + [[nodiscard]] static QString cachePathFor( + LyricsBackend backend, const QString& id); + [[nodiscard]] static QString readCachedLrc( + LyricsBackend backend, const QString& id); + static void writeCachedLrc( + LyricsBackend backend, const QString& id, const QString& text); + + [[nodiscard]] static QVector parseLrc(const QString& text); + [[nodiscard]] static QString tryReadLocalLrc( + const QString& dir, const QString& artist, const QString& title); + [[nodiscard]] static QString findLocalLrcRecursive( + const QString& dir, const QString& artist, const QString& title); + + QNetworkAccessManager* m_nam; + QTimer* m_loadDebounce; + + QVector m_lines; + QStringList m_lyrics; + LyricsBackend m_backend = LyricsBackend::Auto; + LyricsBackend m_preferredBackend = LyricsBackend::Auto; + QList m_candidates; + LyricCandidate m_selected; + bool m_loading = false; + bool m_hasLyrics = false; + qreal m_offset = 0.0; + + QString m_artist; + QString m_title; + QString m_album; + qreal m_duration = 0.0; + + int m_currentRequestId = 0; + QHash>> m_pendingReplies; + + QJsonObject m_lyricsMap; + bool m_lyricsMapLoaded = false; + bool m_settingFromPrefs = false; +}; + +} // namespace ZShell::services diff --git a/Plugins/cmake/qml-module.cmake b/Plugins/cmake/qml-module.cmake index 82257e5..0b8ac52 100644 --- a/Plugins/cmake/qml-module.cmake +++ b/Plugins/cmake/qml-module.cmake @@ -1,7 +1,7 @@ message(STATUS "QML install dir: ${CMAKE_INSTALL_PREFIX}/${INSTALL_QMLDIR}") function(qml_module arg_TARGET) - cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;QML_FILES;QML_SINGLETONS;DEPENDENCIES;IMPORTS;OPTIONAL_IMPORTS;DEFAULT_IMPORTS;LIBRARIES") + cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI;INCLUDE_PREFIX" "SOURCES;QML_FILES;QML_SINGLETONS;DEPENDENCIES;IMPORTS;OPTIONAL_IMPORTS;DEFAULT_IMPORTS;LIBRARIES") set_source_files_properties(${arg_QML_SINGLETONS} PROPERTIES QT_QML_SINGLETON_TYPE TRUE) diff --git a/Services/ShellState.qml b/Services/ShellState.qml new file mode 100644 index 0000000..b806f4d --- /dev/null +++ b/Services/ShellState.qml @@ -0,0 +1,35 @@ +pragma Singleton + +import QtQuick +import Quickshell +import qs.Components +import qs.Helpers + +Singleton { + function anySidebarOpen(): bool { + return states.instances.some(s => s.sidebar); + } + + function forActive(): ScreenState { + const mon = Hypr.focusedMonitor; + for (const s of states.instances) + if (Hypr.monitorFor(s.modelData) === mon) + return s; + return null; + } + + function forScreen(screen: ShellScreen): ScreenState { + for (const s of states.instances) + if (s.modelData === screen) + return s; + return null; + } + + Variants { + id: states + + model: Quickshell.screens + + ScreenState {} + } +}