Author SHA1 Message Date
zach 11e7aac195 feat: better notification storage and image caching + blobs
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 11s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 12s
JS/TS / lint (pull_request) Successful in 10s
Python / static (pull_request) Failing after 27s
Rust / build (pull_request) Successful in 54s
Python / verify (pull_request) Failing after 1m26s
Rust / fmt (pull_request) Successful in 46s
Rust / clippy (pull_request) Successful in 1m6s
2026-11-02 19:00:43 +01:00
zach 789305286a Merge branch 'main' into feat/device-pixel-alignment
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 3m19s
C++ / clang-tidy (pull_request) Failing after 3m17s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 32s
Python / static (pull_request) Successful in 28s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Failing after 1m20s
Rust / fmt (pull_request) Successful in 33s
Rust / clippy (pull_request) Successful in 57s
2026-11-01 12:50:58 +01:00
zach f8101b2807 feat: sql database for notifications 2026-11-01 12:43:21 +01:00
zach 017d7073d2 Merge branch 'main' into feat/device-pixel-alignment
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 3m41s
C++ / clang-tidy (pull_request) Failing after 3m39s
JS/TS / fmt (pull_request) Successful in 13s
JS/TS / lint (pull_request) Successful in 11s
Python / static (pull_request) Successful in 34s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Failing after 1m29s
Rust / fmt (pull_request) Successful in 42s
Rust / clippy (pull_request) Successful in 1m2s
2026-11-01 01:57:30 +01:00
zach 8271e5d013 feat: testing new kinetic scroller for notifications in sidebar 2026-11-01 01:57:13 +01:00
zach 566b71d386 feat: drawing popout now clips strokes again + add redo + add undo and redo keybinds
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 12s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 9s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 28s
Python / verify (pull_request) Failing after 1m41s
Rust / build (pull_request) Successful in 1m15s
Rust / fmt (pull_request) Successful in 42s
Rust / clippy (pull_request) Successful in 1m6s
2026-10-30 14:16:19 +01:00
zach c5608dc9b9 fix: blobs now allow to be drawn in device pixels to stay sharp
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 9s
JS/TS / lint (pull_request) Successful in 25s
Python / static (pull_request) Successful in 34s
Rust / build (pull_request) Successful in 51s
Python / verify (pull_request) Failing after 1m25s
Rust / fmt (pull_request) Successful in 39s
Rust / clippy (pull_request) Successful in 59s
2026-10-30 01:52:33 +01:00
zach 78a9577209 Merge branch 'main' into feat/device-pixel-alignment
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 26s
Rust / build (pull_request) Successful in 54s
Python / verify (pull_request) Failing after 1m29s
Rust / fmt (pull_request) Successful in 36s
Rust / clippy (pull_request) Successful in 50s
2026-10-29 20:11:33 +01:00
zach 90184bff58 Merge pull request 'feat: video wallpapers' (#163) from feat/video-wallpaper into main
Reviewed-on: #163
2026-10-29 19:55:05 +01:00
zach abdde2450d fix: don't use lambda expression
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Failing after 1m27s
Rust / fmt (pull_request) Successful in 31s
Rust / clippy (pull_request) Successful in 58s
2026-10-29 19:50:02 +01:00
zach fd05b3d4c4 Merge branch 'main' into feat/video-wallpaper
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 13s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Failing after 37s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Failing after 1m28s
Rust / fmt (pull_request) Successful in 39s
Rust / clippy (pull_request) Successful in 1m5s
2026-10-29 19:03:10 +01:00
zach 067291f478 fix: lockscreen notifdock
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 14s
Python / static (pull_request) Failing after 27s
Rust / build (pull_request) Successful in 47s
Python / verify (pull_request) Failing after 1m23s
Rust / fmt (pull_request) Successful in 35s
Rust / clippy (pull_request) Successful in 53s
2026-10-29 18:43:50 +01:00
zach 395d1dad3d fix: remove duped property 2026-10-29 17:51:20 +01:00
zach 118b8f482a fix: fix sub-pixel layout arrangement for normalized resolutions
C++ / fmt (pull_request) Failing after 7s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 11s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Failing after 31s
Rust / build (pull_request) Successful in 49s
Python / verify (pull_request) Failing after 1m28s
Rust / fmt (pull_request) Successful in 30s
Rust / clippy (pull_request) Successful in 54s
2026-10-27 23:00:26 +01:00
zach b58b4fdc9c fix: remove more uses of cliprect and finally fix workspace's layer texture
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 9s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Failing after 33s
Rust / build (pull_request) Successful in 48s
Python / verify (pull_request) Failing after 1m24s
Rust / fmt (pull_request) Successful in 32s
Rust / clippy (pull_request) Successful in 57s
2026-10-27 15:15:57 +01:00
zach a56afd7fac fix: don't link eglDestroyImageKHR
C++ / fmt (pull_request) Failing after 7s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Failing after 27s
Rust / build (pull_request) Successful in 47s
Python / verify (pull_request) Failing after 1m24s
Rust / fmt (pull_request) Successful in 29s
Rust / clippy (pull_request) Successful in 45s
2026-10-27 02:30:55 +01:00
zach b0cb43f75c feat: add vaapi path for video wallpapers on generic gpus
C++ / fmt (pull_request) Failing after 6s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Failing after 32s
Rust / build (pull_request) Successful in 52s
Python / verify (pull_request) Failing after 1m23s
Rust / fmt (pull_request) Successful in 51s
Rust / clippy (pull_request) Successful in 1m2s
2026-10-27 02:15:16 +01:00
zach e655ec5bc7 feat: add wallpaper transitions, store crop in database per-wallpaper, move CUDA context creation to async thread
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Failing after 28s
Python / verify (pull_request) Failing after 1m29s
Rust / build (pull_request) Successful in 53s
Rust / fmt (pull_request) Successful in 42s
Rust / clippy (pull_request) Successful in 1m4s
2026-10-26 14:47:47 +01:00
zach dc0f222ace fix: don't store dbPath as const
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Failing after 27s
Rust / build (pull_request) Successful in 47s
Python / verify (pull_request) Failing after 1m24s
Rust / fmt (pull_request) Successful in 30s
Rust / clippy (pull_request) Successful in 53s
2026-10-26 03:13:33 +01:00
zach de19fd6ac4 fix: restore code removed by incorrect replacement command
C++ / fmt (pull_request) Failing after 5s
C++ / clang-tidy (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 6s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Failing after 28s
Rust / build (pull_request) Successful in 48s
Python / verify (pull_request) Failing after 1m19s
Rust / fmt (pull_request) Successful in 36s
Rust / clippy (pull_request) Successful in 1m0s
2026-10-26 03:03:20 +01:00
zach 9edbcbd610 feat: skip two-way copy for cuda devices when decoding video wallpapers + cache wallpapers as blobs in new database
C++ / fmt (pull_request) Failing after 11s
C++ / build (pull_request) Failing after 14s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Failing after 28s
Rust / build (pull_request) Successful in 47s
Python / verify (pull_request) Failing after 1m20s
Rust / fmt (pull_request) Successful in 28s
Rust / clippy (pull_request) Successful in 59s
2026-10-26 02:51:28 +01:00
zach cd44d27b74 chore: suppress more warnings
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 29s
Rust / build (pull_request) Successful in 59s
Python / verify (pull_request) Failing after 1m31s
Rust / fmt (pull_request) Successful in 33s
Rust / clippy (pull_request) Successful in 52s
2026-10-25 02:10:50 +02:00
zach 0d0faecb1c feat: initial video wallpaper implementation 2026-10-25 02:10:50 +02:00
94 changed files with 8388 additions and 3222 deletions
+3
View File
@@ -0,0 +1,3 @@
indent: tabs
list_expansion: favour-expansion
definitions: [Plugins/cmake/qml-module.cmake, Plugins/gersemi/qt-add-shaders.cmake]
+1 -1
View File
@@ -51,7 +51,7 @@ CustomRect {
if (internalChecked)
return checkedRadius;
if (isRound)
return implicitHeight / 2 * Math.min(1, Tokens.rounding.scale);
return (height ?? implicitHeight) / 2 * Math.min(1, Tokens.rounding.scale);
return defaultRadius;
}
+44 -10
View File
@@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Shapes
import ZShell
import ZShell.Components
import ZShell.Config
import qs.Services
@@ -9,21 +10,50 @@ import qs.Services
Item {
id: root
readonly property real arcRadius: (size - padding - strokeWidth * (1 + waveAmplitude * 2)) / 2
// Ring geometry, quantized in the item's own device pixel space and never
// against its position: the parent layout places the item on the device
// grid, so these values cannot change when the item moves (no jitter), and
// the ring stays crisp wherever it lands.
// Device-pixel center of the ring: an even stroke width wants a pixel
// boundary, an odd one a pixel center. Floored, matching
// WavyLineRenderer::snapCenterDev, so the wave and this background arc
// always land on the same center even when the box's device size is odd.
readonly property real centerDev: Math.floor(
sizeDev / 2 - strokeParity / 2
) + strokeParity / 2
readonly property real centerX: centerDev / dpr
readonly property real centerY: centerDev / dpr
// Whole device pixels, and one pixel of slack on the outer edge: the ring's
// antialias feather needs somewhere to fade, otherwise it is cut flat at
// the top/left/right/bottom of the item rect.
readonly property real arcRadius: Math.max(
1,
Math.floor(
(sizeDev - Math.round(padding * dpr) - strokeDev * (1 + waveAmplitude * 2) - 2) / 2
)
) / dpr
property color bgColor: Colors.palette.m3secondaryContainer
property real clampedVal: Math.max(1 / 360, Math.min(1, isNaN(value) ? 0 : value))
readonly property real dotAngleRad: (startAngle + sweepAngle - gapAngle * (sweepAngle < 360 ? 0 : 1)) * Math.PI / 180
readonly property real dpr: ZUtils.dprFor(root)
property color fgColor: Colors.palette.m3primary
readonly property real gapAngle: ((spacing + strokeWidth) / (arcRadius || 1)) * (180 / Math.PI)
readonly property real gapAngle: ((spacing + strokeLogical) / (arcRadius || 1)) * (180 / Math.PI)
property alias hasEndIndicator: dot.active
property real implicitSize
property int padding: 0
readonly property real size: Math.min(width, height)
// The item's own size in whole device pixels. Never adjusted upwards: the
// texture is exactly this wide, so ring values derived from it cannot spill
// past the item's bounds.
readonly property real size: sizeDev / dpr
readonly property real sizeDev: Math.round(Math.min(width, height) * dpr)
property int spacing: Tokens.spacing.small
readonly property real strokeDev: Math.max(1, Math.round(strokeWidth * dpr))
readonly property real strokeLogical: strokeDev / dpr
readonly property real strokeParity: strokeDev % 2
property int startAngle: -90
property int strokeWidth: Tokens.padding.extraSmall
property int sweepAngle: 360
readonly property real thickness: strokeWidth * (1 + waveAmplitude) * 2
readonly property real thickness: strokeLogical * (1 + waveAmplitude) * 2
property real value
property alias waveAmplitude: wave.amplitudeMultiplier
property alias waveDuration: waveProgAnim.duration
@@ -43,7 +73,7 @@ Item {
capStyle: ShapePath.RoundCap
fillColor: "transparent"
strokeColor: root.bgColor
strokeWidth: Math.min(1, remainingArc.sweepAngle) * root.strokeWidth
strokeWidth: Math.max(1, Math.round(Math.min(1, remainingArc.sweepAngle) * root.strokeDev)) / root.dpr
Behavior on strokeColor {
CAnim {
@@ -53,8 +83,8 @@ Item {
PathAngleArc {
id: remainingArc
centerX: root.size / 2
centerY: root.size / 2
centerX: root.centerX
centerY: root.centerY
radiusX: root.arcRadius
radiusY: root.arcRadius
startAngle: root.startAngle + root.clampedVal * root.sweepAngle + root.gapAngle
@@ -68,11 +98,12 @@ Item {
amplitudeMultiplier: root.wavy ? 0.5 : 0
anchors.fill: parent
anchors.margins: -lineWidth * amplitudeMultiplier
anchors.margins: -Math.round(lineWidth * amplitudeMultiplier * root.dpr) / root.dpr
color: root.fgColor
frequency: 8
fullAngle: root.sweepAngle
lineWidth: root.strokeWidth
objectName: "wave"
pathType: WavyLine.Arc
radius: root.arcRadius
startAngle: root.startAngle
@@ -103,8 +134,11 @@ Item {
Loader {
id: dot
x: root.size / 2 + root.arcRadius * Math.cos(root.dotAngleRad) - width / 2
y: root.size / 2 + root.arcRadius * Math.sin(root.dotAngleRad) - height / 2
// Rounded in local device pixels: the dot's own size is a whole even
// device count, so an integral local position puts its center on the grid
// whatever the ring's angle, and moving the gauge moves the dot with it.
x: Math.round((root.centerX + root.arcRadius * Math.cos(root.dotAngleRad) - width / 2) * root.dpr) / root.dpr
y: Math.round((root.centerY + root.arcRadius * Math.sin(root.dotAngleRad) - height / 2) * root.dpr) / root.dpr
sourceComponent: CustomRect {
color: root.fgColor
+2 -1
View File
@@ -96,6 +96,8 @@ ProgressBar {
anchors.right: parent.right
implicitWidth: parent.width - wave.implicitWidth - Tokens.spacing.extraSmall
anchors.top: parent.top
anchors.bottom: parent.bottom
}
Line {
@@ -197,7 +199,6 @@ ProgressBar {
component Line: CustomRect {
property point bounds
anchors.verticalCenter: parent.verticalCenter
color: root.bgColor
implicitHeight: parent.height
implicitWidth: bounds.y - bounds.x
+23
View File
@@ -0,0 +1,23 @@
import QtQuick
import qs.Services
Flickable {
id: root
interactive: !ShellState.forActive().isDrawing
rebound: Transition {
Anim {
properties: "x,y"
type: Anim.FastEffects
}
}
readonly property KineticWheelScroller _scroller: KineticWheelScroller {
id: wheelScroller
anchors.fill: parent
parent: root
z: 12
flickable: root
}
}
+653
View File
@@ -0,0 +1,653 @@
import QtQuick
// Taken from hyprfm: https://github.com/soyeb-jim285/hyprfm/blob/main/src/qml/views/KineticWheelScroller.qml
// Adapted to fit this project where needed.
// Deliberately not a MouseArea: a MouseArea claims the arrow cursor for its
// whole area, which silently killed every cursorShape in the views this
// overlays (column resize grips, pointer hands). A WheelHandler on a plain
// Item takes wheel events without touching the cursor.
Item {
id: root
required property var flickable
property real wheelStep: 56
property real mouseWheelMultiplier: 1.0
property real touchpadMultiplier: 1.35
property real minVelocity: 480
property real maxVelocity: 5200
property real kineticGain: 1.35
property real smoothing: 0.72
property real edgeEpsilon: 1.0
// Browser-style overscroll: larger visible rubber-band, softer
// resistance, and a springy settle on release.
property real overshootLimit: 140
property real overshootResistance: 0.38
property real maxOvershootInput: 520
// Bounce magnitude per unit of impact velocity. Keep this deliberately
// small: slow edge hits should settle, not fire a visibly faster bounce.
property real kineticBounceGain: 0.018
property real maxKineticBounceInput: 90
// Minimum impact velocity that actually produces a visible bounce.
// Very slow landings skip bounce entirely — matches what browsers do
// when a fling decays to near-zero before reaching the edge.
property real minBounceVelocity: 260
property real velocity: 0
property double lastWheelTimestamp: 0
property bool kineticCandidate: false
property real overshootInput: 0
property real overshootReleaseVelocity: 0
property real pendingKineticBounceVelocity: 0
// Set by runKineticGlide when the glide will clamp against an edge.
// It is the glide velocity at the instant of impact, which drives the
// elastic bounce amount and duration — browser-style: slow impact
// makes a small gentle bounce; fast impact makes a big springy one.
property real pendingImpactVelocity: 0
// Browser-style exponential-feeling glide replaces the linear
// flickDeceleration decay. Tail uses OutQuart — soft landing.
property real kineticTau: 0.42
property real mouseGlideTime: 0.09 // decay time constant (s); higher = longer coast
property real mouseAccelWindow: 140 // ms between notches that counts as "continuous"
property real mouseAccelStep: 0.15 // extra impulse multiplier per consecutive notch
property real mouseMaxAccel: 2.5
property real mouseSnap: 0.2 // fraction of each notch applied instantly (0 = fully glided, 1 = no smoothing)
property real mouseVelocity: 0
property int mouseDir: 0
property int mouseStreak: 0
property double mouseLastTime: 0
// Escape hatch if auto-detection misjudges a device
property bool forceMouseWheel: false
readonly property real visualOvershoot: overshootInput < 0 ? rubberDistance(-overshootInput) : -rubberDistance(overshootInput)
signal scrollStarted
WheelHandler {
id: wheelHandler
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
// `blocking` decides whether the event continues to the Flickable
// underneath; handleWheel() flips it per event (the old MouseArea
// code toggled wheel.accepted for the same purpose).
onWheel: wheel => root.handleWheel(wheel)
}
function isMouseWheel(wheel) {
if (forceMouseWheel)
return true;
var ay = wheel.angleDelta.y;
if (ay === 0)
return false;
return Math.abs(ay) % 120 === 0;
}
function minimumContentY() {
if (!flickable)
return 0;
return flickable.originY;
}
function maximumContentY() {
if (!flickable)
return 0;
return Math.max(minimumContentY(), minimumContentY() + flickable.contentHeight - flickable.height);
}
function boundedContentY(value) {
return Math.max(minimumContentY(), Math.min(maximumContentY(), value));
}
function rubberDistance(distance) {
if (distance <= 0)
return 0;
var scaled = (distance * overshootResistance) / overshootLimit;
return overshootLimit * (1 - 1 / (1 + scaled));
}
function setOvershootInput(value) {
overshootInput = Math.max(-maxOvershootInput, Math.min(maxOvershootInput, value));
}
function hasOvershoot() {
return Math.abs(overshootInput) > edgeEpsilon;
}
function absorbOvershoot(delta) {
if (!hasOvershoot())
return delta;
var sameDirection = (overshootInput < 0 && delta < 0) || (overshootInput > 0 && delta > 0);
if (sameDirection) {
setOvershootInput(overshootInput + delta);
return 0;
}
var combined = overshootInput + delta;
if ((overshootInput < 0 && combined < 0) || (overshootInput > 0 && combined > 0)) {
setOvershootInput(combined);
return 0;
}
setOvershootInput(0);
return combined;
}
function deltaFor(wheel) {
var rawDelta;
if (isMouseWheel(wheel))
rawDelta = (wheel.angleDelta.y / 120.0) * wheelStep * mouseWheelMultiplier;
else
rawDelta = wheel.pixelDelta.y !== 0 ? wheel.pixelDelta.y * touchpadMultiplier : (wheel.angleDelta.y / 120.0) * wheelStep * mouseWheelMultiplier;
return wheel.inverted ? rawDelta : -rawDelta;
}
function shouldUseKinetic(wheel, gap) {
return wheel.pixelDelta.y !== 0 || (wheel.phase !== Qt.NoScrollPhase && wheel.angleDelta.y === 0);
}
function scrollBy(delta) {
if (!flickable || Math.abs(delta) < 0.01)
return;
kineticBounceSequence.stop();
bounceBackAnimation.stop();
var remainingDelta = absorbOvershoot(delta);
if (Math.abs(remainingDelta) < 0.01)
return;
var minY = minimumContentY();
var maxY = maximumContentY();
var next = flickable.contentY + remainingDelta;
if (next < minY) {
flickable.contentY = minY;
setOvershootInput(overshootInput + next - minY);
return;
}
if (next > maxY) {
flickable.contentY = maxY;
setOvershootInput(overshootInput + next - maxY);
return;
}
flickable.contentY = next;
}
function remainingDistanceFor(velocityValue) {
if (!flickable)
return 0;
if (velocityValue > 0)
return Math.max(0, maximumContentY() - flickable.contentY);
if (velocityValue < 0)
return Math.max(0, flickable.contentY - minimumContentY());
return 0;
}
function clampedContentVelocity(velocityValue) {
if (!flickable || velocityValue === 0)
return 0;
var remaining = remainingDistanceFor(velocityValue);
if (remaining <= edgeEpsilon)
return 0;
var maxAllowed = Math.sqrt(2 * flickable.flickDeceleration * remaining);
var magnitude = Math.min(Math.abs(velocityValue), maxAllowed);
return velocityValue < 0 ? -magnitude : magnitude;
}
function resetState() {
velocity = 0;
lastWheelTimestamp = 0;
kineticCandidate = false;
pendingKineticBounceVelocity = 0;
pendingImpactVelocity = 0;
overshootReleaseVelocity = 0;
finishTimer.stop();
kineticAnim.stop();
kineticBounceSequence.stop();
bounceBackAnimation.stop();
stopMouseGlide();
}
function bounceBack() {
if (!hasOvershoot()) {
setOvershootInput(0);
overshootReleaseVelocity = 0;
return;
}
var mag = Math.min(1.0, Math.abs(overshootInput) / maxOvershootInput);
var speed = Math.abs(overshootReleaseVelocity);
var duration = speed >= minBounceVelocity ? Math.round(Math.max(280, Math.min(620, Math.abs(overshootInput) / speed * 1600))) : Math.round(380 + mag * 260);
kineticBounceSequence.stop();
bounceBackAnimation.stop();
bounceBackAnimation.duration = duration;
bounceBackAnimation.from = overshootInput;
bounceBackAnimation.to = 0;
overshootReleaseVelocity = 0;
bounceBackAnimation.start();
}
function triggerKineticBounce(contentVelocity) {
if (!flickable || contentVelocity === 0)
return;
var atTop = flickable.contentY <= minimumContentY() + edgeEpsilon;
var atBottom = flickable.contentY >= maximumContentY() - edgeEpsilon;
if ((contentVelocity < 0 && !atTop) || (contentVelocity > 0 && !atBottom))
return;
var speed = Math.abs(contentVelocity);
var effectiveVelocity = contentVelocity;
if (speed < minBounceVelocity)
effectiveVelocity = (contentVelocity < 0 ? -1 : 1) * Math.max(90, speed * 0.6);
var target = Math.max(-maxKineticBounceInput, Math.min(maxKineticBounceInput, effectiveVelocity * kineticBounceGain));
if (Math.abs(target) < 2.5)
target = contentVelocity < 0 ? -2.5 : 2.5;
var mag = Math.min(1.0, Math.abs(target) / maxKineticBounceInput);
var inDur = Math.round(150 + mag * 120);
var outDur = Math.round(320 + mag * 300);
kineticBounceSequence.stop();
bounceBackAnimation.stop();
overshootReleaseVelocity = 0;
overshootInput = 0;
kineticBounceIn.from = 0;
kineticBounceIn.to = target;
kineticBounceIn.duration = inDur;
kineticBounceOut.from = target;
kineticBounceOut.to = 0;
kineticBounceOut.duration = outDur;
kineticBounceSequence.start();
}
function stopAndSettle() {
kineticAnim.stop();
if (flickable && flickable.flicking)
flickable.cancelFlick();
resetState();
bounceBack();
}
function runKineticGlide(contentVelocity) {
// contentVelocity > 0 means the content should scroll downward
// (contentY increases), matching deltaFor()'s sign convention.
if (!flickable || contentVelocity === 0) {
pendingImpactVelocity = 0;
return false;
}
var current = flickable.contentY;
var unclampedTarget = current + contentVelocity * kineticTau;
var target = boundedContentY(unclampedTarget);
var distUnclamped = Math.abs(unclampedTarget - current);
var distActual = Math.abs(target - current);
if (distActual < 4) {
pendingImpactVelocity = 0;
return false;
}
// If the glide is clipped by an edge, compute the velocity at the
// moment of impact using the OutQuart velocity profile. That is
// the velocity the bounce should feed on — gentle impacts get
// gentle bounces, hard impacts get big springy ones.
var hitsEdge = distActual + 0.5 < distUnclamped;
if (hitsEdge && distUnclamped > 0) {
var f = distActual / distUnclamped; // fraction covered
// OutQuart: y = 1 - (1-x)^4, so velocity fraction at distance
// fraction f equals (1-f)^(3/4) (times the constant peak).
var velFraction = Math.pow(Math.max(0, 1 - f), 0.75);
pendingImpactVelocity = contentVelocity * velFraction;
} else {
pendingImpactVelocity = 0;
}
kineticAnim.stop();
kineticAnim.from = current;
kineticAnim.to = target;
// Duration from distance and velocity; clamped so short flicks
// stay snappy and long flicks don't run forever.
kineticAnim.duration = Math.round(Math.min(1400, Math.max(180, distActual / Math.abs(contentVelocity) * 1800)));
kineticAnim.start();
return true;
}
function atEdgeFor(contentVelocity) {
if (!flickable)
return false;
if (contentVelocity > 0)
return flickable.contentY >= maximumContentY() - edgeEpsilon;
if (contentVelocity < 0)
return flickable.contentY <= minimumContentY() + edgeEpsilon;
return false;
}
function finishScroll() {
if (!flickable)
return;
finishTimer.stop();
if (hasOvershoot()) {
velocity = 0;
lastWheelTimestamp = 0;
kineticCandidate = false;
pendingKineticBounceVelocity = 0;
pendingImpactVelocity = 0;
kineticAnim.stop();
bounceBack();
return;
}
var rawContentVelocity = Math.max(-maxVelocity, Math.min(maxVelocity, velocity * kineticGain));
var contentVelocity = clampedContentVelocity(rawContentVelocity);
var useKinetic = kineticCandidate && Math.abs(rawContentVelocity) >= minVelocity;
velocity = 0;
lastWheelTimestamp = 0;
kineticCandidate = false;
if (useKinetic) {
if (atEdgeFor(rawContentVelocity)) {
// Already at the edge — fling bounces immediately.
pendingKineticBounceVelocity = 0;
triggerKineticBounce(rawContentVelocity);
} else if (Math.abs(contentVelocity) >= minVelocity && runKineticGlide(contentVelocity)) {
// Glide started — bounce fires on kineticAnim.onFinished
// if it lands at an edge.
pendingKineticBounceVelocity = contentVelocity;
} else {
pendingKineticBounceVelocity = 0;
flickable.returnToBounds();
}
} else {
pendingKineticBounceVelocity = 0;
flickable.returnToBounds();
}
}
function handleWheel(wheel) {
wheelHandler.blocking = true;
if (!enabled || !visible || !flickable || !flickable.visible || !flickable.interactive) {
wheelHandler.blocking = false;
return;
}
if (wheel.modifiers !== Qt.NoModifier) {
wheelHandler.blocking = false;
return;
}
if (maximumContentY() <= minimumContentY()) {
wheelHandler.blocking = false;
return;
}
var delta = deltaFor(wheel);
if (delta === 0) {
wheelHandler.blocking = false;
return;
}
// Cancel any in-flight glide — user input always wins, position
// stays where it is, then new delta is applied from there.
if (kineticAnim.running) {
pendingKineticBounceVelocity = 0;
pendingImpactVelocity = 0;
kineticAnim.stop();
}
kineticBounceSequence.stop();
bounceBackAnimation.stop();
if (flickable.flicking) {
pendingKineticBounceVelocity = 0;
pendingImpactVelocity = 0;
flickable.cancelFlick();
}
var now = Date.now();
var isTouchpad = !isMouseWheel(wheel) && (wheel.pixelDelta.y !== 0 || (wheel.phase !== Qt.NoScrollPhase && wheel.angleDelta.y === 0));
if (!isTouchpad) {
velocity = 0;
lastWheelTimestamp = 0;
kineticCandidate = false;
scrollStarted();
handleMouseDelta(delta, now);
return;
}
// Touchpad path (unchanged below), but cancel any mouse glide first
stopMouseGlide();
var gap = lastWheelTimestamp > 0 ? now - lastWheelTimestamp : 16;
var dt = Math.max(8, Math.min(24, gap));
var instantVelocity = (delta / dt) * 1000;
kineticCandidate = kineticCandidate || shouldUseKinetic(wheel, gap);
scrollStarted();
scrollBy(delta);
if (hasOvershoot()) {
overshootReleaseVelocity = Math.max(-maxVelocity, Math.min(maxVelocity, instantVelocity));
velocity = 0;
kineticCandidate = false;
lastWheelTimestamp = now;
finishTimer.restart();
return;
}
velocity = lastWheelTimestamp <= 0 ? instantVelocity : velocity * (1.0 - smoothing) + instantVelocity * smoothing;
lastWheelTimestamp = now;
finishTimer.restart();
if (wheel.phase === Qt.ScrollEnd)
finishScroll();
}
function stopMouseGlide() {
mouseGlide.stop();
mouseVelocity = 0;
mouseDir = 0;
mouseStreak = 0;
mouseLastTime = 0;
}
function handleMouseDelta(delta, now) {
var dir = delta > 0 ? 1 : -1;
// Edge or active overshoot: existing rubber-band path
if (hasOvershoot() || atEdgeFor(dir)) {
stopMouseGlide();
scrollBy(delta);
overshootReleaseVelocity = 0;
finishTimer.restart();
return;
}
var continuous = dir === mouseDir && (now - mouseLastTime) <= mouseAccelWindow;
if (!continuous)
mouseVelocity = 0; // new gesture or reversal: start clean
mouseStreak = continuous ? mouseStreak + 1 : 0;
// Compute the gap BEFORE overwriting mouseLastTime
var gap = mouseLastTime > 0 ? Math.max(12, Math.min(mouseAccelWindow, now - mouseLastTime)) : 0;
mouseDir = dir;
mouseLastTime = now;
var accel = Math.min(mouseMaxAccel, 1 + mouseStreak * mouseAccelStep);
// --- velocity update (this is the part that changed) ---
// Part of the notch lands immediately
var instant = delta * mouseSnap;
flickable.contentY = boundedContentY(flickable.contentY + instant);
// The rest becomes glide velocity, stacked onto the current velocity
var glide = delta * (1 - mouseSnap) * accel;
var v = mouseVelocity + glide / mouseGlideTime;
// While notches keep arriving, don't fall below the rate they imply
if (continuous && gap > 0) {
var rate = glide / (gap / 1000);
if (Math.abs(rate) > Math.abs(v))
v = rate;
}
mouseVelocity = Math.max(-maxVelocity, Math.min(maxVelocity, v));
// --- end velocity update ---
if (!mouseGlide.running)
mouseGlide.start();
}
function stepMouseGlide(dt) {
if (!flickable || flickable.dragging) {
stopMouseGlide();
return;
}
dt = Math.min(dt, 0.05);
var decay = Math.exp(-dt / mouseGlideTime);
// Exact integral of v0 * e^(-t/tau) over dt, so it's frame-rate independent
var step = mouseVelocity * mouseGlideTime * (1 - decay);
var next = flickable.contentY + step;
var minY = minimumContentY();
var maxY = maximumContentY();
if (next <= minY || next >= maxY) {
var impact = mouseVelocity;
flickable.contentY = next <= minY ? minY : maxY;
mouseGlide.stop();
mouseVelocity = 0;
mouseDir = 0;
mouseStreak = 0;
triggerKineticBounce(impact); // bounce scales with the real impact speed
return;
}
flickable.contentY = next;
mouseVelocity *= decay;
if (Math.abs(mouseVelocity) < 40) // was 8
stopMouseGlide();
}
FrameAnimation {
id: mouseGlide
onTriggered: root.stepMouseGlide(frameTime)
}
Timer {
id: finishTimer
interval: 45
repeat: false
onTriggered: root.finishScroll()
}
NumberAnimation {
id: kineticAnim
target: root.flickable
property: "contentY"
easing.type: Easing.OutQuart
onFinished: {
// Prefer the impact-time velocity (computed in runKineticGlide)
// so the bounce matches the residual energy at the moment the
// glide hit the edge — slow landings → soft bounce, fast
// landings → big springy bounce.
var bv = Math.abs(root.pendingImpactVelocity) > root.edgeEpsilon ? root.pendingImpactVelocity : root.pendingKineticBounceVelocity;
root.pendingImpactVelocity = 0;
root.pendingKineticBounceVelocity = 0;
if (Math.abs(bv) > root.edgeEpsilon)
root.triggerKineticBounce(bv);
}
}
NumberAnimation {
id: bounceBackAnimation
target: root
property: "overshootInput"
easing.type: Easing.OutCubic
}
SequentialAnimation {
id: kineticBounceSequence
NumberAnimation {
id: kineticBounceIn
target: root
property: "overshootInput"
easing.type: Easing.OutCubic
}
NumberAnimation {
id: kineticBounceOut
target: root
property: "overshootInput"
easing.type: Easing.OutCubic
}
}
Translate {
id: overshootTranslate
y: root.visualOvershoot
}
Connections {
target: root.flickable
function onInteractiveChanged() {
if (!root.flickable.interactive)
root.stopAndSettle();
}
function onContentHeightChanged() {
if (!root.flickable.moving) {
root.flickable.contentY = root.boundedContentY(root.flickable.contentY);
root.bounceBack();
}
}
function onHeightChanged() {
if (!root.flickable.moving) {
root.flickable.contentY = root.boundedContentY(root.flickable.contentY);
root.bounceBack();
}
}
function onVisibleChanged() {
if (!root.flickable.visible)
root.stopAndSettle();
}
function onMovementEnded() {
if (Math.abs(root.pendingKineticBounceVelocity) > root.edgeEpsilon) {
var bounceVelocity = root.pendingKineticBounceVelocity;
root.pendingKineticBounceVelocity = 0;
root.triggerKineticBounce(bounceVelocity);
}
}
}
Component.onCompleted: {
if (!flickable)
return;
flickable.flickDeceleration = 2800;
flickable.maximumFlickVelocity = Math.max(flickable.maximumFlickVelocity, maxVelocity);
flickable.contentItem.transform = [overshootTranslate];
}
}
+18 -2
View File
@@ -55,6 +55,21 @@ Item {
return ZUtils.snap(v, canvas);
}
// A value is plottable only if it is a real number. Everything else -
// null, undefined, NaN, an unparsable string from the API - is a hole, so
// the series splits there. Non-finite coordinates must never reach the
// path: one NaN lineTo makes the canvas ignore the rest of the curve, and
// the fill then runs from whatever y it last had straight down.
function numeric(v) {
if (typeof v === "number")
return Number.isFinite(v) ? v : null;
if (typeof v === "string") {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
return null;
}
function strokeCurve(ctx, pts) {
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
@@ -230,7 +245,8 @@ Item {
if (loAuto || hiAuto) {
let dmin = Infinity;
let dmax = -Infinity;
for (const v of root.values) {
for (const value of root.values) {
const v = numeric(value);
if (v == null)
continue;
if (v < dmin)
@@ -272,7 +288,7 @@ Item {
if (range <= 0 || root.xSteps < 1)
return;
let series = root.values;
let series = root.values.map(v => numeric(v));
if (root.smoothing > 0 && series.length > 0) {
const radius = Math.round(root.smoothing);
const raw = series;
+120
View File
@@ -0,0 +1,120 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Services.Notifications
import Quickshell.Widgets
import ZShell.Components
import ZShell.Config
import ZShell.Services
import qs.Components
import qs.Services
ButtonRow {
id: root
required property NotificationRow notif
required property color buttonColour
readonly property color fillColour: notif.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : buttonColour
readonly property color buttonTextColour: notif.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant
function invoke(action: var): void {
action.invoke();
if (!notif.resident)
notif.dismiss();
}
spacing: Tokens.spacing.extraSmall
IconButton {
isRound: true
shapeMorph: true
fillWidth: root.notif.actions.length === 0
inactiveColor: root.fillColour
inactiveOnColor: root.buttonTextColour
icon: "close"
padding: Tokens.padding.extraSmall
onClicked: root.notif.dismiss()
}
Repeater {
model: root.notif.actions
delegate: root.notif.hasActionIcons ? iconAction : textAction
}
IconButton {
isRound: true
shapeMorph: true
fillWidth: root.notif.actions.length === 0
inactiveColor: root.fillColour
inactiveOnColor: root.buttonTextColour
icon: copyTimer.running ? "inventory" : "content_copy"
padding: Tokens.padding.extraSmall
onClicked: {
Quickshell.clipboardText = root.notif.body;
copyTimer.restart();
}
label.animate: true
Timer {
id: copyTimer
interval: 3000
}
}
Component {
id: textAction
TextButton {
required property var modelData
isRound: true
shapeMorph: true
fillWidth: true
inactiveColor: root.fillColour
inactiveOnColor: root.buttonTextColour
text: modelData.text
onClicked: root.invoke(modelData)
label.horizontalAlignment: Text.AlignHCenter
label.anchors.left: left
label.anchors.right: right
label.anchors.verticalCenter: verticalCenter
label.anchors.centerIn: undefined
label.anchors.margins: Tokens.padding.medium
label.elide: Text.ElideRight
}
}
Component {
id: iconAction
ButtonBase {
id: button
required property var modelData
isRound: true
shapeMorph: true
fillWidth: true
padding: Tokens.padding.extraSmall
inactiveColor: root.fillColour
inactiveOnColor: root.buttonTextColour
implicitWidth: implicitHeight
implicitHeight: icon.implicitHeight + padding * 2
onClicked: root.invoke(modelData)
IconImage {
id: icon
anchors.centerIn: parent
implicitSize: Math.round(Tokens.font.icon.medium.pointSize * 1.3)
asynchronous: true
source: Quickshell.iconPath(button.modelData.identifier)
}
}
}
}
+201
View File
@@ -0,0 +1,201 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Shapes
import Quickshell
import ZShell
import M3Shapes
import ZShell.Config
import qs.Components
import qs.Helpers
import qs.Services
import Quickshell.Services.Notifications
Item {
id: root
required property var owner
required property string image
required property string icon
required property int urgency
required property string summary
required property color lowFillColour
property real progress: 0
property string shownImage
property string shownIcon
readonly property color fillColour: urgency === NotificationUrgency.Critical ? Colors.palette.m3primary : urgency === NotificationUrgency.Low ? lowFillColour : Colors.palette.m3secondaryContainer
readonly property color iconColour: urgency === NotificationUrgency.Critical ? Colors.palette.m3onPrimary : urgency === NotificationUrgency.Low ? Colors.palette.m3onSurfaceVariant : Colors.palette.m3onSecondaryContainer
implicitWidth: Config.notifs.sizes.image
implicitHeight: Config.notifs.sizes.image
// Keeping the previously shown url avoids replaying the fade when a row
// swaps between its raw source and the cached blob.
onImageChanged: {
if (image && !owner.isSameImage(shownImage))
shownImage = image;
}
onIconChanged: {
if (icon && !owner.isSameAppIcon(shownIcon))
shownIcon = icon;
}
Component.onCompleted: {
shownImage = image;
shownIcon = icon;
}
Component {
id: imageComp
FadeImage {
source: Qt.resolvedUrl(root.shownImage)
width: Config.notifs.sizes.image
height: Config.notifs.sizes.image
fadeInLargeAnim: Anim.DefaultEffects
}
}
Component {
id: appIconComp
FadeIcon {
size: Math.round(Config.notifs.sizes.image * 0.6)
}
}
Component {
id: materialIconComp
MaterialIcon {
animate: true
text: Icons.getNotifIcon(root.summary, root.urgency)
color: root.iconColour
fontStyle: Tokens.font.icon.medium
}
}
MaterialShape {
id: bgShape
anchors.fill: parent
shape: contentLoader.sourceComponent === materialIconComp && root.urgency === NotificationUrgency.Critical ? MaterialShape.SoftBurst : MaterialShape.Circle
color: Qt.alpha(root.fillColour, 1)
opacity: root.fillColour.a
Behavior on color {
CAnim {}
}
Behavior on opacity {
Anim {
type: Anim.SlowEffects
}
}
}
Item {
anchors.fill: parent
Loader {
id: contentLoader
anchors.centerIn: parent
anchors.verticalCenterOffset: sourceComponent === materialIconComp ? 1 : 0
asynchronous: true
sourceComponent: root.image ? imageComp : root.icon ? appIconComp : materialIconComp
}
}
Loader {
asynchronous: true
anchors.right: parent.right
anchors.bottom: parent.bottom
active: opacity > 0
opacity: root.icon && root.image ? 1 : 0
sourceComponent: CustomRect {
implicitWidth: Config.notifs.sizes.badge
implicitHeight: Config.notifs.sizes.badge
color: root.fillColour
radius: Tokens.rounding.full
FadeIcon {
anchors.centerIn: parent
size: Math.round(Config.notifs.sizes.badge * 0.6)
}
}
Behavior on opacity {
Anim {
type: root.icon && root.image ? Anim.DefaultEffects : Anim.FastEffects
}
}
}
Loader {
asynchronous: true
anchors.fill: parent
active: root.progress > 0
sourceComponent: Shape {
preferredRendererType: Shape.CurveRenderer
ShapePath {
id: progressShape
property real sweep: root.progress * 360
property real spacing: root.urgency === NotificationUrgency.Critical ? Tokens.spacing.extraSmall : 0
capStyle: ShapePath.RoundCap
fillColor: "transparent"
strokeWidth: 2
strokeColor: Colors.palette.m3primary
PathPolyline {
function pointAt(angle: real): point {
const dist = bgShape.distanceAtAngle(angle) + progressShape.strokeWidth / 2 + progressShape.spacing;
const rad = angle * Math.PI / 180;
return Qt.point(root.width / 2 + dist * Math.sin(rad), root.height / 2 - dist * Math.cos(rad));
}
path: {
bgShape.morphProgress; // mapToItem-free dependency: re-run when the shape morphs
const points = [];
for (let angle = 0; angle < progressShape.sweep; angle += 3)
points.push(pointAt(angle));
points.push(pointAt(progressShape.sweep));
return points;
}
}
Behavior on sweep {
Anim {
easing: Tokens.anim.emphasizedDecel
}
}
Behavior on spacing {
Anim {}
}
Behavior on strokeColor {
CAnim {}
}
}
}
}
component FadeIcon: FadeImage {
id: fadeIcon
required property int size
width: size
height: size
source: Quickshell.iconPath(root.shownIcon)
fillMode: Image.PreserveAspectFit
fadeInLargeAnim: Anim.DefaultEffects
}
}
+124
View File
@@ -0,0 +1,124 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Notifications
import ZShell.Components
import ZShell.Config
import ZShell.Services
import qs.Components
import qs.Services
CustomRect {
id: root
required property NotificationGroup group
required property Flickable container
required property bool expanded
property bool expandable: true
property int baseSurface: 0
property int baseLayer: baseSurface
readonly property list<NotificationRow> notifs: [...group.activeNotifs]
readonly property bool single: notifs.length === 1
readonly property alias rowPressed: notifList.rowPressed
readonly property list<color> surfaces: [Colors.palette.m3surfaceContainer, Colors.palette.m3surfaceContainerHigh, Colors.palette.m3surfaceContainerHighest]
readonly property int nonAnimHeight: {
const headerHeight = header.implicitHeight + (expanded ? Tokens.spacing.extraSmall : 0);
const columnHeight = headerHeight + notifList.layoutHeight;
return Math.round(Math.max(Config.notifs.sizes.image, columnHeight) + Tokens.padding.medium * 2);
}
signal requestExpand(expand: bool)
signal swiped(notif: NotificationRow)
signal linkActivated(notif: NotificationRow)
function surface(offset: int, layer: int): color {
return Colors.layer(surfaces[Math.min(baseSurface + offset, surfaces.length - 1)], baseLayer + layer);
}
Component.onCompleted: group.lock(this)
anchors.left: parent?.left
anchors.right: parent?.right
implicitHeight: nonAnimHeight
clip: true
radius: Tokens.rounding.large
color: group.urgency === NotificationUrgency.Critical && (single || !expanded) ? Colors.palette.m3secondaryContainer : surface(0, 1)
Behavior on implicitHeight {
Anim {}
}
SnapLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Tokens.padding.medium
objectName: "notif-content"
orientation: Qt.Horizontal
spacing: Tokens.spacing.medium
NotifAvatar {
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
Layout.topMargin: root.expanded && !root.single ? header.implicitHeight + Tokens.spacing.extraSmall + Tokens.padding.extraSmall : 0
owner: root.expanded ? root.notifs[0] ?? root.group : root.group
image: root.expanded ? root.notifs[0]?.image ?? "" : root.group.image
icon: root.expanded ? root.notifs[0]?.appIcon ?? "" : root.group.appIcon
urgency: root.expanded ? root.notifs[0]?.urgency ?? NotificationUrgency.Normal : root.group.urgency
lowFillColour: root.surface(2, 2)
summary: root.notifs[0]?.summary ?? ""
progress: (root.notifs[0]?.progress ?? -1) / 100
Behavior on Layout.topMargin {
Anim {}
}
}
Column {
Layout.fillWidth: true
spacing: root.expanded ? Tokens.spacing.extraSmall : 0
Behavior on spacing {
Anim {}
}
NotifGroupHeader {
id: header
anchors.left: parent.left
anchors.right: parent.right
appName: root.group.appName
single: root.single
timeStr: root.notifs[0]?.timeStr ?? ""
count: root.notifs.length
urgency: root.group.urgency
pillColour: root.surface(1, 2)
expandable: root.expandable
expanded: root.expanded
onToggle: root.requestExpand(!root.expanded)
}
NotifGroupList {
id: notifList
notifs: root.notifs
expanded: root.expanded
single: root.single
headerHeight: header.implicitHeight
headerTitleWidth: header.titleWidth
expandable: root.expandable
container: root.container
itemColour: root.surface(1, 2)
avatarColour: root.surface(2, 2)
buttonColour: root.surface(2, 3)
onRequestExpand: expand => root.requestExpand(expand)
onSwiped: notif => root.swiped(notif)
onLinkActivated: notif => root.linkActivated(notif)
}
}
}
}
+156
View File
@@ -0,0 +1,156 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell.Services.Notifications
import ZShell.Components
import ZShell.Config
import qs.Components
import qs.Services
Item {
id: root
required property string appName
required property bool single
required property string timeStr
required property int count
required property int urgency
required property color pillColour
required property bool expandable
required property bool expanded
readonly property alias titleWidth: appName.width
readonly property color expandBtnTextColour: urgency === NotificationUrgency.Critical && !single ? Colors.palette.m3onPrimary : Colors.palette.m3onSurfaceVariant
signal toggle
implicitHeight: Math.max(appName.implicitHeight, time.implicitHeight, pill.implicitHeight)
objectName: "notif-header"
CustomText {
id: appName
anchors.left: parent.left
anchors.right: time.left
anchors.rightMargin: Tokens.spacing.small
anchors.verticalCenter: parent.verticalCenter
opacity: root.single && !root.expanded ? 0 : 1
text: root.appName
color: Colors.palette.m3onSurfaceVariant
font: Tokens.font.body.small
elide: Text.ElideRight
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
CustomText {
id: time
anchors.right: pill.left
anchors.rightMargin: Tokens.spacing.small
anchors.verticalCenter: parent.verticalCenter
animate: true
text: root.timeStr
color: {
const col = root.urgency === NotificationUrgency.Critical && (root.single || !root.expanded) ? Colors.palette.m3secondary : Colors.palette.m3outline;
return root.expanded && !root.single ? Qt.alpha(col, 0) : col;
}
font: Tokens.font.body.small
}
CustomRect {
id: pill
property real horizontalPadding: root.single ? implicitHeight - expandIcon.implicitWidth : Tokens.padding.large
property real countSpan: root.single ? 0 : groupCount.implicitWidth + Tokens.spacing.extraSmall
property real iconInset: root.single ? 0 : Tokens.padding.extraSmall / 2
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
enabled: root.expandable
// StateLayer inherits this through its parent.enabled binding, so a
// non-expandable group gets no hover, ripple, or cursor.
implicitWidth: horizontalPadding + countSpan + (root.expandable ? expandIcon.implicitWidth : 0)
implicitHeight: groupCount.implicitHeight + Tokens.padding.extraSmall
color: {
const c = root.urgency === NotificationUrgency.Critical ? Colors.palette.m3primary : root.pillColour;
return root.single ? Qt.alpha(c, 0) : c;
}
radius: Tokens.rounding.full
StateLayer {
color: root.expandBtnTextColour
onClicked: root.toggle()
}
Item {
anchors.left: parent.left
anchors.leftMargin: pill.horizontalPadding / 2 + pill.iconInset
anchors.verticalCenter: parent.verticalCenter
objectName: "notif-expand"
implicitWidth: groupCount.implicitWidth
implicitHeight: groupCount.implicitHeight
opacity: root.single ? 0 : 1
CustomText {
id: groupCount
animate: true
text: root.count
color: root.expandBtnTextColour
font: Tokens.font.body.small
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
MaterialIcon {
id: expandIcon
anchors.right: parent.right
anchors.rightMargin: pill.horizontalPadding / 2 - pill.iconInset
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: root.expanded && !root.single ? -Tokens.padding.extraSmall / 2 : 0
visible: root.expandable
text: "expand_more"
color: root.expandBtnTextColour
rotation: root.expanded ? 180 : 0
Behavior on rotation {
Anim {}
}
Behavior on anchors.verticalCenterOffset {
Anim {}
}
}
Behavior on horizontalPadding {
Anim {}
}
Behavior on countSpan {
Anim {}
}
Behavior on iconInset {
Anim {}
}
}
}
+152
View File
@@ -0,0 +1,152 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import ZShell.Components
import ZShell.Config
import ZShell.Services
import qs.Components
import qs.Services
LazyListView {
id: root
required property list<NotificationRow> notifs
required property bool expanded
required property bool expandable
required property bool single
required property Flickable container
property real headerHeight
property real headerTitleWidth
required property color itemColour
required property color avatarColour
required property color buttonColour
readonly property bool rowPressed: pressedRows > 0
property int pressedRows
signal requestExpand(expand: bool)
signal swiped(notif: NotificationRow)
signal linkActivated(notif: NotificationRow)
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: contentHeight
spacing: Math.round(Tokens.spacing.extraSmall)
asynchronous: true
readyDelay: 1
cacheBuffer: 400
removeDuration: Tokens.anim.durations.normal
useCustomViewport: true
viewport: {
tWatcher.transform; // mapToItem is not reactive so use this to trigger updates
return Qt.rect(0, container.contentY - mapToItem(container.contentItem, 0, 0).y, width, container.height);
}
model: ScriptModel {
// Spread, not `as Array`: `notifs` is a list<NotificationRow> property,
// and reading it yields a list reference that ScriptModel cannot assign.
values: root.expanded ? [...root.notifs] : [...root.notifs].slice(0, Config.notifs.groupPreviewNum);
}
delegate: Component {
NotifSwipeArea {
id: notif
required property int index
required property NotificationRow modelData
Component.onCompleted: modelData.ref(this)
Component.onDestruction: {
if (pressed)
root.pressedRows--;
}
LazyListView.preferredHeight: LazyListView.removing ? 0 : notifInner.nonAnimHeight
LazyListView.visibleHeight: LazyListView.removing ? 0 : notifInner.implicitHeight
implicitHeight: notifInner.implicitHeight
opacity: LazyListView.removing || LazyListView.adding ? 0 : 1
scale: LazyListView.removing || LazyListView.adding ? 0.7 : 1
expanded: root.expanded
expandable: root.expandable
dragExpand: false
hoveringLink: !!notifInner.body?.hoveredLink
enabled: root.expandable && root.expanded && !LazyListView.removing
acceptedButtons: root.single ? Qt.NoButton : Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onPressedChanged: root.pressedRows += pressed ? 1 : -1
onRequestExpand: expand => root.requestExpand(expand)
onSwiped: root.swiped(modelData)
onDismissed: modelData.dismiss()
Connections {
function onClosedChanged(): void {
if (notif.modelData.closed)
closeAnim.start();
}
target: notif.modelData
}
ParallelAnimation {
id: closeAnim
Anim {
type: Anim.DefaultEffects
target: notif
property: "opacity"
to: 0
}
Anim {
target: notif
property: "x"
to: notif.x >= 0 ? notif.width : -notif.width
}
}
NotifItem {
id: notifInner
anchors.fill: parent
modelData: notif.modelData
expanded: root.expanded
single: root.single
ready: notif.LazyListView.ready
headerHeight: root.headerHeight
headerTitleWidth: root.headerTitleWidth
showAvatar: notif.index > 0
expandedColour: root.itemColour
avatarColour: root.avatarColour
buttonColour: root.buttonColour
onLinkActivated: root.linkActivated(notif.modelData)
}
Behavior on y {
enabled: notif.LazyListView.ready
Anim {}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on scale {
Anim {}
}
}
}
TransformWatcher {
id: tWatcher
a: root.container.contentItem
b: root
}
}
+287
View File
@@ -0,0 +1,287 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell.Services.Notifications
import ZShell.Components
import ZShell.Config
import ZShell.Services
import qs.Components
import qs.Services
CustomRect {
id: root
required property NotificationRow modelData
required property bool expanded
required property color expandedColour
required property color avatarColour
required property color buttonColour
property bool showAvatar: true
property bool single: false
property bool ready: true
property real headerHeight
property real headerTitleWidth
readonly property bool critical: modelData.urgency === NotificationUrgency.Critical
readonly property bool low: modelData.urgency === NotificationUrgency.Low
readonly property CustomText body: (expandedContent.item as ExpandedBody)?.body ?? null
readonly property real padding: single ? 0 : Tokens.padding.medium
readonly property real headerSummaryOffset: -(headerHeight + summaryHeightMetrics.height) / 2
readonly property real inset: expanded ? padding : 0
readonly property real summaryOffset: single && !expanded ? headerSummaryOffset : inset
readonly property real nonAnimHeight: {
if (!expanded)
return summaryHeightMetrics.height;
const contentHeight = summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + padding * 2;
return single ? contentHeight : Math.max(contentHeight, Config.notifs.sizes.image + Tokens.padding.extraSmall * 2);
}
signal linkActivated
implicitHeight: nonAnimHeight
radius: Tokens.rounding.medium
color: {
const c = root.critical ? Colors.palette.m3secondaryContainer : root.expandedColour;
return expanded && !single ? c : Qt.alpha(c, 0);
}
TextMetrics {
id: summaryHeightMetrics
font: dummySummary.font
text: " " // Use this height to prevent weird characters from changing the line height
}
TextMetrics {
id: summaryMetrics
font: dummySummary.font
text: root.modelData.summary
elide: Text.ElideRight
elideWidth: root.single ? root.headerTitleWidth : root.width
}
WrappedLoader {
id: avatar
shouldBeActive: root.expanded && root.showAvatar
anchors.top: parent.top
anchors.left: parent.left
anchors.topMargin: Tokens.padding.extraSmall
anchors.leftMargin: -(Config.notifs.sizes.image + Tokens.spacing.medium)
sourceComponent: NotifAvatar {
owner: root.modelData
image: root.modelData.image
icon: root.modelData.appIcon
urgency: root.modelData.urgency
lowFillColour: root.avatarColour
summary: root.modelData.summary
progress: root.modelData.progress / 100
}
}
WrappedLoader {
id: compactSummary
shouldBeActive: !root.expanded
anchors.top: parent.top
anchors.left: parent.left
anchors.leftMargin: root.inset
anchors.topMargin: root.summaryOffset
sourceComponent: CustomText {
animate: root.ready
text: summaryMetrics.elidedText
color: root.critical ? Colors.palette.m3onSecondaryContainer : root.low ? Colors.palette.m3onSurfaceVariant : Colors.palette.m3onSurface
}
Behavior on anchors.leftMargin {
Anim {}
}
Behavior on anchors.topMargin {
Anim {}
}
}
CustomText {
id: dummySummary
anchors.top: parent.top
anchors.left: parent.left
visible: false
text: root.modelData.summary
}
WrappedLoader {
id: compactBody
shouldBeActive: !root.expanded
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: root.inset + (root.single ? 0 : dummySummary.width + Tokens.spacing.small)
anchors.topMargin: root.single && root.expanded ? summary.implicitHeight + expandedContent.anchors.topMargin : root.inset
sourceComponent: CustomText {
text: root.modelData.body.replace(/\n/g, " ")
color: root.critical ? Colors.palette.m3secondary : root.low ? Colors.palette.m3outline : Colors.palette.m3onSurfaceVariant
elide: Text.ElideRight
}
Behavior on anchors.leftMargin {
Anim {}
}
Behavior on anchors.topMargin {
Anim {}
}
}
WrappedLoader {
id: summary
shouldBeActive: root.expanded
anchors.top: parent.top
anchors.left: parent.left
anchors.leftMargin: root.inset
anchors.topMargin: root.summaryOffset
width: root.width - root.padding * 2 - timeStr.implicitWidth - Tokens.spacing.small
sourceComponent: CustomText {
text: root.modelData.summary
color: root.critical ? Colors.palette.m3onSecondaryContainer : root.low ? Colors.palette.m3onSurfaceVariant : Colors.palette.m3onSurface
wrapMode: Text.WordWrap
}
Behavior on anchors.leftMargin {
Anim {}
}
Behavior on anchors.topMargin {
Anim {}
}
}
WrappedLoader {
id: timeStr
shouldBeActive: root.expanded && !root.single
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: root.inset
sourceComponent: CustomText {
animate: true
text: root.modelData.timeStr
color: root.critical ? Colors.palette.m3secondary : Colors.palette.m3outline
font: Tokens.font.body.small
}
Behavior on anchors.margins {
Anim {}
}
}
WrappedLoader {
id: expandedContent
shouldBeActive: root.expanded
anchors.top: summary.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: root.inset
anchors.topMargin: Tokens.spacing.extraSmall
sourceComponent: ExpandedBody {}
Behavior on anchors.margins {
Anim {}
}
}
Behavior on implicitHeight {
Anim {}
}
component ExpandedBody: SnapLayout {
readonly property alias body: bodyText
orientation: Qt.Vertical
spacing: Tokens.spacing.medium
CustomText {
id: bodyText
Layout.fillWidth: true
textFormat: Text.MarkdownText
text: root.modelData.body.replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body here! :/")
color: root.critical ? Colors.palette.m3secondary : root.low ? Colors.palette.m3outline : Colors.palette.m3onSurfaceVariant
wrapMode: Text.WordWrap
onLinkActivated: link => {
Qt.openUrlExternally(link);
root.linkActivated();
}
}
NotifActions {
Layout.fillWidth: true
notif: root.modelData
buttonColour: root.buttonColour
}
}
component WrappedLoader: Loader {
id: comp
required property bool shouldBeActive
active: false
opacity: 0
// Makes the loader load on the same frame shouldBeActive becomes true, which ensures size is set
states: State {
name: "active"
when: comp.shouldBeActive
PropertyChanges {
comp.opacity: 1
comp.active: true
}
}
transitions: [
Transition {
from: ""
to: "active"
SequentialAnimation {
PropertyAction {
property: "active"
}
Anim {
type: Anim.DefaultEffects
property: "opacity"
}
}
},
Transition {
from: "active"
to: ""
SequentialAnimation {
Anim {
type: Anim.DefaultEffects
property: "opacity"
}
PropertyAction {
property: "active"
}
}
}
]
}
}
+70
View File
@@ -0,0 +1,70 @@
pragma ComponentBehavior: Bound
import QtQuick
import ZShell.Config
import qs.Components
MouseArea {
id: root
required property bool expanded
property bool expandable: true
property bool dragExpand: expandable
property bool hoveringLink
property int startY
property real lastX
property real lastMoveTime
property real velocity
signal swiped
signal dismissed
signal requestExpand(expand: bool)
hoverEnabled: true
cursorShape: hoveringLink ? Qt.PointingHandCursor : pressedButtons === Qt.LeftButton ? Qt.ClosedHandCursor : undefined
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
preventStealing: dragExpand
drag.target: pressedButtons === Qt.LeftButton ? this : null
drag.axis: Drag.XAxis
onPressed: event => {
startY = event.y;
lastX = x;
lastMoveTime = Date.now();
velocity = 0;
if (event.button === Qt.RightButton && expandable)
requestExpand(!expanded);
else if (event.button === Qt.MiddleButton)
dismissed();
}
onPositionChanged: event => {
if (pressedButtons !== Qt.LeftButton)
return;
const now = Date.now();
const dt = now - lastMoveTime;
if (dt > 0) {
velocity = (velocity + (x - lastX) / dt * 1000) / 2;
lastX = x;
lastMoveTime = now;
}
if (dragExpand) {
const diffY = event.y - startY;
if (Math.abs(diffY) > Config.notifs.expandThreshold)
requestExpand(diffY > 0);
}
}
onReleased: {
const flung = Date.now() - lastMoveTime < 100 && Math.sign(velocity) === Math.sign(x) && Math.abs(velocity) > Config.notifs.clearVelocity;
if (!flung && Math.abs(x) < width * Config.notifs.clearThreshold)
x = 0;
else
swiped();
}
Behavior on x {
Anim {}
}
}
+17 -261
View File
@@ -4,14 +4,11 @@ pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications
import Quickshell.Hyprland
import QtQuick
import ZShell
import ZShell.Services
import qs.Components
import qs.Modules
import qs.Helpers
import qs.Services
import qs.Paths
import qs.Services
import ZShell.Config
Singleton {
@@ -19,11 +16,11 @@ Singleton {
readonly property var appCooldownMap: new Map()
property alias dnd: props.dnd
property list<Notif> list: []
property bool loaded
readonly property list<Notif> notClosed: list.filter(n => !n.closed)
readonly property list<Notif> popups: list.filter(n => n.popup)
property alias server: server
readonly property alias groups: store.groups
property alias list: store.rows
readonly property alias popupGroups: store.popupGroups
readonly property list<NotificationRow> notClosed: store.rows.filter(n => !n.closed)
readonly property list<NotificationRow> popups: store.rows.filter(n => n.popup)
function shouldThrottle(appName: string): bool {
if (props.dnd || ShellState.anySidebarOpen())
@@ -46,33 +43,6 @@ Singleton {
return true;
}
onListChanged: {
if (loaded) {
saveTimer.restart();
}
}
Timer {
id: saveTimer
interval: 1000
onTriggered: storage.setText(JSON.stringify(root.notClosed.map(n => ({
time: n.time,
id: n.id,
summary: n.summary,
body: n.body,
appIcon: n.appIcon,
appName: n.appName,
image: n.image,
expireTimeout: n.expireTimeout,
urgency: n.urgency,
resident: n.resident,
hasActionIcons: n.hasActionIcons,
actions: n.actions
}))))
}
PersistentProperties {
id: props
@@ -81,6 +51,12 @@ Singleton {
reloadableId: "notifs"
}
NotificationStore {
id: store
path: `${Paths.state}/notifs.sqlite`
}
NotificationServer {
id: server
@@ -89,40 +65,12 @@ Singleton {
bodyImagesSupported: true
bodyMarkupSupported: true
imageSupported: true
keepOnReload: false
keepOnReload: true
persistenceSupported: true
onNotification: notif => {
notif.tracked = true;
const is_popup = root.shouldThrottle(notif.appName);
const comp = notifComp.createObject(root, {
popup: is_popup,
notification: notif
});
root.list = [comp, ...root.list];
}
}
FileView {
id: storage
path: `${Paths.state}/notifs.json`
printErrors: false
onLoadFailed: err => {
if (err === FileViewError.FileNotFound) {
root.loaded = true;
Qt.callLater(() => setText("[]"));
}
}
onLoaded: {
const data = JSON.parse(text());
for (const notif of data)
root.list.push(notifComp.createObject(root, notif));
root.list.sort((a, b) => b.time - a.time);
root.loaded = true;
store.insert(notif, root.shouldThrottle(notif.appName));
}
}
@@ -130,16 +78,12 @@ Singleton {
description: "Clear all notifications"
name: "clearnotifs"
onPressed: {
for (const notif of root.list.slice())
notif.close();
}
onPressed: store.clear()
}
IpcHandler {
function clear(): void {
for (const notif of root.list.slice())
notif.close();
store.clear();
}
function disableDnd(): void {
@@ -160,192 +104,4 @@ Singleton {
target: "notifs"
}
Component {
id: notifComp
Notif {
}
}
component Notif: QtObject {
id: notif
property list<var> actions
property string appIcon
property string appName
property string body
property string cachedImageSource: ""
property bool cachingImage: false
property bool closed
readonly property Connections conn: Connections {
function onActionsChanged(): void {
notif.actions = notif.notification.actions.map(a => ({
identifier: a.identifier,
text: a.text,
invoke: () => a.invoke()
}));
}
function onAppIconChanged(): void {
notif.appIcon = notif.notification.appIcon;
}
function onAppNameChanged(): void {
notif.appName = notif.notification.appName;
}
function onBodyChanged(): void {
notif.body = notif.notification.body;
}
function onClosed(): void {
notif.close();
}
function onExpireTimeoutChanged(): void {
notif.expireTimeout = notif.notification.expireTimeout;
}
function onHasActionIconsChanged(): void {
notif.hasActionIcons = notif.notification.hasActionIcons;
}
function onHintsChanged(): void {
notif.hints = notif.notification.hints;
}
function onImageChanged(): void {
notif.imageSource = notif.notification.image || "";
notif.image = notif.imageSource;
notif.cacheImageIfNeeded();
}
function onResidentChanged(): void {
notif.resident = notif.notification.resident;
}
function onSummaryChanged(): void {
notif.summary = notif.notification.summary;
}
function onUrgencyChanged(): void {
notif.urgency = notif.notification.urgency;
}
target: notif.notification
}
property real expireTimeout: 5
property bool hasActionIcons
property var hints
property string image
property string imageSource
property var locks: new Set()
property string notifId
property Notification notification
property bool popup
property bool resident
property string summary
property date time: new Date()
readonly property string timeStr: {
const diff = Time.date.getTime() - time.getTime();
const m = Math.floor(diff / 60000);
if (m < 1)
return qsTr("now");
const h = Math.floor(m / 60);
const d = Math.floor(h / 24);
if (d > 0)
return `${d}d`;
if (h > 0)
return `${h}h`;
return `${m}m`;
}
readonly property Timer timer: Timer {
property bool paused: false
property int remainingTime: totalTime
property int totalTime: Config.notifs.defaultExpireTimeout
interval: 50
repeat: true
running: !paused
onTriggered: {
remainingTime -= interval;
if (remainingTime <= 0) {
remainingTime = 0;
notif.popup = false;
stop();
}
}
}
property int urgency: NotificationUrgency.Normal
function cacheImageIfNeeded(): void {
const source = imageSource;
if (!source || cachingImage)
return;
if (cachedImageSource === source)
return;
cachingImage = true;
ZShellIo.cacheImage(Qt.resolvedUrl(source), Paths.notifimagecache, (path, url) => {
cachedImageSource = source;
image = path;
cachingImage = false;
}, () => {
cachingImage = false;
});
}
function close(): void {
closed = true;
if (locks.size === 0 && root.list.includes(this)) {
root.list = root.list.filter(n => n !== this);
notification?.dismiss();
destroy();
}
}
function lock(item: Item): void {
locks.add(item);
}
function unlock(item: Item): void {
locks.delete(item);
if (closed)
close();
}
Component.onCompleted: {
if (!notification)
return;
notifId = notification.id;
summary = notification.summary;
body = notification.body;
appIcon = notification.appIcon;
appName = notification.appName;
imageSource = notification.image || "";
image = imageSource;
hints = notification.hints;
expireTimeout = notification.expireTimeout;
urgency = notification.urgency;
resident = notification.resident;
hasActionIcons = notification.hasActionIcons;
actions = notification.actions.map(a => ({
identifier: a.identifier,
text: a.text,
invoke: () => a.invoke()
}));
cacheImageIfNeeded();
}
}
}
+7
View File
@@ -4,12 +4,16 @@ import Quickshell
import QtQuick
import ZShell.Internal
import qs.Components
import qs.Services
Item {
id: root
readonly property alias content: contentLoader.item
required property ScreenState screenState
property rect holeRect
property real holeRadius: 0
Loader {
id: contentLoader
@@ -18,6 +22,9 @@ Item {
anchors.fill: parent
sourceComponent: StrokeCanvas {
anchors.fill: parent
hole: root.holeRect
holeRadius: root.holeRadius
penColor: root.screenState.penColor
penWidth: root.screenState.penWidth
}
+95
View File
@@ -0,0 +1,95 @@
import Quickshell
import QtQuick
import ZShell.Config
import qs.Components
CustomMouseArea {
id: root
required property real borderThickness
required property Drawing drawing
required property Panels panels
required property ScreenState screenState
required property EdgeGeometry geometry
function inLeftPanel(panel: Item, x: real, y: real): bool {
return x < panel.x + panel.width + geometry.insetLeft(borderThickness) && withinPanelHeight(panel, x, y);
}
function withinPanelHeight(panel: Item, x: real, y: real): bool {
const panelY = panel.y + geometry.insetTop(borderThickness);
return y >= panelY && y <= panelY + panel.height;
}
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.BlankCursor
enabled: z > 0
hoverEnabled: true
focus: root.screenState.isDrawing
z: 2
Binding {
target: root.drawing.content
property: "hoverPoint"
value: Qt.point(root.mouseX, root.mouseY)
}
Keys.onPressed: e => {
const mod = e.modifiers;
const key = e.key;
if ((key == Qt.Key_Z) && (mod & Qt.ControlModifier) && (mod & Qt.ShiftModifier)) {
root.drawing.content.redo();
e.accepted = true;
} else if ((key == Qt.Key_Z) && (mod & Qt.ControlModifier)) {
root.drawing.content.undoLastGroup();
e.accepted = true;
}
}
onPositionChanged: event => {
const x = event.x;
const y = event.y;
if (!drawing.content.hoverVisible)
drawing.content.showHover();
if (event.buttons & Qt.LeftButton) {
drawing.content.appendPoint(x, y);
return;
}
if (!(event.buttons & Qt.LeftButton) && inLeftPanel(panels.drawing, x, y)) {
z = -2;
panels.drawing.expand();
drawing.content.hideHover();
}
}
onPressed: event => {
const x = event.x;
const y = event.y;
panels.drawing.collapse();
if (event.buttons & Qt.LeftButton) {
panels.drawing.collapse();
drawing.content.beginStroke(x, y);
return;
}
if (event.button & Qt.RightButton) {
drawing.content.clear();
return;
}
}
onReleased: event => {
const x = event.x;
const y = event.y;
if (event.button & Qt.LeftButton) {
drawing.content.endStroke();
drawing.content.showHover();
}
}
}
+121 -198
View File
@@ -1,25 +1,33 @@
import Quickshell
import QtQuick
import ZShell.Config
import ZShell.Internal
import qs.Components
import qs.Helpers
import qs.Modules.Bar.Popouts
Item {
CustomMouseArea {
id: root
required property Drawing drawing
required property DrawingInput input
required property Item bar
required property real borderThickness
property bool dashboardShortcutActive
required property Drawing drawing
required property EdgeGeometry geometry
property bool osdShortcutActive
required property Panels panels
required property Wrapper popouts
required property ShellScreen screen
required property ScreenState screenState
property bool dashboardShortcutActive
property point dragStart
property bool osdShortcutActive
property bool setInitialPoint: false
property bool singleGestureTriggered: false
property bool utilitiesShortcutActive
required property ScreenState screenState
function inBottomPanel(panel: Item, x: real, y: real): bool {
const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0));
@@ -56,124 +64,116 @@ Item {
return x >= panelX && x <= panelX + panel.width;
}
acceptedButtons: bar.fullscreen ? Qt.NoButton : Qt.AllButtons
anchors.fill: parent
hoverEnabled: true
DragHandler {
id: singleHandler
cursorShape: (active && centroid.pressPosition.y < root.bar.implicitHeight) ? Qt.ClosedHandCursor : undefined
dragThreshold: 0
enabled: !root.screenState.isDrawing
grabPermissions: PointerHandler.CanTakeOverFromHandlersOfSameType | PointerHandler.ApprovesTakeOverByAnything
maximumPointCount: 1
minimumPointCount: 1
target: null
onActiveChanged: {
if (!active)
root.singleGestureTriggered = false;
}
onCentroidChanged: {
const x = centroid.position.x;
const y = centroid.position.y;
const pressX = centroid.pressPosition.x;
const pressY = centroid.pressPosition.y;
const dragX = x - pressX;
const dragY = y - pressY;
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;
const inDashboard = root.inTopPanel(root.panels.dashboardWrapper, pressX, pressY);
if (inDashboard && 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;
}
onContainsMouseChanged: {
if (!containsMouse) {
if (!osdShortcutActive) {
screenState.osd = false;
panels.osd.hovered = false;
}
if (root.geometry.insetTop(root.borderThickness) > pressY && (Config.dashboard.showOnHover || !inDashboard)) {
if (dragY > 20) {
root.screenState.settings = true;
root.singleGestureTriggered = true;
} else if (dragY < -20) {
root.screenState.settings = false;
root.singleGestureTriggered = true;
}
if (!popouts.currentName.startsWith("traymenu") || Config.bar.tray.showOnHover) {
popouts.hasCurrent = false;
}
if (pressY > root.screen.height - root.geometry.insetBottom(root.borderThickness) && pressX < root.screen.width / 5 && dragY < -50)
root.screenState.clipboard = true;
if (Config.bar.autoHide || bar.fullscreen)
bar.isHovered = false;
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.screenState.dock = true;
root.singleGestureTriggered = 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 (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 (pressX <= Config.bar.border && dragX > 20) {
Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`);
root.singleGestureTriggered = true;
}
if (screenState.dashboard && Config.dashboard.showOnHover)
screenState.dashboard = false;
}
}
onPositionChanged: event => {
const x = event.x;
const y = event.y;
const dragX = x - dragStart.x;
const dragY = y - dragStart.y;
PointHandler {
id: drawingHandler
if (screenState.isDrawing && !inLeftPanel(panels.drawing, x, y)) {
input.z = 2;
panels.drawing.collapse();
}
property bool setInitialPoint: false
if (root.bar.fullscreen && !root.bar.isHovered)
unhideTimer.start();
acceptedButtons: Qt.LeftButton | Qt.RightButton
enabled: root.screenState.isDrawing && (!root.inLeftPanel(root.panels.drawing, hoverHandler.point.position.x, hoverHandler.point.position.y) || !root.panels.drawing.expanded)
if (pressed && dragStart.y >= screen.height - Config.bar.border && dragStart.x > screen.width / 5 && dragY < -Config.launcher.dragThreshold)
screenState.launcher = true;
onActiveChanged: {
if (!active) {
setInitialPoint = false;
root.drawing.content.endStroke();
} else {
root.panels.drawing.collapse();
const entryAt = bar.entryAt(geometry.axisPos(x, y));
const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboardWrapper, x, y) && (!entryAt || entryAt === "activeWindow");
if (!dashboardShortcutActive) {
screenState.dashboard = showDashboard;
} else if (showDashboard) {
dashboardShortcutActive = false;
}
if (pressed && geometry.insetTop(borderThickness) > dragStart.y && withinPanelWidth(panels.dashboardWrapper, x, y)) {
if (dragY > Config.dashboard.dragThreshold)
screenState.dashboard = true;
else if (dragY < -Config.dashboard.dragThreshold)
screenState.dashboard = false;
}
if (pressed && geometry.insetTop(borderThickness) > dragStart.y && !inTopPanel(panels.dashboardWrapper, dragStart.x, dragStart.y)) {
if (dragY > 20) {
screenState.settings = true;
} else if (dragY < -20) {
screenState.settings = false;
}
}
onPointChanged: {
if (!active)
return;
const x = point.position.x;
const y = point.position.y;
const origX = point.pressPosition.x;
const origY = point.pressPosition.y;
if (point.pressedButtons & Qt.RightButton) {
root.drawing.content.clear();
return;
if (pressed && dragStart.y > screen.height - geometry.insetBottom(borderThickness) && dragStart.x < screen.width / 5 && dragY < -50)
screenState.clipboard = true;
if (pressed && !Config.dock.hoverToReveal && dragStart.y > screen.height - geometry.insetTop(borderThickness) && dragStart.x > screen.width / 5 && !screenState.launcher)
if (dragY < -10)
screenState.dock = true;
if (pressed && dragStart.x > screen.width - Config.bar.border && dragStart.y < (screen.height / 2) && dragX < -Config.sidebar.dragThreshold)
screenState.sidebar = true;
if (pressed && dragStart.x >= screen.width - Config.bar.border && dragStart.y > (screen.height / 2) && dragX < -20)
Hypr.dispatch(`hl.dsp.focus({ workspace = 'r+1', on_current_monitor = true })`);
if (pressed && dragStart.x <= Config.bar.border && dragX > 20)
Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`);
if (!screenState.bar && Config.bar.autoHide && geometry.barContains(x, y, true))
bar.isHovered = true;
if (panels.sidebar.offsetScale === 1) {
const showOsd = inRightPanel(panels.osdWrapper, x, y);
if (showOsd) {
osdShortcutActive = false;
panels.osd.hovered = true;
}
if (x === 0 && y === 0 && origX === 0 && origY === 0)
return;
} else {
const outOfSidebar = x < width - panels.sidebar.width * (1 - panels.sidebar.offsetScale);
const showOsd = outOfSidebar && inRightPanel(panels.osdWrapper, x, y);
if (!setInitialPoint) {
setInitialPoint = true;
root.drawing.content.beginStroke(origX, origY);
return;
if (!osdShortcutActive) {
screenState.osd = showOsd;
panels.osd.hovered = showOsd;
} else if (showOsd) {
osdShortcutActive = false;
panels.osd.hovered = true;
}
root.drawing.content.appendPoint(x, y);
}
if (Config.dock.enable && Config.dock.hoverToReveal && !screenState.dock && !screenState.launcher && inBottomPanel(panels.dock, x, y))
screenState.dock = true;
if (geometry.barContains(x, y))
bar.checkPopout(geometry.axisPos(x, y));
}
onPressed: event => {
dragStart = Qt.point(event.x, event.y);
}
Timer {
@@ -184,98 +184,20 @@ Item {
running: false
onTriggered: {
if (hoverHandler.hovered)
if (root.containsMouse)
root.bar.isHovered = true;
}
}
HoverHandler {
id: hoverHandler
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.screenState.osd = false;
root.panels.osd.hovered = false;
}
if (!root.popouts.currentName.startsWith("traymenu") || Config.bar.tray.showOnHover) {
root.popouts.hasCurrent = false;
}
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.screenState.isDrawing) {
if (root.inLeftPanel(root.panels.drawing, x, y) && !(drawingHandler.point.pressedButtons & Qt.LeftButton)) {
root.panels.drawing.expand();
root.drawing.content.hideHover();
return;
}
root.drawing.content.showHover(x, y);
return;
}
if (!root.screenState.bar && Config.bar.autoHide && root.geometry.barContains(x, y, true))
root.bar.isHovered = true;
if (root.bar.fullscreen && !root.bar.isHovered)
unhideTimer.start();
if (root.panels.sidebar.offsetScale === 1) {
const showOsd = root.inRightPanel(root.panels.osdWrapper, x, y);
if (showOsd) {
root.osdShortcutActive = false;
root.panels.osd.hovered = true;
}
} else {
const outOfSidebar = x < root.width - root.panels.sidebar.width * (1 - root.panels.sidebar.offsetScale);
const showOsd = outOfSidebar && root.inRightPanel(root.panels.osdWrapper, x, y);
if (!root.osdShortcutActive) {
root.screenState.osd = showOsd;
root.panels.osd.hovered = showOsd;
} else if (showOsd) {
root.osdShortcutActive = false;
root.panels.osd.hovered = 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));
}
}
Connections {
function onDashboardChanged() {
function onDashboardChanged(): void {
if (root.screenState.dashboard) {
const inDashboardArea = root.inTopPanel(root.panels.dashboard, root.mouseX, root.mouseY);
const inDashboardArea = root.inTopPanel(root.panels.dashboardWrapper, root.mouseX, root.mouseY);
if (!inDashboardArea) {
root.dashboardShortcutActive = true;
}
if (root.panels.launcher.x + root.panels.launcher.width > root.panels.dashboardWrapper.x)
root.screenState.launcher = false;
root.screenState.launcher = false;
root.screenState.settings = false;
root.screenState.sidebar = false;
root.screenState.resources = false;
@@ -285,12 +207,15 @@ Item {
}
}
function onIsDrawingChanged() {
if (!root.screenState.isDrawing)
root.drawing.clear();
function onIsDrawingChanged(): void {
if (root.screenState.isDrawing) {
root.drawing.content.showHover();
} else {
root.drawing.content.clear();
}
}
function onLauncherChanged() {
function onLauncherChanged(): void {
if (!root.screenState.launcher) {
root.dashboardShortcutActive = false;
root.osdShortcutActive = false;
@@ -305,16 +230,13 @@ Item {
}
if (root.screenState.launcher) {
if (root.panels.dashboardWrapper.x < root.panels.launcher.x + root.panels.launcher.width) {
root.screenState.dashboard = false;
}
root.screenState.dashboard = false;
root.screenState.dock = false;
root.screenState.settings = false;
}
}
function onOsdChanged() {
function onOsdChanged(): void {
if (root.screenState.osd) {
const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY);
if (!inOsdArea) {
@@ -325,7 +247,7 @@ Item {
}
}
function onResourcesChanged() {
function onResourcesChanged(): void {
if (root.screenState.resources) {
if (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates"))
root.popouts.hasCurrent = false;
@@ -335,7 +257,7 @@ Item {
}
}
function onSettingsChanged() {
function onSettingsChanged(): void {
if (root.screenState.settings) {
root.screenState.resources = false;
root.screenState.dashboard = false;
@@ -345,9 +267,10 @@ Item {
}
}
function onSidebarChanged() {
function onSidebarChanged(): void {
if (root.screenState.sidebar) {
root.screenState.dashboard = false;
if (root.panels.dashboardWrapper.x + root.panels.dashboardWrapper.width > root.panels.sidebar.x)
root.screenState.dashboard = false;
root.screenState.settings = false;
root.popouts.hasCurrent = false;
}
-315
View File
@@ -1,315 +0,0 @@
import Quickshell
import QtQuick
import ZShell.Config
import ZShell.Internal
import qs.Components
import qs.Helpers
import qs.Modules.Bar.Popouts
CustomMouseArea {
id: root
required property Item bar
required property real borderThickness
property bool dashboardShortcutActive
property point dragStart
required property Drawing drawing
required property EdgeGeometry geometry
property bool osdShortcutActive
required property Panels panels
required property Wrapper popouts
required property ShellScreen screen
required property ScreenState screenState
property bool setInitialPoint: false
property bool singleGestureTriggered: false
property bool utilitiesShortcutActive
function inBottomPanel(panel: Item, x: real, y: real): bool {
const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0));
return y > root.height - (panelHeight + geometry.insetBottom(borderThickness)) && withinPanelWidth(panel, x, y);
}
function inLeftPanel(panel: Item, x: real, y: real): bool {
return x < panel.x + panel.width + geometry.insetLeft(borderThickness) && withinPanelHeight(panel, x, y);
}
function inPopoutArea(x: real, y: real): bool {
const panel = panels.popoutsWrapper;
if (!geometry.horizontal)
return inLeftPanel(panel, x, y);
return withinPanelWidth(panel, x, y) && withinPanelHeight(panel, x, y);
}
function inRightPanel(panel: Item, x: real, y: real): bool {
return x > panel.x + geometry.insetLeft(borderThickness) && withinPanelHeight(panel, x, y);
}
function inTopPanel(panel: Item, x: real, y: real): bool {
const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0));
return y < geometry.insetTop(borderThickness) + panelHeight && withinPanelWidth(panel, x, y);
}
function withinPanelHeight(panel: Item, x: real, y: real): bool {
const panelY = panel.y + geometry.insetTop(borderThickness);
return y >= panelY && y <= panelY + panel.height;
}
function withinPanelWidth(panel: Item, x: real, y: real): bool {
const panelX = panel.x + geometry.insetLeft(borderThickness);
return x >= panelX && x <= panelX + panel.width;
}
acceptedButtons: bar.fullscreen ? Qt.NoButton : Qt.AllButtons
anchors.fill: parent
hoverEnabled: true
onContainsMouseChanged: {
if (!containsMouse) {
if (!osdShortcutActive) {
screenState.osd = false;
panels.osd.hovered = false;
}
if (!popouts.currentName.startsWith("traymenu") || Config.bar.tray.showOnHover) {
popouts.hasCurrent = false;
}
if (Config.bar.autoHide || bar.fullscreen)
bar.isHovered = false;
if (screenState.dashboard && Config.dashboard.showOnHover)
screenState.dashboard = false;
}
}
onPositionChanged: event => {
const x = event.x;
const y = event.y;
const dragX = x - dragStart.x;
const dragY = y - dragStart.y;
if (screenState.isDrawing) {
if (pressed) {
drawing.content.appendPoint(x, y);
} else {
if (inLeftPanel(panels.drawing, x, y)) {
if (!(pressedButtons & Qt.LeftButton)) {
panels.drawing.expand();
drawing.content.hideHover();
Cursor.clearOverride();
}
} else {
Cursor.setOverride(Qt.BlankCursor);
drawing.content.showHover(x, y);
}
}
if (drawing.content.hoverVisible)
drawing.content.showHover(x, y);
return;
}
if (root.bar.fullscreen && !root.bar.isHovered)
unhideTimer.start();
if (pressed && dragStart.y >= screen.height - Config.bar.border && dragStart.x > screen.width / 5 && dragY < -Config.launcher.dragThreshold)
screenState.launcher = true;
const entryAt = bar.entryAt(geometry.axisPos(x, y));
const showDashboard = Config.dashboard.showOnHover && inTopPanel(panels.dashboardWrapper, x, y) && (!entryAt || entryAt === "activeWindow");
if (!dashboardShortcutActive) {
screenState.dashboard = showDashboard;
} else if (showDashboard) {
dashboardShortcutActive = false;
}
if (pressed && geometry.insetTop(borderThickness) > dragStart.y && withinPanelWidth(panels.dashboardWrapper, x, y)) {
if (dragY > Config.dashboard.dragThreshold)
screenState.dashboard = true;
else if (dragY < -Config.dashboard.dragThreshold)
screenState.dashboard = false;
}
if (pressed && geometry.insetTop(borderThickness) > dragStart.y && !inTopPanel(panels.dashboardWrapper, dragStart.x, dragStart.y)) {
if (dragY > 20) {
screenState.settings = true;
} else if (dragY < -20) {
screenState.settings = false;
}
}
if (pressed && dragStart.y > screen.height - geometry.insetBottom(borderThickness) && dragStart.x < screen.width / 5 && dragY < -50)
screenState.clipboard = true;
if (pressed && !Config.dock.hoverToReveal && dragStart.y > screen.height - geometry.insetTop(borderThickness) && dragStart.x > screen.width / 5 && !screenState.launcher)
if (dragY < -10)
screenState.dock = true;
if (pressed && dragStart.x > screen.width - Config.bar.border && dragStart.y < (screen.height / 2) && dragX < -Config.sidebar.dragThreshold)
screenState.sidebar = true;
if (pressed && dragStart.x >= screen.width - Config.bar.border && dragStart.y > (screen.height / 2) && dragX < -20)
Hypr.dispatch(`hl.dsp.focus({ workspace = 'r+1', on_current_monitor = true })`);
if (pressed && dragStart.x <= Config.bar.border && dragX > 20)
Hypr.dispatch(`hl.dsp.focus({ workspace = 'r-1', on_current_monitor = true })`);
if (!screenState.bar && Config.bar.autoHide && geometry.barContains(x, y, true))
bar.isHovered = true;
if (panels.sidebar.offsetScale === 1) {
const showOsd = inRightPanel(panels.osdWrapper, x, y);
if (showOsd) {
osdShortcutActive = false;
panels.osd.hovered = true;
}
} else {
const outOfSidebar = x < width - panels.sidebar.width * (1 - panels.sidebar.offsetScale);
const showOsd = outOfSidebar && inRightPanel(panels.osdWrapper, x, y);
if (!osdShortcutActive) {
screenState.osd = showOsd;
panels.osd.hovered = showOsd;
} else if (showOsd) {
osdShortcutActive = false;
panels.osd.hovered = true;
}
}
if (Config.dock.enable && Config.dock.hoverToReveal && !screenState.dock && !screenState.launcher && inBottomPanel(panels.dock, x, y))
screenState.dock = true;
if (geometry.barContains(x, y))
bar.checkPopout(geometry.axisPos(x, y));
}
onPressed: event => {
dragStart = Qt.point(event.x, event.y);
if (screenState.isDrawing) {
if (event.button & Qt.RightButton) {
drawing.content.clear();
return;
}
if (!inLeftPanel(panels.drawing, mouseX, mouseY))
drawing.content.beginStroke(event.x, event.y);
}
}
onPressedChanged: {
if (!pressed) {
if (screenState.isDrawing) {
setInitialPoint = false;
drawing.content.endStroke();
if (!inLeftPanel(panels.drawing, mouseX, mouseY))
drawing.content.showHover(mouseX, mouseY);
}
} else {
if (screenState.isDrawing) {
panels.drawing.collapse();
}
}
}
Timer {
id: unhideTimer
interval: Config.bar.revealDelay
repeat: false
running: false
onTriggered: {
if (root.containsMouse)
root.bar.isHovered = true;
}
}
Connections {
function onDashboardChanged(): void {
if (root.screenState.dashboard) {
const inDashboardArea = root.inTopPanel(root.panels.dashboardWrapper, root.mouseX, root.mouseY);
if (!inDashboardArea) {
root.dashboardShortcutActive = true;
}
root.screenState.launcher = false;
root.screenState.settings = false;
root.screenState.sidebar = false;
root.screenState.resources = false;
root.popouts.hasCurrent = false;
} else {
root.dashboardShortcutActive = false;
}
}
function onIsDrawingChanged(): void {
if (root.screenState.isDrawing) {
Cursor.setOverride(Qt.BlankCursor);
} else {
Cursor.clearOverride();
}
}
function onLauncherChanged(): void {
if (!root.screenState.launcher) {
root.dashboardShortcutActive = false;
root.osdShortcutActive = false;
root.utilitiesShortcutActive = false;
const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY);
if (!inOsdArea) {
root.screenState.osd = false;
root.panels.osd.hovered = false;
}
}
if (root.screenState.launcher) {
root.screenState.dashboard = false;
root.screenState.dock = false;
root.screenState.settings = false;
}
}
function onOsdChanged(): void {
if (root.screenState.osd) {
const inOsdArea = root.inRightPanel(root.panels.osd, root.mouseX, root.mouseY);
if (!inOsdArea) {
root.osdShortcutActive = true;
}
} else {
root.osdShortcutActive = false;
}
}
function onResourcesChanged(): void {
if (root.screenState.resources) {
if (root.popouts.currentName.startsWith("audio") || root.popouts.currentName.startsWith("updates"))
root.popouts.hasCurrent = false;
root.screenState.settings = false;
root.screenState.dashboard = false;
}
}
function onSettingsChanged(): void {
if (root.screenState.settings) {
root.screenState.resources = false;
root.screenState.dashboard = false;
root.screenState.sidebar = false;
root.panels.popouts.hasCurrent = false;
root.screenState.launcher = false;
}
}
function onSidebarChanged(): void {
if (root.screenState.sidebar) {
if (root.panels.dashboardWrapper.x + root.panels.dashboardWrapper.width > root.panels.sidebar.x)
root.screenState.dashboard = false;
root.screenState.settings = false;
root.popouts.hasCurrent = false;
}
}
target: root.screenState
}
}
+11 -5
View File
@@ -74,7 +74,7 @@ Item {
id: osdWrapper
anchors.right: parent.right
anchors.rightMargin: sidebar.width * (1 - sidebar.offsetScale)
anchors.rightMargin: (sidebar.width - 5) * (1 - sidebar.offsetScale)
y: ZUtils.snap((parent.height - implicitHeight) / 2, root)
@@ -86,7 +86,8 @@ Item {
Osd.Wrapper {
id: osd
anchors.fill: parent
anchors.right: parent.right
screen: root.screen
screenState: root.screenState
sidebarOrSessionVisible: sidebar.visible
@@ -154,8 +155,10 @@ Item {
property real offsetScale: dashboard.shouldBeActive ? 0 : 1
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
x: ZUtils.snap((parent.width - implicitWidth) / 2, dashboardWrapper)
clip: offsetScale > 0
implicitHeight: dashboard.implicitHeight * (1 - offsetScale)
implicitWidth: dashboard.implicitWidth
@@ -194,8 +197,11 @@ Item {
Settings.Wrapper {
id: settings
anchors.centerIn: parent
anchors.verticalCenterOffset: (-implicitHeight - 5 - ((root.height - implicitHeight) / 2)) * offsetScale
x: ZUtils.snap((parent.width - implicitWidth) / 2, settings)
y: ZUtils.snap((parent.height - implicitHeight) / 2 - (implicitHeight + 5 + (parent.height - implicitHeight) / 2) * offsetScale, settings)
// anchors.centerIn: parent
// anchors.verticalCenterOffset: (-implicitHeight - 5 - ((root.height - implicitHeight) / 2)) * offsetScale
panels: root
screen: root.screen
screenState: root.screenState
+28 -9
View File
@@ -2,16 +2,14 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Hyprland
import ZShell.Blobs
import ZShell.Config
import qs.Daemons
import qs.Components
import qs.Modules.Bar
import ZShell.Config
import qs.Helpers
import qs.Drawers
import qs.Services
@@ -56,7 +54,7 @@ CustomWindow {
property color surfaceColor: Colors.tPalette.m3surface
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.keyboardFocus: screenState.screenshot ? WlrKeyboardFocus.Exclusive : screenState.launcher || screenState.settings || screenState.sidebar ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
WlrLayershell.keyboardFocus: screenState.screenshot ? WlrKeyboardFocus.Exclusive : screenState.launcher || screenState.settings || screenState.sidebar || screenState.isDrawing ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
WlrLayershell.layer: (fsTransitionProg > 0 && Config.general.showOverFullscreen) || (hasSpecialWorkspace && hasFullscreenOnNormalWs) ? WlrLayer.Overlay : WlrLayer.Top
color: "transparent"
mask: screenState.isDrawing || screenState.screenshot ? null : (hasFullscreen && !bar.isHovered ? emptyRegion : regions)
@@ -192,7 +190,7 @@ CustomWindow {
anchors.fill: parent
color: root.surfaceColor
shadowColor: Qt.alpha(Colors.palette.m3shadow, Math.max(0, root.shadowOpacity))
shadowRadius: 18
shadowRadius: 10
smoothing: Config.bar.smoothing
}
@@ -248,7 +246,6 @@ CustomWindow {
atRestOverride: panels.osd.offsetScale < 0.99
deformAmount: 0.1
implicitHeight: panels.osd.height
implicitWidth: panels.osd.width
panel: panels.osdWrapper
radius: 20
@@ -260,7 +257,7 @@ CustomWindow {
atRestOverride: panel.implicitHeight > 0
panel: panels.notifications
radius: Tokens.rounding.large
radius: Tokens.rounding.large + Tokens.padding.smaller
}
PanelBg {
@@ -308,7 +305,7 @@ CustomWindow {
PanelBg {
id: settingsBg
deformAmount: 0.03
deformAmount: 0.01
panel: panels.settings
radius: Tokens.rounding.largeIncreased + Tokens.padding.small
}
@@ -347,12 +344,31 @@ CustomWindow {
Drawing {
id: drawing
readonly property int extraWidth: drawingBg.radius * 2
anchors.fill: parent
holeRect: Qt.rect(root.borderThickness + panels.drawing.x - extraWidth, panels.drawing.y + bar.implicitHeight, panels.drawing.implicitWidth + extraWidth, panels.drawing.implicitHeight)
holeRadius: drawingBg.radius
screenState: root.screenState
z: 2
}
Interactions2 {
LazyLoader {
id: lazyLoader
activeAsync: root.screenState.isDrawing
component: DrawingInput {
borderThickness: root.borderLayoutThickness
drawing: drawing
panels: panels
screenState: root.screenState
geometry: geometry
parent: root.contentItem
}
}
Interactions {
id: interactions
anchors.fill: parent
@@ -365,6 +381,8 @@ CustomWindow {
popouts: panels.popouts
screen: root.screen
screenState: root.screenState
input: lazyLoader.item
z: 1
Panels {
id: panels
@@ -442,6 +460,7 @@ CustomWindow {
parent: root.contentItem
screen: root.screen
screenState: root.screenState
z: 4
}
}
+2 -1
View File
@@ -8,6 +8,7 @@ import ZShell.Components
import qs.Components
import qs.Modules.Bar.Components
import qs.Modules.Bar.Popouts
import ZShell
SnapLayout {
id: root
@@ -23,7 +24,7 @@ SnapLayout {
function axisCenterOf(item: Item): real {
const c = item.mapToItem(root, item.implicitWidth / 2, item.implicitHeight / 2);
return horizontal ? Math.floor(c.x) : Math.floor(c.y);
return horizontal ? ZUtils.snap(c.x, item) : ZUtils.snap(c.y, item);
}
function checkPopout(pos: real): void {
+4 -4
View File
@@ -4,6 +4,7 @@ import QtQuick
import Quickshell
import qs.Components
import ZShell.Config
import ZShell
Item {
id: root
@@ -36,7 +37,7 @@ Item {
const diff = parent.width - Math.floor(off + content.nonAnimWidth);
if (diff < 0)
return off + diff;
return Math.floor(Math.max(off, 0));
return ZUtils.snap(Math.max(off, 0), root);
}
y: {
if (horizontal)
@@ -46,12 +47,11 @@ Item {
const diff = parent.height - Math.floor(off + content.nonAnimHeight);
if (diff < 0)
return off + diff;
return Math.max(off, 0);
return ZUtils.snap(Math.max(off, 0), root);
}
Behavior on offsetScale {
Anim {
}
Anim {}
}
Behavior on x {
enabled: !root.horizontal || root.offsetScale < 1
+4 -3
View File
@@ -13,6 +13,7 @@ Item {
readonly property Popout currentPopout: content.children.find(c => c.shouldBeActive) ?? null
required property bool horizontal
required property PopoutState popouts
required property Wrapper wrapper
implicitHeight: (currentPopout?.implicitHeight ?? 0) + Tokens.padding.smaller * 2
implicitWidth: (currentPopout?.implicitWidth ?? 0) + Tokens.padding.smaller * 2
@@ -98,10 +99,10 @@ Item {
readonly property bool shouldBeActive: root.popouts.currentName === name
active: false
anchors.centerIn: opacity === 1 ? undefined : parent
anchors.centerIn: parent
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: opacity === 1 ? parent.top : undefined
anchors.topMargin: Tokens.padding.smaller
// anchors.top: opacity === 1 ? parent.top : undefined
// anchors.topMargin: Tokens.padding.smaller
opacity: 0
scale: 0.8
+9 -16
View File
@@ -16,7 +16,7 @@ StackView {
id: root
readonly property int itemHeight: 30
readonly property int panelRadius: ((itemHeight / 2) + Tokens.padding.extraSmall) * Tokens.rounding.scale
readonly property int panelRadius: ((itemHeight / 2) + Tokens.padding.smaller) * Tokens.rounding.scale
required property PopoutState popouts
property int rootWidth: 0
required property QsMenuHandle trayItem
@@ -27,20 +27,15 @@ StackView {
initialItem: SubMenu {
handle: root.trayItem
}
popEnter: NoAnim {
}
popExit: NoAnim {
}
pushEnter: NoAnim {
}
pushExit: NoAnim {
}
popEnter: NoAnim {}
popExit: NoAnim {}
pushEnter: NoAnim {}
pushExit: NoAnim {}
Component {
id: subMenuComp
SubMenu {
}
SubMenu {}
}
component NoAnim: Transition {
@@ -49,8 +44,8 @@ StackView {
}
}
component SubMenu: SnapLayout {
orientation: Qt.Vertical
id: menu
orientation: Qt.Vertical
required property QsMenuHandle handle
property bool isSubMenu
@@ -61,12 +56,10 @@ orientation: Qt.Vertical
spacing: Tokens.spacing.extraSmall
Behavior on opacity {
Anim {
}
Anim {}
}
Behavior on scale {
Anim {
}
Anim {}
}
Component.onCompleted: shown = true
+4 -2
View File
@@ -2,6 +2,7 @@ import Quickshell
import QtQuick
import qs.Components
import ZShell.Config
import ZShell
Item {
id: root
@@ -26,8 +27,8 @@ Item {
detachedMode = "";
}
implicitHeight: nonAnimHeight
implicitWidth: nonAnimWidth
implicitHeight: ZUtils.snap(nonAnimHeight, root)
implicitWidth: ZUtils.snap(nonAnimWidth, root)
Behavior on implicitHeight {
enabled: root.offsetScale < 1
@@ -57,6 +58,7 @@ Item {
shouldBeActive: root.hasCurrent
sourceComponent: Content {
wrapper: root
horizontal: root.horizontal
popouts: popoutState
}
+2 -2
View File
@@ -30,8 +30,8 @@ Item {
}
SnapLayout {
orientation: Qt.Vertical
id: layout
orientation: Qt.Vertical
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.large
@@ -77,7 +77,7 @@ orientation: Qt.Vertical
required property string icon
implicitSize: ZUtils.snap((root.height - layout.anchors.margins * 2 - layout.spacing * 4) / 5, root)
implicitSize: (root.height - layout.anchors.margins * 2 - layout.spacing * 4) / 5
Behavior on clampedVal {
Anim {}
+4 -4
View File
@@ -17,7 +17,7 @@ Item {
implicitWidth: btn.implicitWidth * 0.9
implicitHeight: btn.implicitHeight * 0.9
BlobGroup {
BlobGroup2 {
id: blobGroup
color: Colors.palette.m3surfaceContainerHighest
@@ -29,7 +29,7 @@ Item {
}
}
BlobRect {
BlobRect2 {
id: btnRect
anchors.fill: parent
@@ -42,7 +42,7 @@ Item {
}
}
BlobRect {
BlobRect2 {
id: rect
anchors.right: parent.right
@@ -143,8 +143,8 @@ Item {
]
SnapLayout {
orientation: Qt.Vertical
id: layout
orientation: Qt.Vertical
anchors.centerIn: parent
spacing: Tokens.spacing.extraSmall
+5 -5
View File
@@ -42,7 +42,7 @@ SnapLayout {
}
SnapLayout {
orientation: Qt.Vertical
orientation: Qt.Vertical
Layout.fillWidth: true
Layout.fillHeight: true
Layout.preferredWidth: root.width / 2
@@ -71,11 +71,11 @@ orientation: Qt.Vertical
id: tempGraph
anchors.fill: parent
values: root.forecastToday.map(e => e.tempC)
values: root.forecastToday.map(e => e?.tempC ?? null)
verticalLineStep: 3
xSteps: 24
horizontalLineStep: 10
minHorizontalLines: 4
minHorizontalLines: 3
yPaddingTop: horizontalLineStep
yPaddingBottom: horizontalLineStep
xLabel: i => root.formatHour(i)
@@ -95,7 +95,7 @@ orientation: Qt.Vertical
}
SnapLayout {
orientation: Qt.Vertical
orientation: Qt.Vertical
Layout.fillWidth: true
Layout.fillHeight: true
Layout.preferredWidth: root.width / 2
@@ -132,7 +132,7 @@ orientation: Qt.Vertical
yPaddingTop: 20
verticalLineStep: 3
lastXLine: false
horizontalLineStep: 20
horizontalLineStep: 30
xLabel: i => root.formatHour(i)
yLabel: v => v + "%"
+3 -17
View File
@@ -53,24 +53,10 @@ Item {
lock: root.lock
}
SnapLayout {
NotifDock {
Layout.fillWidth: true
spacing: Tokens.spacing.medium
orientation: Qt.Vertical
CustomRect {
Layout.fillHeight: true
Layout.fillWidth: true
bottomRightRadius: Tokens.rounding.largeIncreased
color: Colors.tPalette.m3surfaceContainer
radius: Tokens.rounding.small
topRightRadius: Tokens.rounding.largeIncreased
NotifDock {
lock: root.lock
}
}
Layout.fillHeight: true
lock: root.lock
}
}
}
+60 -18
View File
@@ -6,8 +6,10 @@ import QtQuick
import QtQuick.Layouts
import ZShell.Components
import ZShell.Config
import ZShell.Services
import qs.Modules
import qs.Components
import qs.Components.Notifs
import qs.Helpers
import qs.Daemons
import qs.Services
@@ -23,7 +25,6 @@ CustomRect {
topRightRadius: Tokens.rounding.largeIncreased
SnapLayout {
orientation: Qt.Vertical
anchors.fill: parent
anchors.margins: Tokens.padding.large
@@ -35,7 +36,7 @@ CustomRect {
color: Colors.palette.m3outline
elide: Text.ElideRight
font: Tokens.font.mono.builders.small.weight(Font.Medium).build()
text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications")
text: NotifServer.notClosed.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.notClosed.length).arg(NotifServer.notClosed.length === 1 ? "" : "s") : qsTr("Notifications")
}
Item {
@@ -45,29 +46,26 @@ CustomRect {
Loader {
active: opacity > 0
anchors.centerIn: parent
opacity: NotifServer.list.length > 0 ? 0 : 1
opacity: NotifServer.notClosed.length > 0 ? 0 : 1
Behavior on opacity {
Anim {
duration: Tokens.anim.durations.extraLarge
}
}
sourceComponent: SnapLayout {
orientation: Qt.Vertical
spacing: Tokens.spacing.largeIncreased
CustomText {
Layout.alignment: Qt.AlignHCenter
color: Colors.palette.m3outlineVariant
font: Tokens.font.headline.builders.small.width(90).build()
text: qsTr("No Notifications")
}
sourceComponent: CustomText {
color: Colors.palette.m3outlineVariant
font: Tokens.font.headline.builders.small.width(90).build()
text: qsTr("No Notifications")
}
}
CustomListView {
id: list
anchors.fill: parent
clip: true
orientation: Qt.Vertical
spacing: Tokens.spacing.small
add: Transition {
@@ -85,7 +83,7 @@ CustomRect {
to: 1
}
}
delegate: NotifGroup {}
delegate: NotifRow {}
displaced: Transition {
Anim {
properties: "opacity,scale"
@@ -99,10 +97,7 @@ CustomRect {
}
}
model: ScriptModel {
values: {
const list = NotifServer.notClosed.map(n => [n.appName, null]);
return [...new Map(list).keys()];
}
values: [...NotifServer.groups].filter(g => g.count > 0)
}
move: Transition {
Anim {
@@ -130,4 +125,51 @@ CustomRect {
}
}
}
component NotifRow: Item {
id: row
required property NotificationGroup modelData
property bool expanded: false
function dismissAll(): void {
for (const n of [...modelData.activeNotifs])
n.dismiss();
}
implicitHeight: card.implicitHeight
implicitWidth: list.width
NotifSwipeArea {
id: area
implicitHeight: card.implicitHeight
implicitWidth: row.implicitWidth
expanded: row.expanded
onDismissed: row.dismissAll()
onRequestExpand: expand => row.expanded = expand
onSwiped: row.dismissAll()
Item {
anchors.top: parent.top
implicitHeight: card.implicitHeight
implicitWidth: row.implicitWidth
clip: true
NotifGroupCard {
id: card
baseLayer: 1
container: list
expanded: row.expanded
group: row.modelData
onRequestExpand: expand => row.expanded = expand
onSwiped: notif => notif.dismiss()
}
}
}
}
}
-324
View File
@@ -1,324 +0,0 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Widgets
import Quickshell.Services.Notifications
import QtQuick
import QtQuick.Layouts
import ZShell.Components
import ZShell.Config
import qs.Modules
import qs.Components
import qs.Helpers
import qs.Daemons
import qs.Services
CustomRect {
id: root
readonly property string appIcon: notifs.find(n => n.appIcon.length > 0)?.appIcon ?? ""
property bool expanded
readonly property string image: notifs.find(n => n.image.length > 0)?.image ?? ""
required property string modelData
readonly property list<var> notifs: NotifServer.list.filter(notif => notif.appName === modelData)
readonly property string urgency: notifs.some(n => n.urgency === NotificationUrgency.Critical) ? "critical" : notifs.some(n => n.urgency === NotificationUrgency.Normal) ? "normal" : "low"
anchors.left: parent?.left
anchors.right: parent?.right
clip: true
color: root.urgency === "critical" ? Colors.palette.m3secondaryContainer : Colors.layer(Colors.palette.m3surfaceContainerHigh, 2)
implicitHeight: content.implicitHeight + Tokens.padding.small * 2
radius: Tokens.rounding.medium
Behavior on implicitHeight {
Anim {
duration: Tokens.anim.durations.expressiveDefaultSpatial
easing: Tokens.anim.expressiveDefaultSpatial
}
}
SnapLayout {
id: content
anchors.left: parent.left
anchors.margins: Tokens.padding.small
anchors.right: parent.right
anchors.top: parent.top
spacing: Tokens.spacing.medium
Item {
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
implicitHeight: Config.notifs.sizes.image
implicitWidth: Config.notifs.sizes.image
Component {
id: imageComp
Image {
asynchronous: true
cache: false
fillMode: Image.PreserveAspectCrop
height: Config.notifs.sizes.image
source: Qt.resolvedUrl(root.image)
visible: Config.lock.showNotifIcon
width: Config.notifs.sizes.image
}
}
Component {
id: appIconComp
ColoredIcon {
color: root.urgency === "critical" ? Colors.palette.m3onError : root.urgency === "low" ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer
implicitSize: Math.round(Config.notifs.sizes.image * 0.6)
layer.enabled: root.appIcon.endsWith("symbolic")
source: Quickshell.iconPath(root.appIcon)
}
}
Component {
id: materialIconComp
MaterialIcon {
color: root.urgency === "critical" ? Colors.palette.m3onError : root.urgency === "low" ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer
fontStyle: Tokens.font.icon.large
text: Icons.getNotifIcon(root.notifs[0]?.summary, root.urgency)
}
}
ClipRect {
anchors.fill: parent
color: root.urgency === "critical" ? Colors.palette.m3error : root.urgency === "low" ? Colors.layer(Colors.palette.m3surfaceContainerHighest, 3) : Colors.palette.m3secondaryContainer
radius: Tokens.rounding.full
Loader {
anchors.centerIn: parent
sourceComponent: root.image ? imageComp : root.appIcon ? appIconComp : materialIconComp
}
}
Loader {
active: root.appIcon && root.image
anchors.bottom: parent.bottom
anchors.right: parent.right
sourceComponent: CustomRect {
color: root.urgency === "critical" ? Colors.palette.m3error : root.urgency === "low" ? Colors.palette.m3surfaceContainerHighest : Colors.palette.m3secondaryContainer
implicitHeight: Config.notifs.sizes.badge
implicitWidth: Config.notifs.sizes.badge
radius: Tokens.rounding.full
ColoredIcon {
anchors.centerIn: parent
color: root.urgency === "critical" ? Colors.palette.m3onError : root.urgency === "low" ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer
implicitSize: Math.round(Config.notifs.sizes.badge * 0.6)
layer.enabled: root.appIcon.endsWith("symbolic")
source: Quickshell.iconPath(root.appIcon)
}
}
}
}
SnapLayout {
Layout.bottomMargin: -Tokens.padding.extraSmall / 2 - (root.expanded ? 0 : spacing)
Layout.fillWidth: true
Layout.topMargin: -Tokens.padding.extraSmall
spacing: Math.round(Tokens.spacing.small / 2)
orientation: Qt.Vertical
SnapLayout {
Layout.bottomMargin: -parent.spacing
Layout.fillWidth: true
spacing: Tokens.spacing.small
CustomText {
Layout.fillWidth: true
color: Colors.palette.m3onSurfaceVariant
elide: Text.ElideRight
font: Tokens.font.body.small
text: root.modelData
}
CustomText {
animate: true
color: Colors.palette.m3outline
font: Tokens.font.body.small
text: root.notifs[0]?.timeStr ?? ""
}
CustomRect {
Layout.preferredWidth: root.notifs.length > Config.notifs.groupPreviewNum ? implicitWidth : 0
color: root.urgency === "critical" ? Colors.palette.m3error : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2)
implicitHeight: groupCount.implicitHeight + Tokens.padding.extraSmall
implicitWidth: expandBtn.implicitWidth + Tokens.padding.small * 2
opacity: root.notifs.length > Config.notifs.groupPreviewNum ? 1 : 0
radius: Tokens.rounding.full
Behavior on Layout.preferredWidth {
Anim {}
}
Behavior on opacity {
Anim {}
}
StateLayer {
color: root.urgency === "critical" ? Colors.palette.m3onError : Colors.palette.m3onSurface
onClicked: {
root.expanded = !root.expanded;
}
}
SnapLayout {
id: expandBtn
anchors.centerIn: parent
spacing: Tokens.spacing.small / 2
CustomText {
id: groupCount
Layout.leftMargin: Tokens.padding.extraSmall / 2
animate: true
color: root.urgency === "critical" ? Colors.palette.m3onError : Colors.palette.m3onSurface
font: Tokens.font.body.small
text: root.notifs.length
}
MaterialIcon {
Layout.rightMargin: -Tokens.padding.extraSmall / 2
animate: true
color: root.urgency === "critical" ? Colors.palette.m3onError : Colors.palette.m3onSurface
text: root.expanded ? "expand_less" : "expand_more"
}
}
}
}
Repeater {
model: ScriptModel {
values: root.notifs.slice(0, Config.notifs.groupPreviewNum)
}
NotifLine {
id: notif
ParallelAnimation {
running: true
Anim {
from: 0
property: "opacity"
target: notif
to: 1
}
Anim {
from: 0.7
property: "scale"
target: notif
to: 1
}
Anim {
from: 0
property: "preferredHeight"
target: notif.Layout
to: notif.implicitHeight
}
}
ParallelAnimation {
running: notif.modelData.closed
onFinished: notif.modelData.unlock(notif)
Anim {
property: "opacity"
target: notif
to: 0
}
Anim {
property: "scale"
target: notif
to: 0.7
}
Anim {
property: "preferredHeight"
target: notif.Layout
to: 0
}
}
}
}
Loader {
Layout.fillWidth: true
Layout.preferredHeight: root.expanded ? implicitHeight : 0
active: opacity > 0
opacity: root.expanded ? 1 : 0
Behavior on opacity {
Anim {}
}
sourceComponent: SnapLayout {
orientation: Qt.Vertical
spacing: Tokens.spacing.extraSmall
Repeater {
model: ScriptModel {
values: root.notifs.slice(Config.notifs.groupPreviewNum)
}
NotifLine {}
}
}
}
}
}
component NotifLine: CustomText {
id: notifLine
required property NotifServer.Notif modelData
Layout.fillWidth: true
color: root.urgency === "critical" ? Colors.palette.m3onSecondaryContainer : Colors.palette.m3onSurface
text: {
if (!Config.lock.showNotifContent)
return "Unlock to view";
const summary = modelData.summary.replace(/\n/g, " ");
const body = modelData.body.replace(/\n/g, " ");
const color = root.urgency === "critical" ? Colors.palette.m3secondary : Colors.palette.m3outline;
if (metrics.text === metrics.elidedText)
return `${summary} <span style='color:${color}'>${body}</span>`;
const t = metrics.elidedText.length - 3;
if (t < summary.length)
return `${summary.slice(0, t)}...`;
return `${summary} <span style='color:${color}'>${body.slice(0, t - summary.length)}...</span>`;
}
textFormat: Text.MarkdownText
Component.onCompleted: modelData.lock(this)
Component.onDestruction: modelData.unlock(this)
TextMetrics {
id: metrics
elide: Text.ElideRight
elideWidth: notifLine.width
font.family: notifLine.font.family
font.pointSize: notifLine.font.pointSize
text: `${notifLine.modelData.summary} ${notifLine.modelData.body}`.replace(/\n/g, " ")
}
}
}
+137 -86
View File
@@ -3,11 +3,14 @@ pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Widgets
import QtQuick
import ZShell.Config
import ZShell.Components
import ZShell.Config
import ZShell.Services
import qs.Components
import qs.Components.Notifs
import qs.Daemons
import qs.Helpers
import qs.Services
Item {
id: root
@@ -20,13 +23,10 @@ Item {
anchors.right: parent.right
anchors.top: parent.top
implicitHeight: {
const count = list.count;
if (count === 0)
if (list.count === 0)
return 0;
let height = (count - 1) * Tokens.spacing.small;
for (let i = 0; i < count; i++)
height += (list.itemAtIndex(i) as NotifWrapper)?.nonAnimHeight ?? 0;
let height = list.layoutHeight;
if (panels.popouts.hasCurrent && (panels.popouts.currentCenter + (panels.popouts.current?.width / 2)) > panels.notifications.x)
return 0;
@@ -53,36 +53,55 @@ Item {
anchors.fill: parent
anchors.margins: root.padding
clip: true
// radius: Tokens.rounding.large - root.padding
CustomListView {
id: list
KineticFlickable {
id: view
anchors.fill: parent
cacheBuffer: (QsWindow.window as QsWindow)?.screen.height ?? 0
orientation: Qt.Vertical
spacing: 0
delegate: NotifWrapper {}
displaced: Transition {
Anim {
property: "y"
}
}
model: ScriptModel {
values: {
const popups = NotifServer.popups.filter(n => !n.closed);
contentHeight: list.implicitHeight
contentWidth: width
flickableDirection: Flickable.VerticalFlick
if (!Config.notifs.showInFullscreen) {
if (Hypr.monitorFor(root.panels.screen).activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false)
return [];
LazyListView {
id: list
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: contentHeight
spacing: Tokens.spacing.medium
readyDelay: 1
cacheBuffer: (QsWindow.window as QsWindow)?.screen.height ?? 0
removeDuration: Tokens.anim.durations.normal
useCustomViewport: true
viewport: Qt.rect(0, view.contentY, width, view.height)
model: ScriptModel {
values: {
if (!Config.notifs.showInFullscreen) {
if (Hypr.monitorFor(root.panels.screen).activeWorkspace?.toplevels.values.some(t => t.lastIpcObject.fullscreen > 1) ?? false)
return [];
}
return [...NotifServer.popupGroups];
}
return popups;
}
}
move: Transition {
delegate: NotifWrapper {}
onViewportAdjustNeeded: d => {
if (contentYAnim.running)
contentYAnim.complete();
contentYAnim.to = Math.max(0, view.contentY + d);
contentYAnim.start();
}
Anim {
property: "y"
id: contentYAnim
target: view
property: "contentY"
}
}
}
@@ -95,74 +114,106 @@ Item {
component NotifWrapper: Item {
id: wrapper
property int idx
required property int index
required property NotifServer.Notif modelData
readonly property alias nonAnimHeight: notif.nonAnimHeight
required property NotificationGroup modelData
property bool expanded: Config.notifs.openExpanded
readonly property bool holding: area.containsMouse || area.pressed || card.rowPressed
implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : Tokens.spacing.small)
implicitWidth: notif.implicitWidth
ListView.onRemove: removeAnim.start()
onIndexChanged: {
if (index !== -1)
idx = index;
function forEachPopup(fn: var): void {
for (const n of [...card.notifs])
fn(n);
}
SequentialAnimation {
id: removeAnim
PropertyAction {
property: "ListView.delayRemove"
target: wrapper
value: true
}
PropertyAction {
property: "enabled"
target: wrapper
value: false
}
PropertyAction {
property: "implicitHeight"
target: wrapper
value: 0
}
PropertyAction {
property: "z"
target: wrapper
value: 1
}
Anim {
property: "x"
target: notif
to: (notif.x >= 0 ? Config.notifs.sizes.width : -Config.notifs.sizes.width) * 2
}
PropertyAction {
property: "ListView.delayRemove"
target: wrapper
value: false
}
function holdPopups(): void {
forEachPopup(n => n.holdPopup());
}
CustomRect {
LazyListView.trackViewport: !expanded && card.nonAnimHeight < card.implicitHeight
LazyListView.preferredHeight: LazyListView.removing ? 0 : card.nonAnimHeight
LazyListView.visibleHeight: LazyListView.removing ? 0 : card.implicitHeight
LazyListView.onRemovingChanged: {
if (!wrapper.LazyListView.removing)
return;
slideOut.to = (area.x >= 0 ? root.implicitWidth : -root.implicitWidth) * 2;
slideOut.start();
}
implicitWidth: area.implicitWidth
implicitHeight: card.implicitHeight
enabled: !LazyListView.removing
z: LazyListView.removing ? 1 : 0
onHoldingChanged: {
if (holding)
holdPopups();
else
forEachPopup(n => n.releasePopup());
}
Connections {
function onNotifsChanged(): void {
if (wrapper.holding)
wrapper.holdPopups();
}
target: wrapper.modelData
}
Anim {
id: slideOut
target: area
property: "x"
duration: Tokens.anim.durations.normal
easing: Tokens.anim.emphasized
}
Item {
anchors.top: parent.top
anchors.topMargin: wrapper.idx === 0 ? 0 : 8
color: "transparent"
implicitHeight: notif.implicitHeight
implicitWidth: notif.implicitWidth
radius: Tokens.rounding.small
Notification {
id: notif
implicitWidth: area.implicitWidth
implicitHeight: card.implicitHeight
clip: true
NotifSwipeArea {
id: area
implicitWidth: root.implicitWidth - root.padding * 2
modelData: wrapper.modelData
implicitHeight: card.implicitHeight
x: wrapper.LazyListView.adding ? implicitWidth : 0
expanded: wrapper.expanded
onRequestExpand: expand => wrapper.expanded = expand
onSwiped: wrapper.forEachPopup(n => n.dismissPopup())
onDismissed: wrapper.forEachPopup(n => n.dismiss())
onClicked: event => {
if (!Config.notifs.actionOnClick || event.button !== Qt.LeftButton || card.notifs.length !== 1)
return;
const actions = card.notifs[0].actions;
if (actions.length === 1)
actions[0].invoke();
}
NotifGroupCard {
id: card
group: wrapper.modelData
container: view
expanded: wrapper.expanded
onRequestExpand: expand => wrapper.expanded = expand
onSwiped: notif => notif.dismissPopup()
onLinkActivated: notif => notif.dismissPopup()
}
}
}
Behavior on y {
enabled: wrapper.LazyListView.ready
Anim {}
}
}
}
-510
View File
@@ -1,510 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Shapes
import Quickshell
import Quickshell.Services.Notifications
import ZShell.Components
import qs.Daemons
import qs.Helpers
import ZShell.Config
import qs.Components
import qs.Services
CustomRect {
id: root
property bool expanded: Config.notifs.openExpanded
readonly property bool hasAppIcon: modelData.appIcon.length > 0
readonly property bool hasImage: modelData.image.length > 0
required property NotifServer.Notif modelData
readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? Tokens.spacing.extraSmall * 2 + appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2
color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondaryContainer : Colors.tPalette.m3surfaceContainer
implicitHeight: inner.implicitHeight
radius: Tokens.rounding.large - Tokens.padding.smaller
x: implicitWidth
Behavior on x {
Anim {
easing: Tokens.anim.emphasizedDecel
}
}
Component.onCompleted: {
x = 0;
modelData.lock(this);
}
Component.onDestruction: modelData.unlock(this)
MouseArea {
property int startY
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
anchors.fill: parent
cursorShape: root.expanded && body.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined
drag.axis: Drag.XAxis
drag.target: parent
hoverEnabled: true
preventStealing: true
onClicked: event => {
if (!Config.notifs.actionOnClick || event.button !== Qt.LeftButton)
return;
const actions = root.modelData.actions;
if (actions.length === 1)
actions[0].invoke();
}
onEntered: root.modelData.timer.stop()
onExited: {
if (!pressed)
root.modelData.timer.start();
}
onPositionChanged: event => {
if (pressed) {
const diffY = event.y - startY;
if (Math.abs(diffY) > Config.notifs.expandThreshold)
root.expanded = diffY > 0;
}
}
onPressed: event => {
root.modelData.timer.stop();
startY = event.y;
if (event.button === Qt.MiddleButton)
root.modelData.close();
}
onReleased: event => {
if (!containsMouse)
root.modelData.timer.start();
if (Math.abs(root.x) < root.implicitWidth * Config.notifs.clearThreshold)
root.x = 0;
else
root.modelData.popup = false;
}
Item {
id: inner
anchors.left: parent.left
anchors.margins: Tokens.padding.small
anchors.right: parent.right
anchors.top: parent.top
implicitHeight: root.nonAnimHeight
Behavior on implicitHeight {
Anim {}
}
Loader {
id: image
active: root.hasImage
anchors.left: parent.left
anchors.top: parent.top
asynchronous: true
height: Config.notifs.sizes.image
visible: root.hasImage || root.hasAppIcon
width: Config.notifs.sizes.image
sourceComponent: ClipRect {
color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3error : root.modelData.urgency === NotificationUrgency.Low ? Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) : Colors.palette.m3secondaryContainer
implicitHeight: Config.notifs.sizes.image
implicitWidth: Config.notifs.sizes.image
radius: Tokens.rounding.full
Image {
anchors.fill: parent
asynchronous: true
cache: false
fillMode: Image.PreserveAspectCrop
source: Qt.resolvedUrl(root.modelData.image)
sourceSize: {
const size = Config.notifs.sizes.image * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1);
return Qt.size(size, size);
}
}
}
}
Loader {
id: appIcon
active: root.hasAppIcon || !root.hasImage
anchors.bottom: root.hasImage ? image.bottom : undefined
anchors.horizontalCenter: root.hasImage ? undefined : image.horizontalCenter
anchors.right: root.hasImage ? image.right : undefined
anchors.verticalCenter: root.hasImage ? undefined : image.verticalCenter
asynchronous: true
sourceComponent: CustomRect {
color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3error : root.modelData.urgency === NotificationUrgency.Low ? Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) : Colors.palette.m3secondaryContainer
implicitHeight: root.hasImage ? Config.notifs.sizes.badge : Config.notifs.sizes.image
implicitWidth: root.hasImage ? Config.notifs.sizes.badge : Config.notifs.sizes.image
radius: Tokens.rounding.full
Loader {
id: icon
active: root.hasAppIcon
anchors.centerIn: parent
asynchronous: true
height: Math.round(parent.width * 0.6)
width: Math.round(parent.width * 0.6)
sourceComponent: ColoredIcon {
anchors.fill: parent
color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer
layer.enabled: root.modelData.appIcon.endsWith("symbolic")
source: Quickshell.iconPath(root.modelData.appIcon)
}
}
Loader {
active: !root.hasAppIcon
anchors.centerIn: parent
anchors.verticalCenterOffset: 1
asynchronous: true
sourceComponent: MaterialIcon {
color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer
text: Icons.getNotifIcon(root.modelData.summary, root.modelData.urgency)
fontStyle: Tokens.font.icon.medium
}
}
}
}
Shape {
id: progressIndicator
anchors.centerIn: appIcon
height: appIcon.implicitHeight + progressShape.strokeWidth * 2
preferredRendererType: Shape.CurveRenderer
width: appIcon.implicitWidth + progressShape.strokeWidth * 2
ShapePath {
id: progressShape
capStyle: ShapePath.RoundCap
fillColor: "transparent"
strokeColor: Colors.palette.m3primary
strokeWidth: 2
PathAngleArc {
id: progressArc
centerX: progressIndicator.width / 2
centerY: progressIndicator.height / 2
radiusX: progressIndicator.width / 2 - Tokens.padding.extraSmall / 2
radiusY: progressIndicator.height / 2 - Tokens.padding.extraSmall / 2
startAngle: -90
sweepAngle: ((root.modelData.hints.value ?? 0) / 100) * 360
Behavior on sweepAngle {
Anim {
easing: Tokens.anim.emphasizedDecel
}
}
}
}
}
CustomText {
id: appName
anchors.left: image.right
anchors.leftMargin: Tokens.spacing.small
anchors.top: parent.top
animate: true
color: Colors.palette.m3onSurfaceVariant
maximumLineCount: 1
opacity: root.expanded ? 1 : 0
text: appNameMetrics.elidedText
font: Tokens.font.label.medium
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
TextMetrics {
id: appNameMetrics
elide: Text.ElideRight
elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - Tokens.spacing.small * 3
font: appName.font
text: root.modelData.appName
}
CustomText {
id: summary
anchors.left: image.right
anchors.leftMargin: Tokens.spacing.small
anchors.top: parent.top
animate: true
height: implicitHeight
maximumLineCount: 1
text: summaryMetrics.elidedText
Behavior on height {
Anim {}
}
states: State {
name: "expanded"
when: root.expanded
PropertyChanges {
body.anchors.topMargin: Tokens.spacing.extraSmall
bodyPreview.anchors.topMargin: Tokens.spacing.extraSmall
summary.anchors.topMargin: Tokens.spacing.extraSmall
summary.maximumLineCount: undefined
}
AnchorChanges {
anchors.top: appName.bottom
target: summary
}
}
transitions: Transition {
PropertyAction {
property: "maximumLineCount"
target: summary
}
Anim {
property: "topMargin"
}
AnchorAnim {}
}
}
TextMetrics {
id: summaryMetrics
elide: Text.ElideRight
elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - Tokens.spacing.small * 3
font: summary.font
text: root.modelData.summary
}
CustomText {
id: timeSep
anchors.left: summary.right
anchors.leftMargin: Tokens.spacing.small
anchors.top: parent.top
color: Colors.palette.m3onSurfaceVariant
text: "•"
font: Tokens.font.body.small
states: State {
name: "expanded"
when: root.expanded
AnchorChanges {
anchors.left: appName.right
target: timeSep
}
}
transitions: Transition {
AnchorAnim {}
}
}
CustomText {
id: time
anchors.left: timeSep.right
anchors.leftMargin: Tokens.spacing.small
anchors.top: parent.top
animate: true
color: Colors.palette.m3onSurfaceVariant
font: Tokens.font.body.small
horizontalAlignment: Text.AlignLeft
text: root.modelData.timeStr
}
Item {
id: expandBtn
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: -Tokens.padding.extraSmall
implicitHeight: expandIcon.implicitHeight
implicitWidth: expandIcon.implicitHeight
StateLayer {
color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondaryContainer : Colors.palette.m3onSurface
radius: Tokens.rounding.full
onClicked: root.expanded = !root.expanded
}
MaterialIcon {
id: expandIcon
anchors.centerIn: parent
anchors.verticalCenterOffset: root.expanded ? -1 : 1
rotation: root.expanded ? 180 : 0
text: "expand_more"
fontStyle: Tokens.font.icon.medium
Behavior on anchors.verticalCenterOffset {
Anim {}
}
Behavior on rotation {
Anim {}
}
}
}
CustomText {
id: bodyPreview
anchors.left: summary.left
anchors.right: expandBtn.left
anchors.rightMargin: Tokens.spacing.small
anchors.top: summary.bottom
animate: true
color: Colors.palette.m3onSurfaceVariant
font: Tokens.font.body.small
opacity: root.expanded ? 0 : 1
text: bodyPreviewMetrics.elidedText
textFormat: Text.MarkdownText
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
TextMetrics {
id: bodyPreviewMetrics
elide: Text.ElideRight
elideWidth: bodyPreview.width
font: bodyPreview.font
text: root.modelData.body
}
CustomText {
id: body
anchors.left: summary.left
anchors.right: expandBtn.left
anchors.rightMargin: Tokens.spacing.small
anchors.top: summary.bottom
animate: true
color: Colors.palette.m3onSurfaceVariant
height: text ? implicitHeight : 0
font: Tokens.font.body.medium
opacity: root.expanded ? 1 : 0
text: root.modelData.body
textFormat: Text.MarkdownText
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
onLinkActivated: link => {
if (!root.expanded)
return;
Quickshell.execDetached(["app2unit", "-O", "--", link]);
root.modelData.popup = false;
}
}
ButtonRow {
id: actions
anchors.left: parent.left
anchors.right: parent.right
anchors.top: body.bottom
anchors.topMargin: Tokens.spacing.small
opacity: root.expanded ? 1 : 0
spacing: Tokens.spacing.extraSmall
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
IconButton {
enabled: root.expanded
fillWidth: root.modelData.actions.length === 0
icon: "close"
font: Tokens.font.icon.builders.small.size(14).build()
inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2)
inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant
isRound: true
padding: Tokens.padding.extraSmall
shapeMorph: true
onClicked: root.modelData.close()
}
Repeater {
model: root.modelData.actions
TextButton {
required property var modelData
enabled: root.expanded
fillWidth: true
implicitWidth: label.implicitWidth
inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2)
inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant
isRound: true
label.anchors.centerIn: undefined
label.anchors.left: left
label.anchors.margins: Tokens.padding.small
label.anchors.right: right
label.anchors.verticalCenter: verticalCenter
label.elide: Text.ElideRight
label.horizontalAlignment: Text.AlignHCenter
shapeMorph: true
text: modelData.text
onClicked: modelData.invoke()
}
}
IconButton {
enabled: root.expanded
fillWidth: root.modelData.actions.length === 0
icon: copyTimer.running ? "inventory" : "content_copy"
inactiveColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondary : Colors.layer(Colors.palette.m3surfaceContainerHighest, 2)
inactiveOnColor: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3onSecondary : Colors.palette.m3onSurfaceVariant
isRound: true
label.animate: true
padding: Tokens.padding.extraSmall
shapeMorph: true
font: Tokens.font.icon.builders.small.size(14).build()
onClicked: {
Quickshell.clipboardText = root.modelData.body;
copyTimer.restart();
}
Timer {
id: copyTimer
interval: 3000
}
}
}
}
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ Item {
property real offsetScale: shouldBeActive ? 0 : 1
required property ShellScreen screen
readonly property bool shouldBeActive: screenState.osd && Config.osd.enabled && !(screenState.utilities && Config.utilities.enabled)
property real sidebarOffset: sidebarOrSessionVisible ? 12 : 0
property real sidebarOffset: sidebarOrSessionVisible ? 0 : 0
required property bool sidebarOrSessionVisible
property bool sourceMuted
property real sourceVolume
+5 -1
View File
@@ -21,6 +21,8 @@ CustomRect {
implicitWidth: ZUtils.snapCeil(450, root)
radius: Tokens.rounding.largeIncreased
objectName: "card-rect"
ClipRect {
anchors.fill: parent
radius: parent.radius
@@ -93,6 +95,8 @@ CustomRect {
anchors.left: parent.left
anchors.margins: Tokens.padding.largeIncreased
objectName: "progress-layout"
spacing: Tokens.spacing.extraSmall
orientation: Qt.Vertical
@@ -114,7 +118,7 @@ CustomRect {
CustomProgressBar {
fgColor: root.accent
implicitHeight: Tokens.padding.small
implicitHeight: Tokens.padding.extraSmall
indeterminate: isNaN(root.usage) || isNaN(root.temperature)
value: root.temperature / 100
}
+1 -1
View File
@@ -62,8 +62,8 @@ CustomRect {
}
SnapLayout {
orientation: Qt.Vertical
id: usageColumn
orientation: Qt.Vertical
anchors.centerIn: parent
anchors.verticalCenterOffset: Tokens.padding.extraSmall
+2 -2
View File
@@ -51,7 +51,7 @@ CustomRect {
id: sparkline
property bool initialized: false
property real smoothMax: targetMax
property real smoothMax: targetMax * 1.6
property real targetMax: 1024
anchors.fill: parent
@@ -74,7 +74,7 @@ CustomRect {
Component.onCompleted: {
sparkline.targetMax = Math.max(NetworkUsage.downloadBuffer.maximum, NetworkUsage.uploadBuffer.maximum, 1024);
sparkline.smoothMax = Qt.binding(() => sparkline.targetMax);
sparkline.smoothMax = Qt.binding(() => sparkline.targetMax * 1.6);
sparkline.initialized = true;
}
+1 -2
View File
@@ -28,8 +28,7 @@ Item {
Behavior on offsetScale {
Anim {
duration: Tokens.anim.durations.expressiveDefaultSpatial
easing: Tokens.anim.expressiveDefaultSpatial
type: Anim.DefaultSpatial
}
}
+1 -1
View File
@@ -9,7 +9,6 @@ import ZShell.Config
import qs.Services
SnapLayout {
orientation: Qt.Vertical
id: root
property bool animateScroll: false
@@ -69,6 +68,7 @@ orientation: Qt.Vertical
}
spacing: Tokens.spacing.largeIncreased
orientation: Qt.Vertical
Component.onCompleted: applySearchAnchor()
+2 -1
View File
@@ -1,5 +1,6 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Components
import ZShell.Config
import qs.Components
import qs.Services
@@ -10,7 +11,7 @@ CustomText {
Layout.bottomMargin: Tokens.spacing.extraSmall
Layout.fillWidth: true
Layout.leftMargin: Tokens.padding.extraSmall
Layout.topMargin: first ? 0 : Tokens.spacing.largeIncreased - ((parent as ColumnLayout).spacing ?? 0)
Layout.topMargin: first ? 0 : Tokens.spacing.largeIncreased - ((parent as SnapLayout).spacing ?? 0)
color: Colors.palette.m3onSurfaceVariant
elide: Text.ElideRight
font: Tokens.font.label.medium
+6 -2
View File
@@ -18,10 +18,12 @@ PageBase {
title: qsTr("All apps")
SnapLayout {
orientation: Qt.Vertical
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
spacing: Tokens.spacing.extraSmall / 2
orientation: Qt.Vertical
width: root.cappedWidth
Repeater {
@@ -54,6 +56,7 @@ orientation: Qt.Vertical
anchors.leftMargin: Tokens.padding.largeIncreased
anchors.margins: Tokens.padding.small
anchors.rightMargin: Tokens.padding.largeIncreased
spacing: Tokens.spacing.medium
IconImage {
@@ -63,8 +66,9 @@ orientation: Qt.Vertical
}
SnapLayout {
orientation: Qt.Vertical
Layout.fillWidth: true
orientation: Qt.Vertical
spacing: 0
CustomText {
+4 -2
View File
@@ -1,4 +1,4 @@
import QtQuick.Layouts
import QtQuick
import ZShell.Components
import ZShell.Config
import qs.Modules.Settings.Common
@@ -9,9 +9,11 @@ PageBase {
title: qsTr("Panels")
SnapLayout {
orientation: Qt.Vertical
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
orientation: Qt.Vertical
spacing: Tokens.spacing.extraSmall / 2
width: root.cappedWidth
-198
View File
@@ -1,198 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
import ZShell.Config
import qs.Components
import qs.Services
import qs.Daemons
import ZShell.Components
CustomRect {
id: root
readonly property CustomText body: (expandedContent.item as ExpandedBody)?.body ?? null
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 ScreenState screenState
color: {
const c = root.modelData?.urgency === "critical" ? Colors.palette.m3secondaryContainer : Colors.layer(Colors.palette.m3surfaceContainerHigh, 2);
return expanded ? c : Qt.alpha(c, 0);
}
implicitHeight: nonAnimHeight
radius: Tokens.rounding.large - Tokens.padding.small
state: expanded ? "expanded" : ""
Behavior on implicitHeight {
Anim {}
}
states: State {
name: "expanded"
PropertyChanges {
compactBody.anchors.margins: Tokens.padding.medium
dummySummary.anchors.margins: Tokens.padding.medium
expandedContent.anchors.margins: Tokens.padding.medium
summary.anchors.margins: Tokens.padding.medium
summary.maximumLineCount: Number.MAX_SAFE_INTEGER
summary.width: root.width - Tokens.padding.medium * 2 - timeStr.implicitWidth - Tokens.spacing.small
timeStr.anchors.margins: Tokens.padding.medium
}
}
transitions: Transition {
Anim {
properties: "margins,width,maximumLineCount"
}
}
TextMetrics {
id: summaryHeightMetrics
font: summary.font
text: " " // Use this height to prevent weird characters from changing the line height
}
CustomText {
id: summary
anchors.left: parent.left
anchors.top: parent.top
color: root.modelData?.urgency === "critical" ? Colors.palette.m3onSecondaryContainer : Colors.palette.m3onSurface
elide: Text.ElideRight
maximumLineCount: 1
text: root.modelData?.summary ?? ""
width: parent.width
wrapMode: Text.WordWrap
}
CustomText {
id: dummySummary
anchors.left: parent.left
anchors.top: parent.top
text: root.modelData?.summary ?? ""
visible: false
}
WrappedLoader {
id: compactBody
anchors.left: dummySummary.right
anchors.leftMargin: Tokens.spacing.small
anchors.right: parent.right
anchors.top: parent.top
shouldBeActive: !root.expanded
sourceComponent: CustomText {
color: root.modelData?.urgency === "critical" ? Colors.palette.m3secondary : Colors.palette.m3outline
elide: Text.ElideRight
text: String(root.modelData?.body ?? "").replace(/\n/g, " ")
}
}
WrappedLoader {
id: timeStr
anchors.right: parent.right
anchors.top: parent.top
shouldBeActive: root.expanded
sourceComponent: CustomText {
animate: true
color: Colors.palette.m3outline
text: root.modelData?.timeStr ?? ""
}
}
WrappedLoader {
id: expandedContent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: summary.bottom
anchors.topMargin: Tokens.spacing.extraSmall
shouldBeActive: root.expanded
sourceComponent: ExpandedBody {}
}
component ExpandedBody: SnapLayout {
readonly property alias body: bodyText
spacing: Tokens.spacing.medium
orientation: Qt.Vertical
CustomText {
id: bodyText
Layout.fillWidth: true
color: root.modelData?.urgency === "critical" ? Colors.palette.m3secondary : Colors.palette.m3onSurface
text: String(root.modelData?.body ?? "").replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body given")
textFormat: Text.MarkdownText
wrapMode: Text.WordWrap
onLinkActivated: link => {
Qt.openUrlExternally(link);
root.screenState.sidebar = false;
}
}
NotifActionList {
notif: root.modelData
}
}
component WrappedLoader: Loader {
id: comp
required property bool shouldBeActive
active: false
opacity: 0
// Makes the loader load on the same frame shouldBeActive becomes true, which ensures size is set
states: State {
name: "active"
when: comp.shouldBeActive
PropertyChanges {
comp.active: true
comp.opacity: 1
}
}
transitions: [
Transition {
from: ""
to: "active"
SequentialAnimation {
PropertyAction {
property: "active"
}
Anim {
property: "opacity"
type: Anim.DefaultEffects
}
}
},
Transition {
from: "active"
to: ""
SequentialAnimation {
Anim {
property: "opacity"
type: Anim.DefaultEffects
}
PropertyAction {
property: "active"
}
}
}
]
}
}
-126
View File
@@ -1,126 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import ZShell.Config
import ZShell.Components
import qs.Components
import qs.Effects
import qs.Services
import qs.Daemons
Item {
id: root
required property NotifServer.Notif notif
Layout.fillWidth: true
implicitHeight: actionList.implicitHeight
SnapLayout {
id: actionList
anchors.fill: parent
spacing: Tokens.spacing.extraSmall
Repeater {
model: [
{
isClose: true
},
...(root.notif?.actions ?? []),
{
isCopy: true
}
]
CustomRect {
id: action
required property var modelData
Layout.fillHeight: true
Layout.fillWidth: true
Layout.preferredWidth: implicitWidth + (actionStateLayer.pressed ? Tokens.padding.large : 0)
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 4)
implicitHeight: actionInner.implicitHeight + Tokens.padding.small
implicitWidth: actionInner.implicitWidth + Tokens.padding.medium * 2
radius: actionStateLayer.pressed ? Tokens.rounding.medium / 2 : Tokens.rounding.large
Behavior on Layout.preferredWidth {
Anim {
type: Anim.FastSpatial
}
}
Behavior on radius {
Anim {
type: Anim.FastSpatial
}
}
Timer {
id: copyTimer
interval: 3000
onTriggered: actionInner.item.text = "content_copy"
}
StateLayer {
id: actionStateLayer
onClicked: {
if (action.modelData.isClose) {
root.notif.close();
} else if (action.modelData.isCopy) {
Quickshell.clipboardText = root.notif.body;
actionInner.item.text = "inventory";
copyTimer.start();
} else if (action.modelData.invoke) {
action.modelData.invoke();
} else if (!root.notif.resident) {
root.notif.close();
}
}
}
Loader {
id: actionInner
anchors.centerIn: parent
sourceComponent: action.modelData.isClose || action.modelData.isCopy ? iconBtn : root.notif?.hasActionIcons ? iconComp : textComp
}
Component {
id: iconBtn
MaterialIcon {
animate: action.modelData.isCopy ?? false
color: Colors.palette.m3onSurfaceVariant
text: action.modelData.isCopy ? "content_copy" : "close"
}
}
Component {
id: iconComp
IconImage {
asynchronous: true
source: Quickshell.iconPath(action.modelData.identifier)
}
}
Component {
id: textComp
CustomText {
color: Colors.palette.m3onSurfaceVariant
text: action.modelData.text
}
}
}
}
}
}
+67 -49
View File
@@ -7,100 +7,113 @@ import QtQuick.Layouts
import ZShell.Components
import ZShell.Config
import qs.Components
import qs.Modules
import qs.Daemons
import qs.Modules
import qs.Services
Item {
id: root
readonly property int notifCount: NotifServer.list.reduce((acc, n) => n.closed ? acc : acc + 1, 0)
required property ScreenState screenState
readonly property int notifCount: NotifServer.notClosed.length
anchors.fill: parent
anchors.margins: Tokens.padding.small
Component.onCompleted: NotifServer.list.forEach(n => n.popup = false)
Component.onCompleted: NotifServer.list.forEach(n => n.dismissPopup())
Item {
id: title
anchors.left: parent.left
anchors.margins: 4
anchors.right: parent.right
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Tokens.padding.extraSmall
implicitHeight: Math.max(count.implicitHeight, titleText.implicitHeight)
CustomText {
id: count
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: root.notifCount > 0 ? 0 : -width - titleText.anchors.leftMargin
anchors.verticalCenter: parent.verticalCenter
opacity: root.notifCount > 0 ? 1 : 0
text: root.notifCount
color: Colors.palette.m3outline
font: Tokens.font.label.large
opacity: root.notifCount > 0 ? 1 : 0
text: root.notifCount
Behavior on anchors.leftMargin {
Anim {}
}
Behavior on opacity {
Anim {}
Anim {
type: Anim.DefaultEffects
}
}
}
CustomText {
id: titleText
anchors.left: count.right
anchors.leftMargin: 7
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
color: Colors.palette.m3outline
elide: Text.ElideRight
font: Tokens.font.label.large
anchors.left: count.right
anchors.right: parent.right
anchors.leftMargin: Tokens.spacing.extraSmall
text: root.notifCount > 0 ? qsTr("notification%1").arg(root.notifCount === 1 ? "" : "s") : qsTr("Notifications")
color: Colors.palette.m3outline
font: Tokens.font.label.large
elide: Text.ElideRight
}
}
Item {
id: clipRect
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.top: title.bottom
anchors.topMargin: 10
anchors.bottom: parent.bottom
anchors.topMargin: Tokens.spacing.medium
clip: true
Loader {
active: opacity > 0
asynchronous: true
anchors.centerIn: parent
active: opacity > 0
opacity: root.notifCount > 0 ? 0 : 1
Behavior on opacity {
Anim {}
}
sourceComponent: SnapLayout {
spacing: 20
orientation: Qt.Vertical
spacing: Tokens.spacing.extraLarge
CustomText {
Layout.alignment: Qt.AlignHCenter
text: qsTr("No notifications")
color: Colors.palette.m3outlineVariant
font: Tokens.font.headline.builders.small.width(90).build()
text: qsTr("No Notifications")
}
}
Behavior on opacity {
Anim {
type: Anim.StandardExtraLarge
}
}
}
CustomFlickable {
KineticFlickable {
id: view
anchors.fill: parent
contentHeight: notifList.implicitHeight
contentWidth: width
flickableDirection: Flickable.VerticalFlick
contentWidth: width
contentHeight: notifList.implicitHeight
CustomScrollBar.vertical: CustomScrollBar {
flickable: view
@@ -109,8 +122,8 @@ Item {
NotifDockList {
id: notifList
container: view
screenState: root.screenState
container: view
}
}
}
@@ -118,21 +131,19 @@ Item {
Timer {
id: clearTimer
interval: Math.max(15, Math.min(80, 69.8 - 12.3 * Math.log(NotifServer.notClosed.length)))
repeat: true
triggeredOnStart: true
interval: Math.max(15, Math.min(80, 69.8 - 12.3 * Math.log(NotifServer.notClosed.length)))
onTriggered: {
const first = NotifServer.notClosed[0];
if (!first) {
const group = NotifServer.groups[0];
if (!group) {
stop();
return;
}
const appName = first.appName;
let cleared = 0;
for (const n of NotifServer.notClosed.filter(n => n.appName === appName)) {
n.close();
for (const n of [...group.activeNotifs]) {
n.dismiss();
cleared++;
if (cleared > 30) {
interval = 5;
@@ -143,33 +154,40 @@ Item {
}
Loader {
active: opacity > 0
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.small
asynchronous: true
anchors.right: parent.right
opacity: root.notifCount > 0 ? 1 : 0
scale: root.notifCount > 0 ? 1 : 0.5
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
scale: root.notifCount > 0 ? 1 : 0.5
opacity: root.notifCount > 0 ? 1 : 0
active: opacity > 0
Behavior on opacity {
Anim {}
}
Behavior on scale {
Anim {}
}
sourceComponent: IconButton {
id: clearBtn
font: Tokens.font.icon.large
icon: "clear_all"
font: Tokens.font.icon.large
isRound: true
onClicked: clearTimer.start()
Elevation {
anchors.fill: parent
level: clearBtn.stateLayer.containsMouse ? 4 : 3
radius: parent.radius
z: -1
level: clearBtn.stateLayer.containsMouse ? 4 : 3
}
}
Behavior on scale {
Anim {
type: Anim.FastSpatial
}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
+119 -115
View File
@@ -4,8 +4,10 @@ import QtQuick
import Quickshell
import ZShell.Components
import ZShell.Config
import qs.Daemons
import ZShell.Services
import qs.Components
import qs.Components.Notifs
import qs.Daemons
import qs.Services
LazyListView {
@@ -16,121 +18,12 @@ LazyListView {
anchors.left: parent?.left
anchors.right: parent?.right
asynchronous: true
cacheBuffer: 400
implicitHeight: contentHeight
readyDelay: 1
removeDuration: Tokens.anim.durations.normal
spacing: Tokens.spacing.small
useCustomViewport: true
viewport: Qt.rect(0, container.contentY, width, container.height)
delegate: Component {
MouseArea {
id: notif
readonly property bool closed: notifInner.notifCount === 0
required property int index
required property string modelData
property int startY
function closeAll(): void {
clearTimer.start();
}
LazyListView.preferredHeight: closed ? 0 : notifInner.nonAnimHeight
LazyListView.trackViewport: !notifInner.expanded && notifInner.nonAnimHeight < notifInner.implicitHeight
LazyListView.visibleHeight: notifInner.implicitHeight
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
cursorShape: pressed ? Qt.ClosedHandCursor : undefined
drag.axis: Drag.XAxis
drag.target: this
enabled: !closed
hoverEnabled: true
implicitHeight: notifInner.implicitHeight
opacity: LazyListView.removing || closed || LazyListView.adding ? 0 : 1
preventStealing: true
scale: LazyListView.removing || closed ? 0.6 : LazyListView.adding ? 0 : 1
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on scale {
Anim {
}
}
Behavior on x {
Anim {
}
}
Behavior on y {
enabled: notif.LazyListView.ready
Anim {
}
}
onPositionChanged: event => {
if (pressed) {
const diffY = event.y - startY;
if (Math.abs(diffY) > Config.notifs.expandThreshold)
notifInner.toggleExpand(diffY > 0);
}
}
onPressed: event => {
startY = event.y;
if (event.button === Qt.RightButton)
notifInner.toggleExpand(!notifInner.expanded);
else if (event.button === Qt.MiddleButton)
closeAll();
}
onReleased: event => {
if (Math.abs(x) < width * Config.notifs.clearThreshold)
x = 0;
else
closeAll();
}
Timer {
id: clearTimer
interval: 15
repeat: true
triggeredOnStart: true
onTriggered: {
const notifs = NotifServer.notClosed.filter(n => n.appName === notif.modelData);
if (notifs.length === 0) {
stop();
return;
}
for (const n of notifs.slice(0, 30))
n.close();
}
}
NotifGroup {
id: notifInner
container: root.container
modelData: notif.modelData
screenState: root.screenState
}
}
}
model: ScriptModel {
values: {
const map = new Map();
for (const n of NotifServer.notClosed)
map.set(n.appName, null);
for (const n of NotifServer.list)
map.set(n.appName, null);
return [...map.keys()];
}
}
readyDelay: 1
cacheBuffer: 400
asynchronous: true
onViewportAdjustNeeded: d => {
if (contentYAnim.running)
@@ -139,10 +32,121 @@ LazyListView {
contentYAnim.start();
}
useCustomViewport: true
viewport: Qt.rect(0, container.contentY, width, container.height)
removeDuration: Tokens.anim.durations.normal
model: ScriptModel {
values: [...NotifServer.groups]
}
delegate: Component {
NotifSwipeArea {
id: notif
required property int index
required property NotificationGroup modelData
readonly property bool closed: card.notifs.length === 0
function toggleExpand(expand: bool): void {
if (expand) {
if (!expanded)
root.screenState.expandedNotifs.push(modelData.appName);
} else if (expanded) {
root.screenState.expandedNotifs.splice(root.screenState.expandedNotifs.indexOf(modelData.appName), 1);
}
}
function closeAll(): void {
clearTimer.start();
}
Component.onDestruction: {
if (closed && expanded)
toggleExpand(false);
}
LazyListView.trackViewport: !expanded && card.nonAnimHeight < card.implicitHeight
LazyListView.preferredHeight: closed ? 0 : card.nonAnimHeight
LazyListView.visibleHeight: card.implicitHeight
implicitHeight: card.implicitHeight
opacity: LazyListView.removing || closed || LazyListView.adding ? 0 : 1
scale: LazyListView.adding ? 0 : 1
expanded: root.screenState.expandedNotifs.includes(modelData.appName)
enabled: !closed
onClosedChanged: {
if (closed)
slideOut.start();
}
onRequestExpand: expand => toggleExpand(expand)
onSwiped: closeAll()
onDismissed: closeAll()
Anim {
id: slideOut
target: notif
property: "x"
to: notif.x >= 0 ? notif.width : -notif.width
}
Timer {
id: clearTimer
interval: 15
repeat: true
triggeredOnStart: true
onTriggered: {
const notifs = [...notif.modelData.activeNotifs];
if (notifs.length === 0) {
stop();
return;
}
for (const n of notifs.slice(0, 30))
n.dismiss();
}
}
NotifGroupCard {
id: card
group: notif.modelData
container: root.container
expanded: notif.expanded
baseLayer: 1
onRequestExpand: expand => notif.toggleExpand(expand)
onSwiped: notif => notif.dismiss()
onLinkActivated: root.screenState.sidebar = false
}
Behavior on y {
enabled: notif.LazyListView.ready
Anim {}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on scale {
Anim {}
}
}
}
Anim {
id: contentYAnim
property: "contentY"
target: root.container
property: "contentY"
}
}
-251
View File
@@ -1,251 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Notifications
import ZShell.Config
import qs.Components
import qs.Services
import qs.Helpers
import qs.Daemons
import ZShell.Components
CustomRect {
id: root
readonly property list<var> 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: screenState.expandedNotifs.includes(modelData)
readonly property string image: activeNotifs.find(n => n.image.length > 0)?.image ?? ""
required property string modelData
readonly property int nonAnimHeight: {
const headerHeight = header.implicitHeight + (root.expanded ? Math.round(Tokens.spacing.extraSmall) : 0);
const columnHeight = headerHeight + notifList.layoutHeight;
return Math.round(Math.max(Config.notifs.sizes.image, columnHeight) + Tokens.padding.small * 2);
}
readonly property int notifCount: activeNotifs.length
readonly property list<var> notifs: NotifServer.list.filter(n => n.appName === modelData)
required property ScreenState screenState
readonly property int urgency: {
if (activeNotifs.find(n => n.urgency === NotificationUrgency.Critical))
return NotificationUrgency.Critical;
if (activeNotifs.find(n => n.urgency === NotificationUrgency.Normal))
return NotificationUrgency.Normal;
return NotificationUrgency.Low;
}
function toggleExpand(expand: bool): void {
if (expand) {
if (!expanded)
screenState.expandedNotifs.push(modelData);
} else if (expanded) {
screenState.expandedNotifs.splice(screenState.expandedNotifs.indexOf(modelData), 1);
}
}
anchors.left: parent?.left
anchors.right: parent?.right
clip: true
color: Colors.layer(Colors.palette.m3surfaceContainer, 2)
implicitHeight: nonAnimHeight
radius: Tokens.rounding.large
Behavior on implicitHeight {
Anim {}
}
Component.onDestruction: {
if (notifCount === 0 && expanded)
screenState.expandedNotifs.splice(screenState.expandedNotifs.indexOf(modelData), 1);
}
SnapLayout {
id: content
anchors.left: parent.left
anchors.margins: Tokens.padding.small
anchors.right: parent.right
anchors.top: parent.top
objectName: "notif-content"
spacing: Tokens.spacing.medium
Item {
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
implicitHeight: Config.notifs.sizes.image
implicitWidth: Config.notifs.sizes.image
Component {
id: imageComp
Image {
asynchronous: true
cache: false
fillMode: Image.PreserveAspectCrop
height: Config.notifs.sizes.image
source: Qt.resolvedUrl(root.image)
sourceSize: {
const size = Config.notifs.sizes.image * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1);
return Qt.size(size, size);
}
width: Config.notifs.sizes.image
}
}
Component {
id: appIconComp
ColoredIcon {
color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : root.urgency === NotificationUrgency.Low ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer
implicitSize: Math.round(Config.notifs.sizes.image * 0.6)
layer.enabled: root.appIcon.endsWith("symbolic")
source: Quickshell.iconPath(root.appIcon)
}
}
Component {
id: materialIconComp
MaterialIcon {
color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : root.urgency === NotificationUrgency.Low ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer
text: Icons.getNotifIcon(root.activeNotifs[0]?.summary, root.urgency)
}
}
ClipRect {
anchors.fill: parent
color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3error : root.urgency === NotificationUrgency.Low ? Colors.layer(Colors.palette.m3surfaceContainerHigh, 3) : Colors.palette.m3secondaryContainer
radius: Tokens.rounding.full
Loader {
anchors.centerIn: parent
anchors.verticalCenterOffset: sourceComponent === materialIconComp ? 1 : 0
asynchronous: true
sourceComponent: root.image ? imageComp : root.appIcon ? appIconComp : materialIconComp
}
}
Loader {
active: root.appIcon && root.image
anchors.bottom: parent.bottom
anchors.right: parent.right
asynchronous: true
sourceComponent: CustomRect {
color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3error : root.urgency === NotificationUrgency.Low ? Colors.palette.m3surfaceContainerHigh : Colors.palette.m3secondaryContainer
implicitHeight: Config.notifs.sizes.badge
implicitWidth: Config.notifs.sizes.badge
radius: Tokens.rounding.full
ColoredIcon {
anchors.centerIn: parent
color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : root.urgency === NotificationUrgency.Low ? Colors.palette.m3onSurface : Colors.palette.m3onSecondaryContainer
implicitSize: Math.round(Config.notifs.sizes.badge * 0.6)
layer.enabled: root.appIcon.endsWith("symbolic")
source: Quickshell.iconPath(root.appIcon)
}
}
}
}
SnapLayout {
id: column
Layout.fillWidth: true
spacing: root.expanded ? Math.round(Tokens.spacing.extraSmall) : 0
orientation: Qt.Vertical
objectName: "notif-column"
Behavior on spacing {
Anim {}
}
SnapLayout {
id: header
Layout.fillWidth: true
spacing: Tokens.spacing.small
objectName: "notif-header"
CustomText {
Layout.fillWidth: true
color: Colors.palette.m3onSurfaceVariant
elide: Text.ElideRight
text: root.modelData
}
CustomText {
animate: true
color: Colors.palette.m3outline
text: root.activeNotifs[0]?.timeStr ?? ""
}
CustomRect {
color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3error : Colors.layer(Colors.palette.m3surfaceContainerHigh, 3)
implicitHeight: groupCount.implicitHeight + Tokens.padding.extraSmall
implicitWidth: expandBtn.implicitWidth + Tokens.padding.large
radius: Tokens.rounding.full
StateLayer {
color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : Colors.palette.m3onSurface
onClicked: root.toggleExpand(!root.expanded)
}
SnapLayout {
id: expandBtn
anchors.centerIn: parent
spacing: Tokens.spacing.extraSmall
objectName: "notif-expand"
CustomText {
id: groupCount
Layout.leftMargin: Tokens.padding.extraSmall / 2
animate: true
color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : Colors.palette.m3onSurfaceVariant
text: root.notifCount
}
MaterialIcon {
Layout.rightMargin: -Tokens.padding.extraSmall / 2
Layout.topMargin: root.expanded ? -Math.floor(Tokens.padding.extraSmall) : 0
color: root.urgency === NotificationUrgency.Critical ? Colors.palette.m3onError : Colors.palette.m3onSurfaceVariant
rotation: root.expanded ? 180 : 0
text: "expand_more"
Behavior on Layout.topMargin {
Anim {}
}
Behavior on rotation {
Anim {}
}
}
}
}
}
NotifGroupList {
id: notifList
Layout.fillWidth: true
container: root.container
expanded: root.expanded
notifs: root.notifs
screenState: root.screenState
onRequestToggleExpand: expand => root.toggleExpand(expand)
}
}
}
}
-147
View File
@@ -1,147 +0,0 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import ZShell.Components
import ZShell.Config
import qs.Daemons
import qs.Components
import qs.Services
LazyListView {
id: root
required property Flickable container
required property bool expanded
required property list<var> notifs
required property ScreenState screenState
signal requestToggleExpand(expand: bool)
asynchronous: true
cacheBuffer: 400
implicitHeight: contentHeight
readyDelay: 1
removeDuration: Tokens.anim.durations.normal
spacing: Math.round(Tokens.spacing.extraSmall)
useCustomViewport: true
viewport: {
tWatcher.transform; // mapToItem is not reactive so use this to trigger updates
return Qt.rect(0, container.contentY - mapToItem(container.contentItem, 0, 0).y, width, container.height);
}
delegate: Component {
MouseArea {
id: notif
required property int index
required property NotifServer.Notif modelData
property int startY
LazyListView.preferredHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.nonAnimHeight
LazyListView.visibleHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.implicitHeight
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined
drag.axis: Drag.XAxis
drag.target: this
enabled: !(modelData?.closed ?? true)
hoverEnabled: true
implicitHeight: notifInner.implicitHeight
opacity: LazyListView.removing || LazyListView.adding ? 0 : 1
preventStealing: !root.expanded
scale: LazyListView.removing || LazyListView.adding ? 0.7 : 1
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on scale {
Anim {}
}
Behavior on x {
Anim {}
}
Behavior on y {
enabled: notif.LazyListView.ready
Anim {}
}
Component.onCompleted: modelData?.lock(this)
Component.onDestruction: modelData?.unlock(this)
onPositionChanged: event => {
if (pressed && !root.expanded) {
const diffY = event.y - startY;
if (Math.abs(diffY) > Config.notifs.expandThreshold)
root.requestToggleExpand(diffY > 0);
}
}
onPressed: event => {
startY = event.y;
if (event.button === Qt.RightButton)
root.requestToggleExpand(!root.expanded);
else if (event.button === Qt.MiddleButton)
modelData?.close();
}
onReleased: event => {
if (Math.abs(x) < width * Config.notifs.clearThreshold)
x = 0;
else
modelData?.close();
}
ParallelAnimation {
running: notif.modelData?.closed ?? false
onFinished: notif.modelData?.unlock(notif)
Anim {
property: "opacity"
target: notif
to: 0
type: Anim.DefaultEffects
}
Anim {
property: "x"
target: notif
to: notif.x >= 0 ? notif.width : -notif.width
}
}
Notif {
id: notifInner
anchors.fill: parent
expanded: root.expanded
modelData: notif.modelData
screenState: root.screenState
}
}
}
model: ScriptModel {
values: {
if (root.expanded)
return root.notifs as Array;
let count = 0;
let i = 0;
const previewNum = Config.notifs.groupPreviewNum;
while (i < root.notifs.length && count < previewNum) {
if (!(root.notifs[i]?.closed ?? true))
count++;
i++;
}
return root.notifs.slice(0, i) as Array;
}
}
TransformWatcher {
id: tWatcher
a: root.container.contentItem
b: root
}
}
+1
View File
@@ -6,6 +6,7 @@ find_package(Qt6 REQUIRED COMPONENTS
GuiPrivate
Quick
CanvasPainter
CanvasPainterTools
QuickPrivate
QuickLayouts
QuickLayoutsPrivate
+39 -2
View File
@@ -2,7 +2,17 @@
#include "blobinvertedrect.hpp"
#include "blobshape.hpp"
BlobGroup::BlobGroup(QObject* parent) : QObject(parent) {}
#include <algorithm>
namespace {
constexpr int k_pumpMs = 16;
} // namespace
BlobGroup::BlobGroup(QObject* parent) : QObject(parent) {
m_physicsPump.setInterval(k_pumpMs);
m_physicsPump.setTimerType(Qt::CoarseTimer);
connect(&m_physicsPump, &QTimer::timeout, this, &BlobGroup::pumpPhysics);
}
BlobGroup::~BlobGroup() {
for (auto* shape : std::as_const(m_shapes))
@@ -12,6 +22,7 @@ BlobGroup::~BlobGroup() {
}
void BlobGroup::setSmoothing(qreal s) {
if (s < 0.5) s = 0.5;
if (qFuzzyCompare(m_smoothing, s)) return;
m_smoothing = s;
emit smoothingChanged();
@@ -73,7 +84,6 @@ void BlobGroup::markShapeDirty(BlobShape* source) {
source->polish();
source->update();
// Use cached padded rects to find spatial neighbors
const float pad = static_cast<float>(m_smoothing) * 2.0f;
const QRectF srcRect(
static_cast<double>(source->m_cachedPaddedX - pad),
@@ -103,6 +113,33 @@ void BlobGroup::markShapeDirty(BlobShape* source) {
void BlobGroup::ensurePhysicsUpdated() {
if (m_physicsUpdated) return;
m_physicsUpdated = true;
for (auto* shape : std::as_const(m_shapes)) {
shape->updatePhysics();
if (shape->needsFrame()) {
shape->polish();
shape->update();
}
}
}
void BlobGroup::requestPhysics() {
if (!m_physicsPump.isActive()) m_physicsPump.start();
}
void BlobGroup::pumpPhysics() {
for (auto* shape : std::as_const(m_shapes))
shape->updatePhysics();
const bool anyMoving = std::any_of(
m_shapes.cbegin(), m_shapes.cend(), [](const BlobShape* shape) {
return shape->needsFrame();
});
if (!anyMoving) {
m_physicsPump.stop();
return;
}
markDirty();
m_physicsUpdated = true;
}
+6
View File
@@ -4,6 +4,7 @@
#include <qlist.h>
#include <qobject.h>
#include <qqmlengine.h>
#include <qtimer.h>
class BlobShape;
class BlobInvertedRect;
@@ -51,16 +52,21 @@ class BlobGroup : public QObject {
void markShapeDirty(BlobShape* source);
void ensurePhysicsUpdated();
void requestPhysics();
signals:
void smoothingChanged();
void colorChanged();
void cornerFillChanged();
private:
void pumpPhysics();
qreal m_smoothing = 32.0;
QColor m_color{0x44, 0x88, 0xff};
bool m_cornerFill = true;
QList<BlobShape*> m_shapes;
BlobInvertedRect* m_invertedRect = nullptr;
bool m_physicsUpdated = false;
QTimer m_physicsPump;
};
+65 -39
View File
@@ -29,8 +29,15 @@ static float cornerFillFactor(float sd, float smoothFactor) {
return std::max(outside, inside);
}
namespace {
constexpr int k_pumpMs = 16;
} // namespace
BlobGroup2::BlobGroup2(QQuickItem* parent) : QQuickItem(parent) {
setFlag(ItemHasContents);
m_physicsPump.setInterval(k_pumpMs);
m_physicsPump.setTimerType(Qt::CoarseTimer);
connect(&m_physicsPump, &QTimer::timeout, this, &BlobGroup2::pumpPhysics);
}
BlobGroup2::~BlobGroup2() {
@@ -41,6 +48,7 @@ BlobGroup2::~BlobGroup2() {
}
void BlobGroup2::setSmoothing(qreal s) {
if (s < 0.5) s = 0.5;
if (qFuzzyCompare(m_smoothing, s)) return;
m_smoothing = s;
emit smoothingChanged();
@@ -101,17 +109,11 @@ void BlobGroup2::clearInvertedRect(BlobInvertedRect2* rect) {
void BlobGroup2::markDirty() {
m_physicsUpdated = false;
m_dataBuilt = false;
// Physics runs during polish, before the render pass. The panel content is
// transformed by deformMatrix from QML, so the matrix has to be final for this
// frame; building the data in updatePaintNode would let the transform lag one
// frame behind the material.
polish();
update();
}
void BlobGroup2::updatePolish() {
// The group is the only thing that draws, so it also drives the shapes'
// physics.
ensureDataBuilt();
}
@@ -122,32 +124,53 @@ void BlobGroup2::ensurePhysicsUpdated() {
shape->updatePhysics();
}
void BlobGroup2::requestPhysics() {
if (!m_physicsPump.isActive()) m_physicsPump.start();
}
void BlobGroup2::pumpPhysics() {
for (auto* shape : std::as_const(m_shapes))
shape->updatePhysics();
m_physicsUpdated = true;
const bool anyMoving = std::any_of(
m_shapes.cbegin(), m_shapes.cend(), [](const BlobShape2* shape) {
return shape->needsFrame();
});
if (!anyMoving) {
m_physicsPump.stop();
return;
}
m_dataBuilt = false;
polish();
update();
}
void BlobGroup2::ensureDataBuilt() {
if (m_dataBuilt) return;
ensurePhysicsUpdated();
// Physics can request the next frame through markDirty(); the data is
// refreshed here anyway, so the flag is set after it runs.
m_dataBuilt = true;
m_data.rects.clear();
m_bounds = QRectF();
m_data.hasInverted = false;
m_data.invertedRadius = 0;
memset(m_data.invertedOuter, 0, sizeof(m_data.invertedOuter));
memset(m_data.invertedInner, 0, sizeof(m_data.invertedInner));
memset(m_data.invertedOuterRadii, 0, sizeof(m_data.invertedOuterRadii));
const float smoothFactor = static_cast<float>(m_smoothing);
// The same reach the shader uses to decide whether a rect can contribute, plus
// the shadow, so every pixel the shader can give a value to is inside the node.
const double extent = static_cast<double>(smoothFactor) * 1.5 +
static_cast<double>(m_shadowRadius);
// Group order, no pruning by proximity: the shader sees this list for every pixel. A
// shape that is not pushing is not in the list at all: shapes here are data for the
// group draw, so leaving a panel at rest out means it cannot define any part of the
// edge. A closing panel is still in the list while its curve runs out, even with no
// size left, which is what keeps the bounce.
// entryShapes maps a shared entry back to the shape it came from.
const bool dropAtRest = m_invertedRect != nullptr;
QVector<BlobShape2*> entryShapes;
for (auto* shape : std::as_const(m_shapes)) {
if (!shape->isMoving()) continue;
if (dropAtRest && !shape->isMoving()) continue;
if (shape->width() <= 0 || shape->height() <= 0) continue;
if (static_cast<int>(m_data.rects.size()) >= BlobGroupData::kMaxRects)
continue;
@@ -194,7 +217,6 @@ void BlobGroup2::ensureDataBuilt() {
const int count = static_cast<int>(m_data.rects.size());
// Exclusion gates only the smooth blend.
for (int i = 0; i < count; ++i) {
int mask = 0;
BlobShape2* const si = entryShapes[i];
@@ -214,10 +236,11 @@ void BlobGroup2::ensureDataBuilt() {
BlobShape2* const si = entryShapes[i];
float fTr = 1.0f, fBr = 1.0f, fBl = 1.0f, fTl = 1.0f;
const float cTrX = ri.cx + ri.hw, cTrY = ri.cy - ri.hh;
const float cBrX = ri.cx + ri.hw, cBrY = ri.cy + ri.hh;
const float cBlX = ri.cx - ri.hw, cBlY = ri.cy + ri.hh;
const float cTlX = ri.cx - ri.hw, cTlY = ri.cy - ri.hh;
const float dcx = ri.cx + ri.offsetX, dcy = ri.cy + ri.offsetY;
const float cTrX = dcx + ri.hw, cTrY = dcy - ri.hh;
const float cBrX = dcx + ri.hw, cBrY = dcy + ri.hh;
const float cBlX = dcx - ri.hw, cBlY = dcy + ri.hh;
const float cTlX = dcx - ri.hw, cTlY = dcy - ri.hh;
if (cornerFill) {
for (int j = 0; j < count; ++j) {
@@ -227,37 +250,41 @@ void BlobGroup2::ensureDataBuilt() {
if (si->isCornerExcluded(sj) || sj->isCornerExcluded(si))
continue;
const auto& rj = m_data.rects[j];
const float jcx = rj.cx + rj.offsetX;
const float jcy = rj.cy + rj.offsetY;
fTr = std::min(
fTr,
cornerFillFactor(
cpuSdBox(cTrX, cTrY, rj.cx, rj.cy, rj.hw, rj.hh),
cpuSdBox(cTrX, cTrY, jcx, jcy, rj.hw, rj.hh),
smoothFactor));
fBr = std::min(
fBr,
cornerFillFactor(
cpuSdBox(cBrX, cBrY, rj.cx, rj.cy, rj.hw, rj.hh),
cpuSdBox(cBrX, cBrY, jcx, jcy, rj.hw, rj.hh),
smoothFactor));
fBl = std::min(
fBl,
cornerFillFactor(
cpuSdBox(cBlX, cBlY, rj.cx, rj.cy, rj.hw, rj.hh),
cpuSdBox(cBlX, cBlY, jcx, jcy, rj.hw, rj.hh),
smoothFactor));
fTl = std::min(
fTl,
cornerFillFactor(
cpuSdBox(cTlX, cTlY, rj.cx, rj.cy, rj.hw, rj.hh),
cpuSdBox(cTlX, cTlY, jcx, jcy, rj.hw, rj.hh),
smoothFactor));
}
}
ri.radius[0] = std::max(ri.radius[0] * fTr, minR);
ri.radius[1] = std::max(ri.radius[1] * fBr, minR);
ri.radius[2] = std::max(ri.radius[2] * fBl, minR);
ri.radius[3] = std::max(ri.radius[3] * fTl, minR);
ri.radius[0] = ri.radius[0] > 0.0f ? std::max(ri.radius[0] * fTr, minR)
: 0.0f;
ri.radius[1] = ri.radius[1] > 0.0f ? std::max(ri.radius[1] * fBr, minR)
: 0.0f;
ri.radius[2] = ri.radius[2] > 0.0f ? std::max(ri.radius[2] * fBl, minR)
: 0.0f;
ri.radius[3] = ri.radius[3] > 0.0f ? std::max(ri.radius[3] * fTl, minR)
: 0.0f;
}
// Inverted rect. The frame term is evaluated for every pixel in the bounds,
// so the group flag cannot be gated per shape.
if (!m_invertedRect) return;
auto* inv = static_cast<BlobShape2*>(m_invertedRect);
@@ -297,6 +324,8 @@ void BlobGroup2::ensureDataBuilt() {
m_data.invertedInner[2] = innerHW;
m_data.invertedInner[3] = innerHH;
inv->outerCornerRadii(m_data.invertedOuterRadii);
m_bounds = m_bounds.united(QRectF(
static_cast<double>(outerCX) - static_cast<double>(outerHW) - extent,
static_cast<double>(outerCY) - static_cast<double>(outerHH) - extent,
@@ -311,7 +340,6 @@ void BlobGroup2::fillMaterial(BlobMaterial* material) {
material->m_paddedH = static_cast<float>(m_bounds.height());
material->m_smoothFactor = static_cast<float>(m_smoothing);
// The single group draw has no owner, so the shader skips the ownership test.
material->m_myIndex = -2;
material->m_color = m_color;
material->m_hasInverted = m_data.hasInverted ? 1 : 0;
@@ -324,6 +352,10 @@ void BlobGroup2::fillMaterial(BlobMaterial* material) {
material->m_invertedInner,
data().invertedInner,
sizeof(data().invertedInner));
memcpy(
material->m_invertedOuterRadii,
data().invertedOuterRadii,
sizeof(data().invertedOuterRadii));
material->m_shadowRadius = static_cast<float>(m_shadowRadius);
material->m_shadowColor[0] = static_cast<float>(m_shadowColor.redF());
@@ -339,8 +371,6 @@ void BlobGroup2::fillMaterial(BlobMaterial* material) {
}
QSGNode* BlobGroup2::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) {
// Data is built during polish. This only covers a render pass that happens
// without one.
ensureDataBuilt();
if (m_data.rects.isEmpty() && !m_data.hasInverted) {
@@ -364,10 +394,6 @@ QSGNode* BlobGroup2::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) {
node->setFlag(QSGNode::OwnsMaterial);
}
// The shader evaluates the field at the scene position a vertex maps to, so
// the group item itself must not be transformed. The vertex positions are the
// bounds in this item's frame; the material carries the same rect in scene
// coordinates.
auto* v = node->geometry()->vertexDataAsTexturedPoint2D();
const QPointF p0 = mapFromScene(m_bounds.topLeft());
const QPointF p1 = mapFromScene(m_bounds.bottomRight());
+6 -14
View File
@@ -9,22 +9,13 @@
#include <qquickitem.h>
#include <qrect.h>
#include <qsgnode.h>
#include <qtimer.h>
#include <qvector.h>
class BlobShape2;
class BlobInvertedRect2;
// One shared valuation for a whole group, drawn by one node.
//
// The shapes here are data only: they say where the material should be. The group
// draws a single quad over everything the shader can evaluate, so a pixel cannot
// be evaluated by one shape and missed by another, and no second draw can
// disagree with the first. That is what makes overlapping shapes read as one
// surface - one value, one writer, one alpha per pixel.
struct BlobGroupData {
// The shader reads rectData[80] = 16 entries, and excludeMask is a 32-bit
// mask. The shared list is capped at 16, in group order, so the cap is the
// same list for every pixel.
static constexpr int kMaxRects = 16;
QVector<BlobRectData> rects;
@@ -32,6 +23,7 @@ struct BlobGroupData {
float invertedRadius = 0;
float invertedOuter[4] = {};
float invertedInner[4] = {};
float invertedOuterRadii[4] = {};
};
class BlobGroup2 : public QQuickItem {
@@ -91,10 +83,10 @@ class BlobGroup2 : public QQuickItem {
[[nodiscard]] const BlobGroupData& data() const { return m_data; }
// One draw per group, so a change to any shape changes the value for every
// pixel. Refresh the whole group.
void markDirty();
void requestPhysics();
signals:
void smoothingChanged();
void colorChanged();
@@ -110,6 +102,7 @@ class BlobGroup2 : public QQuickItem {
void ensurePhysicsUpdated();
void ensureDataBuilt();
void fillMaterial(BlobMaterial* material);
void pumpPhysics();
qreal m_smoothing = 32.0;
QColor m_color{0x44, 0x88, 0xff};
@@ -120,9 +113,8 @@ class BlobGroup2 : public QQuickItem {
BlobInvertedRect2* m_invertedRect = nullptr;
bool m_physicsUpdated = false;
bool m_dataBuilt = false;
QTimer m_physicsPump;
BlobGroupData m_data;
// Scene-space union of what the shader can evaluate for any shape. The node
// covers exactly this, so gaps are not possible.
QRectF m_bounds;
};
+8 -14
View File
@@ -11,28 +11,24 @@
BlobInvertedRect::BlobInvertedRect(QQuickItem* parent) : BlobShape(parent) {}
static void setFrameIndices(quint16* idx) {
// Top strip: 0-1-4, 1-5-4
idx[0] = 0;
idx[1] = 1;
idx[2] = 4;
idx[3] = 1;
idx[4] = 5;
idx[5] = 4;
// Right strip: 1-2-5, 2-6-5
idx[6] = 1;
idx[7] = 2;
idx[8] = 5;
idx[9] = 2;
idx[10] = 6;
idx[11] = 5;
// Bottom strip: 2-3-6, 3-7-6
idx[12] = 2;
idx[13] = 3;
idx[14] = 6;
idx[15] = 3;
idx[16] = 7;
idx[17] = 6;
// Left strip: 3-0-7, 0-4-7
idx[18] = 3;
idx[19] = 0;
idx[20] = 7;
@@ -50,17 +46,20 @@ QSGNode* BlobInvertedRect::updatePaintNode(
const float pad = static_cast<float>(m_group->smoothing());
// Compute inner hole boundary in local coords
// Inset past the inner border edge by 2x smoothing to cover the blend zone
const float inset = pad * 2.0f;
const float holeLeft = static_cast<float>(m_borderLeft) + inset;
const float holeTop = static_cast<float>(m_borderTop) + inset;
const float holeRight = static_cast<float>(width() - m_borderRight) - inset;
const float holeBot = static_cast<float>(height() - m_borderBottom) - inset;
// If the hole is too small or invalid, fall back to full quad
if (holeLeft >= holeRight || holeTop >= holeBot)
if (holeLeft >= holeRight || holeTop >= holeBot) {
auto* oldGeoNode = static_cast<QSGGeometryNode*>(oldNode);
if (oldNode && oldGeoNode->geometry()->vertexCount() != 4) {
delete oldNode;
oldNode = nullptr;
}
return BlobShape::updatePaintNode(oldNode, nullptr);
}
auto* node = static_cast<QSGGeometryNode*>(oldNode);
@@ -87,7 +86,6 @@ QSGNode* BlobInvertedRect::updatePaintNode(
node->setFlag(QSGNode::OwnsMaterial);
}
// Outer bounds (local coords)
const float x0 = static_cast<float>(m_localPaddedRect.x());
const float y0 = static_cast<float>(m_localPaddedRect.y());
const float x1 = x0 + static_cast<float>(m_localPaddedRect.width());
@@ -95,15 +93,12 @@ QSGNode* BlobInvertedRect::updatePaintNode(
const float w = x1 - x0;
const float h = y1 - y0;
// Update vertex positions and texture coordinates
auto* v = node->geometry()->vertexDataAsTexturedPoint2D();
// Outer corners
v[0].set(x0, y0, 0.0f, 0.0f);
v[1].set(x1, y0, 1.0f, 0.0f);
v[2].set(x1, y1, 1.0f, 1.0f);
v[3].set(x0, y1, 0.0f, 1.0f);
// Inner corners (hole)
v[4].set(holeLeft, holeTop, (holeLeft - x0) / w, (holeTop - y0) / h);
v[5].set(holeRight, holeTop, (holeRight - x0) / w, (holeTop - y0) / h);
v[6].set(holeRight, holeBot, (holeRight - x0) / w, (holeBot - y0) / h);
@@ -111,7 +106,6 @@ QSGNode* BlobInvertedRect::updatePaintNode(
node->markDirty(QSGNode::DirtyGeometry);
// Update material uniforms
auto* material = static_cast<BlobMaterial*>(node->material());
material->m_paddedX = m_cachedPaddedX;
material->m_paddedY = m_cachedPaddedY;
@@ -218,7 +212,7 @@ void BlobInvertedRect::outerCornerRadii(float out[4]) const {
const auto base = std::min(static_cast<float>(m_outerRadius), maxR);
out[0] = std::min(
m_outerTopRightRadius >= 0 ? static_cast<float>(m_outerTopRightRadius)
: base,
: base,
maxR);
out[1] = std::min(
m_outerBottomRightRadius >= 0
+6 -3
View File
@@ -59,12 +59,15 @@ class BlobInvertedRect : public BlobShape {
void setOuterRadius(qreal v);
// Per-corner outer radii. -1 falls back to outerRadius (mirrors BlobRect).
[[nodiscard]] qreal outerTopLeftRadius() const { return m_outerTopLeftRadius; }
[[nodiscard]] qreal outerTopLeftRadius() const {
return m_outerTopLeftRadius;
}
void setOuterTopLeftRadius(qreal v);
[[nodiscard]] qreal outerTopRightRadius() const { return m_outerTopRightRadius; }
[[nodiscard]] qreal outerTopRightRadius() const {
return m_outerTopRightRadius;
}
void setOuterTopRightRadius(qreal v);
@@ -1,6 +1,8 @@
#include "blobinvertedrect2.hpp"
#include "blobgroup2.hpp"
#include <algorithm>
BlobInvertedRect2::BlobInvertedRect2(QQuickItem* parent) : BlobShape2(parent) {}
BlobInvertedRect2::~BlobInvertedRect2() {
@@ -35,6 +37,64 @@ void BlobInvertedRect2::setBorderBottom(qreal v) {
if (m_group) m_group->markDirty();
}
void BlobInvertedRect2::setOuterRadius(qreal v) {
if (qFuzzyCompare(m_outerRadius, v)) return;
m_outerRadius = v;
emit outerRadiusChanged();
if (m_group) m_group->markDirty();
}
void BlobInvertedRect2::setOuterTopLeftRadius(qreal v) {
if (qFuzzyCompare(m_outerTopLeftRadius, v)) return;
m_outerTopLeftRadius = v;
emit outerTopLeftRadiusChanged();
if (m_group) m_group->markDirty();
}
void BlobInvertedRect2::setOuterTopRightRadius(qreal v) {
if (qFuzzyCompare(m_outerTopRightRadius, v)) return;
m_outerTopRightRadius = v;
emit outerTopRightRadiusChanged();
if (m_group) m_group->markDirty();
}
void BlobInvertedRect2::setOuterBottomLeftRadius(qreal v) {
if (qFuzzyCompare(m_outerBottomLeftRadius, v)) return;
m_outerBottomLeftRadius = v;
emit outerBottomLeftRadiusChanged();
if (m_group) m_group->markDirty();
}
void BlobInvertedRect2::setOuterBottomRightRadius(qreal v) {
if (qFuzzyCompare(m_outerBottomRightRadius, v)) return;
m_outerBottomRightRadius = v;
emit outerBottomRightRadiusChanged();
if (m_group) m_group->markDirty();
}
void BlobInvertedRect2::outerCornerRadii(float out[4]) const {
const auto maxR = static_cast<float>(std::min(width(), height())) * 0.5f;
const auto base = std::min(static_cast<float>(m_outerRadius), maxR);
out[0] = std::min(
m_outerTopRightRadius >= 0 ? static_cast<float>(m_outerTopRightRadius)
: base,
maxR);
out[1] = std::min(
m_outerBottomRightRadius >= 0
? static_cast<float>(m_outerBottomRightRadius)
: base,
maxR);
out[2] = std::min(
m_outerBottomLeftRadius >= 0
? static_cast<float>(m_outerBottomLeftRadius)
: base,
maxR);
out[3] = std::min(
m_outerTopLeftRadius >= 0 ? static_cast<float>(m_outerTopLeftRadius)
: base,
maxR);
}
void BlobInvertedRect2::registerWithGroup() {
if (m_group) m_group->setInvertedRect(this);
}
@@ -19,6 +19,21 @@ class BlobInvertedRect2 : public BlobShape2 {
Q_PROPERTY(
qreal borderBottom READ borderBottom WRITE setBorderBottom NOTIFY
borderBottomChanged)
Q_PROPERTY(
qreal outerRadius READ outerRadius WRITE setOuterRadius NOTIFY
outerRadiusChanged)
Q_PROPERTY(
qreal outerTopLeftRadius READ outerTopLeftRadius WRITE
setOuterTopLeftRadius NOTIFY outerTopLeftRadiusChanged)
Q_PROPERTY(
qreal outerTopRightRadius READ outerTopRightRadius WRITE
setOuterTopRightRadius NOTIFY outerTopRightRadiusChanged)
Q_PROPERTY(
qreal outerBottomLeftRadius READ outerBottomLeftRadius WRITE
setOuterBottomLeftRadius NOTIFY outerBottomLeftRadiusChanged)
Q_PROPERTY(
qreal outerBottomRightRadius READ outerBottomRightRadius WRITE
setOuterBottomRightRadius NOTIFY outerBottomRightRadiusChanged)
public:
explicit BlobInvertedRect2(QQuickItem* parent = nullptr);
@@ -40,13 +55,48 @@ class BlobInvertedRect2 : public BlobShape2 {
void setBorderBottom(qreal v);
[[nodiscard]] qreal outerRadius() const { return m_outerRadius; }
void setOuterRadius(qreal v);
[[nodiscard]] qreal outerTopLeftRadius() const {
return m_outerTopLeftRadius;
}
void setOuterTopLeftRadius(qreal v);
[[nodiscard]] qreal outerTopRightRadius() const {
return m_outerTopRightRadius;
}
void setOuterTopRightRadius(qreal v);
[[nodiscard]] qreal outerBottomLeftRadius() const {
return m_outerBottomLeftRadius;
}
void setOuterBottomLeftRadius(qreal v);
[[nodiscard]] qreal outerBottomRightRadius() const {
return m_outerBottomRightRadius;
}
void setOuterBottomRightRadius(qreal v);
signals:
void borderLeftChanged();
void borderRightChanged();
void borderTopChanged();
void borderBottomChanged();
void outerRadiusChanged();
void outerTopLeftRadiusChanged();
void outerTopRightRadiusChanged();
void outerBottomLeftRadiusChanged();
void outerBottomRightRadiusChanged();
protected:
void outerCornerRadii(float out[4]) const override;
void registerWithGroup() override;
void unregisterFromGroup() override;
@@ -55,4 +105,10 @@ class BlobInvertedRect2 : public BlobShape2 {
qreal m_borderRight = 0;
qreal m_borderTop = 0;
qreal m_borderBottom = 0;
qreal m_outerRadius = 0;
qreal m_outerTopLeftRadius = -1;
qreal m_outerTopRightRadius = -1;
qreal m_outerBottomLeftRadius = -1;
qreal m_outerBottomRightRadius = -1;
};
-15
View File
@@ -44,22 +44,17 @@ bool BlobMaterialShader::updateUniformData(
memcpy(buf->data() + 64, &opacity, 4);
}
// Padded rect (offset 68)
memcpy(buf->data() + 68, &mat->m_paddedX, 4);
memcpy(buf->data() + 72, &mat->m_paddedY, 4);
memcpy(buf->data() + 76, &mat->m_paddedW, 4);
memcpy(buf->data() + 80, &mat->m_paddedH, 4);
// Smooth factor (offset 84)
memcpy(buf->data() + 84, &mat->m_smoothFactor, 4);
// Rect count (offset 88)
memcpy(buf->data() + 88, &mat->m_rectCount, 4);
// My index (offset 92). -2 skips the ownership test in the shader.
memcpy(buf->data() + 92, &mat->m_myIndex, 4);
// Color as vec4 (offset 96, 16 bytes)
const float color[4] = {
mat->m_color.redF(),
mat->m_color.greenF(),
@@ -68,26 +63,19 @@ bool BlobMaterialShader::updateUniformData(
};
memcpy(buf->data() + 96, color, 16);
// Has inverted (offset 112)
memcpy(buf->data() + 112, &mat->m_hasInverted, 4);
// Inverted radius (offset 116)
memcpy(buf->data() + 116, &mat->m_invertedRadius, 4);
// Padding at 120-127 (skip)
// Inverted outer (offset 128, 16 bytes)
memcpy(buf->data() + 128, mat->m_invertedOuter, 16);
// Inverted inner (offset 144, 16 bytes)
memcpy(buf->data() + 144, mat->m_invertedInner, 16);
// Rect data (offset 160, each rect = 5 vec4s = 80 bytes)
const int count = qMin(mat->m_rectCount, 16);
for (int i = 0; i < count; ++i) {
const auto& r = mat->m_rects[i];
const int base = 160 + i * 80;
// Pack excludeMask into props.x via bit-cast (read in shader with floatBitsToInt)
float maskAsFloat = NAN;
memcpy(&maskAsFloat, &r.excludeMask, sizeof(float));
const float d0[4] = {r.cx, r.cy, r.hw, r.hh};
@@ -100,12 +88,9 @@ bool BlobMaterialShader::updateUniformData(
memcpy(buf->data() + base + 64, r.radius, 16);
}
// Shadow: after rectData, which ends at 1440. shadowColor is 16-byte aligned,
// so it lands at 1456.
memcpy(buf->data() + 1440, &mat->m_shadowRadius, 4);
memcpy(buf->data() + 1456, mat->m_shadowColor, 16);
// Outer edge corner radii, after shadowColor, at 1472.
memcpy(buf->data() + 1472, mat->m_invertedOuterRadii, 16);
return true;
-10
View File
@@ -8,14 +8,9 @@ struct BlobRectData {
float cx = 0, cy = 0, hw = 0, hh = 0;
float offsetX = 0, offsetY = 0;
float minEig = 1.0f;
// Inverse of 2x2 deformation matrix, column-major for GLSL
float invDeform[4] = {1, 0, 0, 1};
// Screen-space AABB half-extents of the deformed rect
float screenHalfX = 0, screenHalfY = 0;
// Effective per-corner radii (tr, br, bl, tl), pre-computed on CPU
float radius[4] = {0, 0, 0, 0};
// Bitmask of indices in this rect's m_cachedRects that mutually exclude (or are excluded by) this rect.
// Used by the shader to skip smin between excluded pairs.
int excludeMask = 0;
};
@@ -32,19 +27,14 @@ class BlobMaterial : public QSGMaterial {
float m_paddedH = 0;
float m_smoothFactor = 32.0f;
int m_rectCount = 0;
// -2 means no owner test: the single group draw writes every pixel it covers.
int m_myIndex = -2;
QColor m_color{0x44, 0x88, 0xff};
int m_hasInverted = 0;
float m_invertedRadius = 0;
float m_invertedOuter[4] = {};
float m_invertedInner[4] = {};
// Phase 2: shadow around the material edges. Matches the uniform block, which
// puts these after rectData.
float m_shadowRadius = 0.0f;
float m_shadowColor[4] = {0.0f, 0.0f, 0.0f, 0.5f};
// Outer-edge corner radii (tr, br, bl, tl). Matches the uniform block, which
// puts this after shadowColor.
float m_invertedOuterRadii[4] = {};
BlobRectData m_rects[16] = {};
};
+169 -77
View File
@@ -7,19 +7,116 @@
namespace {
constexpr float k_retireMs = 120.0f;
constexpr qint64 k_minEvalMs = 10;
constexpr float k_staleSampleSec = 0.1f;
constexpr float k_staleMovePx = 2.0f;
constexpr float k_moveSpeed = 5.0f;
constexpr float k_settleDelta = 0.004f;
constexpr float k_settleVel = 0.05f;
constexpr float k_identityEps = 0.0005f;
constexpr float k_stillPx = 0.5f;
constexpr int k_settleStillSamples = 3;
// How far the deform matrix is from identity, summed over its three distinct
// entries. The matrix is applied as a panel's transform, so it has to reach
// *exactly* identity rather than merely close to it: a residue of 1e-4 scales
// a 400 logical px panel by 0.04 px, which moves a device-aligned child onto a
// fraction of a device pixel, and anything that centers itself inside the
// panel (`anchors` with `alignWhenCentered`, `Layout.HCenter`/`VCenter`) then
// antialiases. The residue also differs per entry, so different children land
// on different sub-pixel offsets - which reads as random blur.
float identityResidue(float dm00, float dm01, float dm11) {
return std::abs(dm00 - 1.0f) + std::abs(dm01) + std::abs(dm11 - 1.0f);
}
BlobRect::BlobRect(QQuickItem* parent) : BlobShape(parent) {}
bool nearIdentity(float dm00, float dm01, float dm11) {
return identityResidue(dm00, dm01, dm11) <= k_identityEps;
}
} // namespace
BlobRect::BlobRect(QQuickItem* parent) : BlobShape(parent) {
m_stillSamples = k_settleStillSamples;
}
BlobRect::~BlobRect() {
if (m_group) m_group->removeShape(this);
}
bool BlobRect::needsFrame() const {
// Pump until the matrix is *exactly* identity, not merely within epsilon:
// updatePhysics() snaps it there once it is near enough, so this stays true
// only for the frames between the last integration and that snap.
return m_physicsActive || m_retiring ||
m_stillSamples < k_settleStillSamples ||
identityResidue(m_dm00, m_dm01, m_dm11) > 0.0f;
}
void BlobRect::resetDeformMatrix() {
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f;
m_retiring = false;
m_physicsActive = false;
BlobShape::resetDeformMatrix();
}
void BlobRect::requestPhysics() {
if (m_group) m_group->requestPhysics();
}
void BlobRect::updatePolish() {
BlobShape::updatePolish();
if (m_physicsActive || m_retiring) requestPhysics();
}
void BlobRect::finishAtIdentity() {
const bool settled = identityResidue(m_dm00, m_dm01, m_dm11) <= 0.0f &&
!m_retiring && !m_physicsActive;
if (settled) return;
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
m_deformMatrix = QMatrix4x4();
m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f;
m_retiring = false;
m_physicsActive = false;
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
if (m_group) m_group->markDirty();
}
void BlobRect::beginRetire() {
if (m_retiring) return;
m_retire00 = m_dm00;
m_retire01 = m_dm01;
m_retire11 = m_dm11;
m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f;
m_retireElapsed.start();
m_retiring = true;
requestPhysics();
}
void BlobRect::trySettle(float speed) {
if (m_retiring) return;
if (!m_physicsActive) return;
const float totalDelta =
std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + std::abs(m_dm11 - 1.0f);
const float totalVel =
std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11);
if (speed < k_moveSpeed && totalDelta < k_settleDelta &&
totalVel < k_settleVel && m_stillSamples >= k_settleStillSamples) {
beginRetire();
}
}
void BlobRect::updatePhysics() {
if (m_retiring) {
const float t = std::clamp(
static_cast<float>(m_retireElapsed.elapsed()) / k_retireMs,
@@ -28,100 +125,93 @@ void BlobRect::updatePolish() {
const float s = t * t * (3.0f - 2.0f * t);
if (t >= 1.0f) {
finishAtIdentity();
return;
}
m_dm00 = m_retire00 + (1.0f - m_retire00) * s;
m_dm01 = m_retire01 - m_retire01 * s;
m_dm11 = m_retire11 + (1.0f - m_retire11) * s;
// Snap the emitted matrix to exact identity while the retire tail is
// below the visible threshold, but keep retiring: ending the retire early
// hides the background while the panel animation still has a tail.
if (nearIdentity(m_dm00, m_dm01, m_dm11)) {
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
m_deformMatrix = QMatrix4x4();
m_retiring = false;
m_physicsActive = false;
} else {
m_dm00 = m_retire00 + (1.0f - m_retire00) * s;
m_dm01 = m_retire01 - m_retire01 * s;
m_dm11 = m_retire11 + (1.0f - m_retire11) * s;
m_deformMatrix = QMatrix4x4(
m_dm00,
m_dm01,
0,
0,
m_dm01,
m_dm11,
0,
0,
0,
0,
1,
0,
0,
0,
0,
1);
}
m_deformMatrix = QMatrix4x4(
m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
if (!m_physicsActive) return;
} else {
const float totalDelta = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) +
std::abs(m_dm11 - 1.0f);
const float totalVel =
std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11);
if (totalDelta < 0.004f && totalVel < 0.05f) beginRetire();
}
QMetaObject::invokeMethod(
this,
[this]() {
if (m_physicsActive && m_group) m_group->markDirty();
},
Qt::QueuedConnection);
}
void BlobRect::beginRetire() {
m_retire00 = m_dm00;
m_retire01 = m_dm01;
m_retire11 = m_dm11;
m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f;
m_retireElapsed.start();
m_retiring = true;
}
void BlobRect::updatePhysics() {
const QPointF scenePos = mapToScene(QPointF(width() / 2.0, height() / 2.0));
if (!m_physicsActive && !m_retiring) {
const float residue = identityResidue(m_dm00, m_dm01, m_dm11);
if (residue > k_identityEps) {
beginRetire();
return;
}
// Residue at or below the threshold is invisible, so it is written as
// exact identity - the panel transform then stops being a transform at
// all. Sampling continues below, so a new movement can restart physics.
if (residue > 0.0f) finishAtIdentity();
}
if (!m_hasPrevPos) {
m_prevScenePos = scenePos;
m_elapsed.start();
m_hasPrevPos = true;
m_stillSamples = k_settleStillSamples;
return;
}
const float dt = static_cast<float>(m_elapsed.restart()) / 1000.0f;
if (dt > 0.1f || dt < 0.001f) {
m_prevScenePos = scenePos;
if (m_physicsActive) checkAtRest(0.0f);
const qint64 evalMs = m_elapsed.elapsed();
if (evalMs < k_minEvalMs) {
return;
}
m_elapsed.restart();
const float velX =
static_cast<float>(scenePos.x() - m_prevScenePos.x()) / dt;
const float velY =
static_cast<float>(scenePos.y() - m_prevScenePos.y()) / dt;
const float dx = static_cast<float>(scenePos.x() - m_prevScenePos.x());
const float dy = static_cast<float>(scenePos.y() - m_prevScenePos.y());
m_prevScenePos = scenePos;
if (std::abs(dx) + std::abs(dy) > k_stillPx)
m_stillSamples = 0;
else
m_stillSamples += 1;
const float dt = static_cast<float>(evalMs) / 1000.0f;
if (dt > k_staleSampleSec) {
if (std::abs(dx) + std::abs(dy) > k_staleMovePx) {
m_physicsActive = true;
requestPhysics();
} else {
trySettle(0.0f);
}
return;
}
const float velX = dx / dt;
const float velY = dy / dt;
const float speed = std::sqrt(velX * velX + velY * velY);
if (m_retiring) {
if (speed > 5.0f)
if (speed > k_moveSpeed)
m_retiring = false;
else
return;
}
if (!m_physicsActive) {
if (speed < 5.0f) return;
if (speed < k_moveSpeed) return;
m_physicsActive = true;
requestPhysics();
}
const float kStretchFactor = static_cast<float>(m_deformScale);
@@ -131,7 +221,7 @@ void BlobRect::updatePhysics() {
float target01 = 0.0f;
float target11 = 1.0f;
if (speed > 5.0f) {
if (speed > k_moveSpeed) {
const float targetStretch =
1.0f + std::min(speed * kStretchFactor, kMaxStretch);
const float targetCompress = 1.0f / targetStretch;
@@ -160,12 +250,27 @@ void BlobRect::updatePhysics() {
m_dmVel11 = (m_dmVel11 - kStiffness * (m_dm11 - target11) * dt) * invDamp;
m_dm11 += m_dmVel11 * dt;
// Land on exact identity as soon as the spring is within the invisible
// threshold, so the panel's contents are back on the device pixel grid -
// but keep physicsActive until trySettle() completes: the at-rest drop
// must not happen while the panel animation still has a slow tail.
if (speed < k_moveSpeed && nearIdentity(m_dm00, m_dm01, m_dm11)) {
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
m_deformMatrix = QMatrix4x4();
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
trySettle(speed);
return;
}
m_deformMatrix = QMatrix4x4(
m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
checkAtRest(speed);
trySettle(speed);
}
void BlobRect::setTopLeftRadius(qreal r) {
@@ -340,16 +445,3 @@ void BlobRect::excludeCornersRemoveLast(QQmlListProperty<BlobRect>* prop) {
if (self->m_group) self->m_group->markDirty();
emit self->excludeCornersChanged();
}
void BlobRect::checkAtRest(float speed) {
if (m_retiring) return;
constexpr float kEpsilon = 0.002f;
const bool atRest =
std::abs(m_dm00 - 1.0f) < kEpsilon && std::abs(m_dm01) < kEpsilon &&
std::abs(m_dm11 - 1.0f) < kEpsilon && std::abs(m_dmVel00) < kEpsilon &&
std::abs(m_dmVel01) < kEpsilon && std::abs(m_dmVel11) < kEpsilon &&
speed < 5.0f;
if (atRest) beginRetire();
}
+6 -1
View File
@@ -105,15 +105,20 @@ class BlobRect : public BlobShape {
protected:
void updatePolish() override;
void updatePhysics() override;
bool needsFrame() const override;
void resetDeformMatrix() override;
private:
void checkAtRest(float speed);
void trySettle(float speed);
void beginRetire();
void finishAtIdentity();
void requestPhysics();
QPointF m_prevScenePos;
QElapsedTimer m_elapsed;
bool m_physicsActive = false;
bool m_hasPrevPos = false;
int m_stillSamples = 0;
bool m_retiring = false;
QElapsedTimer m_retireElapsed;
+162 -50
View File
@@ -6,28 +6,96 @@
namespace {
constexpr float k_retireMs = 120.0f;
constexpr qint64 k_minEvalMs = 10;
constexpr float k_staleSampleSec = 0.1f;
constexpr float k_staleMovePx = 2.0f;
constexpr float k_moveSpeed = 5.0f;
constexpr float k_settleDelta = 0.004f;
constexpr float k_settleVel = 0.05f;
constexpr float k_identityEps = 0.0005f;
constexpr float k_stillPx = 0.5f;
constexpr int k_settleStillSamples = 3;
// How far the deform matrix is from identity, summed over its three distinct
// entries. The matrix is applied as a panel's transform, so it has to reach
// *exactly* identity rather than merely close to it: a residue of 1e-4 scales
// a 400 logical px panel by 0.04 px, which moves a device-aligned child onto a
// fraction of a device pixel, and anything that centers itself inside the
// panel (`anchors` with `alignWhenCentered`, `Layout.HCenter`/`VCenter`) then
// antialiases. The residue also differs per entry, so different children land
// on different sub-pixel offsets - which reads as random blur.
float identityResidue(float dm00, float dm01, float dm11) {
return std::abs(dm00 - 1.0f) + std::abs(dm01) + std::abs(dm11 - 1.0f);
}
bool nearIdentity(float dm00, float dm01, float dm11) {
return identityResidue(dm00, dm01, dm11) <= k_identityEps;
}
} // namespace
BlobRect2::BlobRect2(QQuickItem* parent) : BlobShape2(parent) {}
BlobRect2::BlobRect2(QQuickItem* parent) : BlobShape2(parent) {
m_stillSamples = k_settleStillSamples;
}
BlobRect2::~BlobRect2() {
if (m_group) m_group->removeShape(this);
}
bool BlobRect2::needsFrame() const {
// Pump until the matrix is *exactly* identity, not merely within epsilon:
// updatePhysics() snaps it there once it is near enough, so this stays true
// only for the frames between the last integration and that snap.
return m_physicsActive || m_retiring ||
m_stillSamples < k_settleStillSamples ||
identityResidue(m_dm00, m_dm01, m_dm11) > 0.0f;
}
bool BlobRect2::isMoving() const {
return m_atRestOverride || m_physicsActive || m_retiring ||
m_stillSamples < k_settleStillSamples;
}
void BlobRect2::updateMoving() {
const bool moving = isMoving();
if (moving == m_movingEmitted) return;
m_movingEmitted = moving;
emit movingChanged();
}
void BlobRect2::resetDeformMatrix() {
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f;
m_retiring = false;
m_physicsActive = false;
BlobShape2::resetDeformMatrix();
}
void BlobRect2::requestPhysics() {
if (m_group) m_group->requestPhysics();
}
void BlobRect2::finishAtIdentity() {
const bool settled = identityResidue(m_dm00, m_dm01, m_dm11) <= 0.0f &&
!m_retiring && !m_physicsActive;
if (settled) return;
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
m_deformMatrix = QMatrix4x4();
m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f;
m_retiring = false;
m_physicsActive = false;
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
if (m_group) m_group->markDirty();
updateMoving();
}
void BlobRect2::beginRetire() {
// The settle check would otherwise re-enter this every frame and restart the
// curve timer, so the curve never reaches t = 1.
if (m_retiring) return;
m_retire00 = m_dm00;
@@ -36,10 +104,26 @@ void BlobRect2::beginRetire() {
m_dmVel00 = m_dmVel01 = m_dmVel11 = 0.0f;
m_retireElapsed.start();
m_retiring = true;
requestPhysics();
updateMoving();
}
void BlobRect2::trySettle(float speed) {
if (m_retiring) return;
if (!m_physicsActive) return;
const float totalDelta =
std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + std::abs(m_dm11 - 1.0f);
const float totalVel =
std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11);
if (speed < k_moveSpeed && totalDelta < k_settleDelta &&
totalVel < k_settleVel && m_stillSamples >= k_settleStillSamples) {
beginRetire();
}
}
void BlobRect2::updatePhysics() {
// Shapes are data only, so the group drives physics here.
if (m_retiring) {
const float t = std::clamp(
static_cast<float>(m_retireElapsed.elapsed()) / k_retireMs,
@@ -55,6 +139,17 @@ void BlobRect2::updatePhysics() {
m_dm00 = m_retire00 + (1.0f - m_retire00) * s;
m_dm01 = m_retire01 - m_retire01 * s;
m_dm11 = m_retire11 + (1.0f - m_retire11) * s;
// Snap the emitted matrix to exact identity while the retire tail is
// below the visible threshold, but keep retiring: isMoving() drives the
// at-rest drop, and finishing early hides the background while the panel
// animation still has a tail.
if (nearIdentity(m_dm00, m_dm01, m_dm11)) {
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
}
m_deformMatrix = QMatrix4x4(
m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
emit rawDeformMatrixChanged();
@@ -63,12 +158,19 @@ void BlobRect2::updatePhysics() {
if (!m_physicsActive) return;
}
// No "not active" guard here: an inactive shape still has to be evaluated so it
// can see the panel start moving and become active. The frame loop is only kept
// alive while active, and it is requested before anything can return early —
// requesting it at the end lets a shape stop being evaluated with the matrix
// still off identity, which is what left the panel contents blurry.
if (m_physicsActive && m_group) m_group->markDirty();
if (!m_physicsActive && !m_retiring) {
const float residue = identityResidue(m_dm00, m_dm01, m_dm11);
if (residue > k_identityEps) {
beginRetire();
return;
}
// Residue at or below the threshold is invisible, so it is written as
// exact identity - the panel transform then stops being a transform at
// all. Sampling continues below, so a new movement can restart physics.
if (residue > 0.0f) finishAtIdentity();
}
if (m_physicsActive) requestPhysics();
const QPointF scenePos = mapToScene(QPointF(width() / 2.0, height() / 2.0));
@@ -76,34 +178,54 @@ void BlobRect2::updatePhysics() {
m_prevScenePos = scenePos;
m_elapsed.start();
m_hasPrevPos = true;
m_stillSamples = k_settleStillSamples;
updateMoving();
return;
}
const float dt = static_cast<float>(m_elapsed.restart()) / 1000.0f;
if (dt > 0.1f || dt < 0.001f) {
m_prevScenePos = scenePos;
if (m_physicsActive) checkAtRest(0.0f);
const qint64 evalMs = m_elapsed.elapsed();
if (evalMs < k_minEvalMs) {
return;
}
m_elapsed.restart();
const float velX =
static_cast<float>(scenePos.x() - m_prevScenePos.x()) / dt;
const float velY =
static_cast<float>(scenePos.y() - m_prevScenePos.y()) / dt;
const float dx = static_cast<float>(scenePos.x() - m_prevScenePos.x());
const float dy = static_cast<float>(scenePos.y() - m_prevScenePos.y());
m_prevScenePos = scenePos;
if (std::abs(dx) + std::abs(dy) > k_stillPx)
m_stillSamples = 0;
else
m_stillSamples += 1;
const float dt = static_cast<float>(evalMs) / 1000.0f;
if (dt > k_staleSampleSec) {
if (std::abs(dx) + std::abs(dy) > k_staleMovePx) {
m_physicsActive = true;
requestPhysics();
updateMoving();
} else {
trySettle(0.0f);
}
return;
}
const float velX = dx / dt;
const float velY = dy / dt;
const float speed = std::sqrt(velX * velX + velY * velY);
if (m_retiring) {
if (speed > 5.0f)
if (speed > k_moveSpeed)
m_retiring = false;
else
return;
}
if (!m_physicsActive) {
if (speed < 5.0f) return;
if (speed < k_moveSpeed) return;
m_physicsActive = true;
requestPhysics();
}
const float kStretchFactor = static_cast<float>(m_deformScale);
@@ -113,7 +235,7 @@ void BlobRect2::updatePhysics() {
float target01 = 0.0f;
float target11 = 1.0f;
if (speed > 5.0f) {
if (speed > k_moveSpeed) {
const float targetStretch =
1.0f + std::min(speed * kStretchFactor, kMaxStretch);
const float targetCompress = 1.0f / targetStretch;
@@ -142,27 +264,30 @@ void BlobRect2::updatePhysics() {
m_dmVel11 = (m_dmVel11 - kStiffness * (m_dm11 - target11) * dt) * invDamp;
m_dm11 += m_dmVel11 * dt;
// Land on exact identity as soon as the spring is within the invisible
// threshold, so the panel's contents are back on the device pixel grid -
// but keep physicsActive until trySettle() completes: isMoving() drives the
// at-rest drop, and clearing physics early hides the background while the
// panel animation still has a slow tail.
if (speed < k_moveSpeed && nearIdentity(m_dm00, m_dm01, m_dm11)) {
m_dm00 = 1.0f;
m_dm01 = 0.0f;
m_dm11 = 1.0f;
m_deformMatrix = QMatrix4x4();
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
trySettle(speed);
updateMoving();
return;
}
m_deformMatrix = QMatrix4x4(
m_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
// Start the retire as soon as the matrix is close to identity, as in v1. Waiting
// for every term to fall below kEpsilon keeps the matrix just off identity for
// longer, and the panel content is transformed by it, which reads as blur on
// text.
const float totalDelta =
std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) + std::abs(m_dm11 - 1.0f);
const float totalVel =
std::abs(m_dmVel00) + std::abs(m_dmVel01) + std::abs(m_dmVel11);
// The residual is already inside the rest epsilon, so the eased curve would only
// be aborted or restarted every frame by the panel creeping. Snap instead.
if (totalDelta < 0.004f && totalVel < 0.05f) {
finishAtIdentity();
return;
}
checkAtRest(speed);
trySettle(speed);
updateMoving();
}
void BlobRect2::setTopLeftRadius(qreal r) {
@@ -338,16 +463,3 @@ void BlobRect2::excludeCornersRemoveLast(QQmlListProperty<BlobRect2>* prop) {
if (self->m_group) self->m_group->markDirty();
emit self->excludeCornersChanged();
}
void BlobRect2::checkAtRest(float speed) {
if (m_retiring) return;
constexpr float kEpsilon = 0.002f;
const bool atRest =
std::abs(m_dm00 - 1.0f) < kEpsilon && std::abs(m_dm01) < kEpsilon &&
std::abs(m_dm11 - 1.0f) < kEpsilon && std::abs(m_dmVel00) < kEpsilon &&
std::abs(m_dmVel01) < kEpsilon && std::abs(m_dmVel11) < kEpsilon &&
speed < 5.0f;
if (atRest) beginRetire();
}
+12 -14
View File
@@ -25,6 +25,7 @@ class BlobRect2 : public BlobShape2 {
Q_PROPERTY(
bool atRestOverride READ atRestOverride WRITE setAtRestOverride NOTIFY
atRestOverrideChanged)
Q_PROPERTY(bool moving READ isMoving NOTIFY movingChanged)
Q_PROPERTY(
qreal topLeftRadius READ topLeftRadius WRITE setTopLeftRadius NOTIFY
topLeftRadiusChanged)
@@ -78,18 +79,13 @@ class BlobRect2 : public BlobShape2 {
if (m_atRestOverride == o) return;
m_atRestOverride = o;
emit atRestOverrideChanged();
updateMoving();
if (m_group) m_group->markDirty();
}
bool isExcluded(const BlobShape2* other) const override;
bool isCornerExcluded(const BlobShape2* other) const override;
// The shape that is pushing is the one that can shape the bar edge. A shape that is
// retiring still counts, because the retire curve is still moving the surface.
// atRestOverride keeps the shape in the group's data even at rest; when it is false
// the actual spring state decides, so it is not an override of the state itself.
bool isMoving() const override {
return m_atRestOverride || m_physicsActive || m_retiring;
}
bool isMoving() const override;
void cornerRadii(float out[4]) const override;
[[nodiscard]] qreal topLeftRadius() const { return m_topLeftRadius; }
@@ -117,6 +113,7 @@ class BlobRect2 : public BlobShape2 {
void excludeChanged();
void excludeCornersChanged();
void atRestOverrideChanged();
void movingChanged();
void topLeftRadiusChanged();
void topRightRadiusChanged();
void bottomLeftRadiusChanged();
@@ -124,20 +121,24 @@ class BlobRect2 : public BlobShape2 {
protected:
void updatePhysics() override;
bool needsFrame() const override;
void resetDeformMatrix() override;
private:
void checkAtRest(float speed);
void trySettle(float speed);
void beginRetire();
void finishAtIdentity();
void requestPhysics();
void updateMoving();
// Physics state
QPointF m_prevScenePos;
QElapsedTimer m_elapsed;
bool m_physicsActive = false;
bool m_hasPrevPos = false;
// Set from QML. Keeps the shape in the group's data while it is at rest; when it is
// false the spring state decides on its own.
int m_stillSamples = 0;
bool m_movingEmitted = false;
bool m_atRestOverride = false;
bool m_retiring = false;
@@ -146,13 +147,10 @@ class BlobRect2 : public BlobShape2 {
float m_retire01 = 0.0f;
float m_retire11 = 1.0f;
// Symmetric 2x2 deformation matrix components (3 independent: m00, m01,
// m11) Rest state is identity: m00=1, m01=0, m11=1
float m_dm00 = 1.0f;
float m_dm01 = 0.0f;
float m_dm11 = 1.0f;
// Spring velocities for each component
float m_dmVel00 = 0.0f;
float m_dmVel01 = 0.0f;
float m_dmVel11 = 0.0f;
+40 -15
View File
@@ -8,6 +8,10 @@
#include <algorithm>
#include <cmath>
namespace {
constexpr int kMaxRects = 16;
} // namespace
static float deformPadding(const QMatrix4x4& dm, float hw, float hh) {
const float dm00 = dm(0, 0), dm01 = dm(0, 1);
const float dm10 = dm(1, 0), dm11 = dm(1, 1);
@@ -45,6 +49,7 @@ BlobShape::BlobShape(QQuickItem* parent) : QQuickItem(parent) {
void BlobShape::setGroup(BlobGroup* g) {
if (m_group == g) return;
if (m_group && isComponentComplete()) unregisterFromGroup();
if (m_group) resetDeformMatrix();
m_group = g;
if (m_group && isComponentComplete()) registerWithGroup();
emit groupChanged();
@@ -87,6 +92,8 @@ void BlobShape::geometryChange(
std::abs(m_pendingDh) > syncThreshold) {
m_pendingDx = 0;
m_pendingDy = 0;
m_pendingDw = 0;
m_pendingDh = 0;
m_group->markShapeDirty(this);
}
}
@@ -105,6 +112,12 @@ void BlobShape::updateCenteredDeformMatrix() {
}
}
void BlobShape::resetDeformMatrix() {
m_deformMatrix = QMatrix4x4();
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
}
void BlobShape::cornerRadii(float out[4]) const {
const auto maxR = static_cast<float>(std::min(width(), height())) * 0.5f;
const auto r = std::min(static_cast<float>(m_radius), maxR);
@@ -187,6 +200,12 @@ void BlobShape::updatePolish() {
}
if (include) {
if (other == this && m_cachedRects.size() >= kMaxRects) {
m_cachedRects.removeLast();
rectShapes.removeLast();
}
if (m_cachedRects.size() >= kMaxRects) continue;
if (other == this)
m_cachedMyIndex = static_cast<int>(m_cachedRects.size());
@@ -240,8 +259,7 @@ void BlobShape::updatePolish() {
m_cachedInvertedRadius = 0;
memset(m_cachedInvertedOuter, 0, sizeof(m_cachedInvertedOuter));
memset(m_cachedInvertedInner, 0, sizeof(m_cachedInvertedInner));
memset(
m_cachedInvertedOuterRadii, 0, sizeof(m_cachedInvertedOuterRadii));
memset(m_cachedInvertedOuterRadii, 0, sizeof(m_cachedInvertedOuterRadii));
auto* inv = m_group->invertedRect();
if (inv) {
@@ -308,10 +326,11 @@ void BlobShape::updatePolish() {
BlobShape* const si = rectShapes[i];
float fTr = 1.0f, fBr = 1.0f, fBl = 1.0f, fTl = 1.0f;
const float cTrX = ri.cx + ri.hw, cTrY = ri.cy - ri.hh;
const float cBrX = ri.cx + ri.hw, cBrY = ri.cy + ri.hh;
const float cBlX = ri.cx - ri.hw, cBlY = ri.cy + ri.hh;
const float cTlX = ri.cx - ri.hw, cTlY = ri.cy - ri.hh;
const float dcx = ri.cx + ri.offsetX, dcy = ri.cy + ri.offsetY;
const float cTrX = dcx + ri.hw, cTrY = dcy - ri.hh;
const float cBrX = dcx + ri.hw, cBrY = dcy + ri.hh;
const float cBlX = dcx - ri.hw, cBlY = dcy + ri.hh;
const float cTlX = dcx - ri.hw, cTlY = dcy - ri.hh;
for (qsizetype j = 0; cornerFill && j < rectCount; ++j) {
if (j == i) continue;
@@ -319,10 +338,12 @@ void BlobShape::updatePolish() {
BlobShape* const sj = rectShapes[j];
if (si->isCornerExcluded(sj) || sj->isCornerExcluded(si)) continue;
const auto& rj = m_cachedRects[j];
const float sdTr = cpuSdBox(cTrX, cTrY, rj.cx, rj.cy, rj.hw, rj.hh);
const float sdBr = cpuSdBox(cBrX, cBrY, rj.cx, rj.cy, rj.hw, rj.hh);
const float sdBl = cpuSdBox(cBlX, cBlY, rj.cx, rj.cy, rj.hw, rj.hh);
const float sdTl = cpuSdBox(cTlX, cTlY, rj.cx, rj.cy, rj.hw, rj.hh);
const float jcx = rj.cx + rj.offsetX;
const float jcy = rj.cy + rj.offsetY;
const float sdTr = cpuSdBox(cTrX, cTrY, jcx, jcy, rj.hw, rj.hh);
const float sdBr = cpuSdBox(cBrX, cBrY, jcx, jcy, rj.hw, rj.hh);
const float sdBl = cpuSdBox(cBlX, cBlY, jcx, jcy, rj.hw, rj.hh);
const float sdTl = cpuSdBox(cTlX, cTlY, jcx, jcy, rj.hw, rj.hh);
fTr = std::min(fTr, cornerFillFactor(sdTr, smoothFactor));
fBr = std::min(fBr, cornerFillFactor(sdBr, smoothFactor));
fBl = std::min(fBl, cornerFillFactor(sdBl, smoothFactor));
@@ -360,10 +381,14 @@ void BlobShape::updatePolish() {
-cpuSdBox(cTlX, cTlY, icx, icy, ihw, ihh)));
}
ri.radius[0] = std::max(ri.radius[0] * fTr, minR);
ri.radius[1] = std::max(ri.radius[1] * fBr, minR);
ri.radius[2] = std::max(ri.radius[2] * fBl, minR);
ri.radius[3] = std::max(ri.radius[3] * fTl, minR);
ri.radius[0] = ri.radius[0] > 0.0f ? std::max(ri.radius[0] * fTr, minR)
: 0.0f;
ri.radius[1] = ri.radius[1] > 0.0f ? std::max(ri.radius[1] * fBr, minR)
: 0.0f;
ri.radius[2] = ri.radius[2] > 0.0f ? std::max(ri.radius[2] * fBl, minR)
: 0.0f;
ri.radius[3] = ri.radius[3] > 0.0f ? std::max(ri.radius[3] * fTl, minR)
: 0.0f;
}
}
@@ -428,7 +453,7 @@ QSGNode* BlobShape::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) {
sizeof(m_cachedInvertedOuterRadii));
const int count =
static_cast<int>(qMin(m_cachedRects.size(), qsizetype(16)));
static_cast<int>(qMin(m_cachedRects.size(), qsizetype(kMaxRects)));
material->m_rectCount = count;
for (int i = 0; i < count; ++i)
material->m_rects[i] = m_cachedRects[i];
+4 -4
View File
@@ -61,9 +61,6 @@ class BlobShape : public QQuickItem {
virtual void cornerRadii(float out[4]) const;
// Outer-edge corner radii. Only the inverted rect draws an outer frame edge,
// so the base has no outer rounding and returns zeros. Subclasses that expose
// outer corner properties override this (order: tr, br, bl, tl).
virtual void outerCornerRadii(float out[4]) const {
out[0] = 0;
out[1] = 0;
@@ -73,16 +70,19 @@ class BlobShape : public QQuickItem {
virtual void updatePhysics() {}
virtual bool needsFrame() const { return false; }
virtual void registerWithGroup();
virtual void unregisterFromGroup();
void updateCenteredDeformMatrix();
virtual void resetDeformMatrix();
BlobGroup* m_group = nullptr;
qreal m_radius = 0;
QMatrix4x4 m_deformMatrix; // identity by default
QMatrix4x4 m_centeredDeformMatrix;
// Cached data from updatePolish
float m_cachedPaddedX = 0;
float m_cachedPaddedY = 0;
float m_cachedPaddedW = 0;
+7 -1
View File
@@ -9,6 +9,7 @@ BlobShape2::BlobShape2(QQuickItem* parent) : QQuickItem(parent) {}
void BlobShape2::setGroup(BlobGroup2* g) {
if (m_group == g) return;
if (m_group && isComponentComplete()) unregisterFromGroup();
if (m_group) resetDeformMatrix();
m_group = g;
if (m_group && isComponentComplete()) registerWithGroup();
emit groupChanged();
@@ -32,7 +33,6 @@ void BlobShape2::geometryChange(
QQuickItem::geometryChange(newGeometry, oldGeometry);
updateCenteredDeformMatrix();
// One draw covers the whole group, so any shape change is a group change.
if (m_group) m_group->markDirty();
}
@@ -49,6 +49,12 @@ void BlobShape2::updateCenteredDeformMatrix() {
}
}
void BlobShape2::resetDeformMatrix() {
m_deformMatrix = QMatrix4x4();
emit rawDeformMatrixChanged();
updateCenteredDeformMatrix();
}
void BlobShape2::cornerRadii(float out[4]) const {
const auto maxR = static_cast<float>(std::min(width(), height())) * 0.5f;
const auto r = std::min(static_cast<float>(m_radius), maxR);
+11 -9
View File
@@ -7,12 +7,6 @@
class BlobGroup2;
// Data-only shape.
//
// The group draws a single node for the whole group, so a shape here contributes
// only what the shader needs: position, radii, exclusion, physics. A shape does
// not create a render node, so no pixel can be evaluated by one shape and missed
// by another.
class BlobShape2 : public QQuickItem {
Q_OBJECT
Q_PROPERTY(BlobGroup2* group READ group WRITE setGroup NOTIFY groupChanged)
@@ -56,8 +50,6 @@ class BlobShape2 : public QQuickItem {
virtual bool isExcluded(const BlobShape2*) const { return false; }
// Whether this shape is part of the group's data. Only a pushing shape is, so a shape
// at rest has no part in the draw. BlobRect2 also honours the QML atRestOverride.
virtual bool isMoving() const { return false; }
virtual bool isCornerExcluded(const BlobShape2* /*other*/) const {
@@ -66,13 +58,23 @@ class BlobShape2 : public QQuickItem {
virtual void cornerRadii(float out[4]) const;
// Called by the group when it rebuilds the shared data, once per frame.
virtual void outerCornerRadii(float out[4]) const {
out[0] = 0;
out[1] = 0;
out[2] = 0;
out[3] = 0;
}
virtual void updatePhysics() {}
virtual bool needsFrame() const { return false; }
virtual void registerWithGroup();
virtual void unregisterFromGroup();
void updateCenteredDeformMatrix();
virtual void resetDeformMatrix();
BlobGroup2* m_group = nullptr;
qreal m_radius = 0;
QMatrix4x4 m_deformMatrix; // identity by default
+67 -105
View File
@@ -30,7 +30,6 @@ float sdRoundedBox(vec2 p, vec2 center, vec2 halfSize, float radius) {
}
float sdRoundedBox4(vec2 p, vec2 center, vec2 halfSize, vec4 r) {
// r = (topRight, bottomRight, bottomLeft, topLeft)
p -= center;
r.xy = (p.x > 0.0) ? r.xy : r.wz;
r.x = (p.y > 0.0) ? r.y : r.x;
@@ -44,33 +43,32 @@ float sdBox(vec2 p, vec2 center, vec2 halfSize) {
}
float smin(float a, float b, float k) {
// Cubic smooth min (C2 continuous — no curvature kinks at blend boundary)
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * h * k * (1.0 / 6.0);
// Circular smooth min — the blend fillet is a true circular arc of radius k,
// tangent to both surfaces (not a polynomial/squircle curve). Deviates from
// min(a, b) only in the corner region where BOTH a < k and b < k (unlike the
// cubic, which deviates over the whole band |a - b| < k and dents the surface
// inward while the shapes are still apart). Always <= min(a, b); max blend
// depth at a == b is (sqrt(2) - 1) * k.
return max(k, min(a, b)) - length(max(vec2(k) - vec2(a, b), vec2(0.0)));
}
float smax(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return max(a, b) + h * h * h * k * (1.0 / 6.0);
// Circular smooth max — dual of smin: -smin(-a, -b, k). Always >= max(a, b).
return min(-k, max(a, b)) + length(max(vec2(a, b) + vec2(k), vec2(0.0)));
}
float smaxSharpA(float a, float b, float k) {
// smax variant that keeps a's boundary sharp (no inward rounding at a = 0).
// Used for the frame inner side, so the outer edge keeps its own shape and
// always fills to the edges.
float h = max(k - abs(a - b), 0.0) / k;
float blend = h * h * h * k * (1.0 / 6.0);
blend *= smoothstep(0.0, k * 0.5, -a);
// Circular smax variant that keeps a's boundary sharp (no inward rounding at
// a = 0). Used for the frame outer edge so it always fills to the edges.
float sm =
min(-k, max(a, b)) + length(max(vec2(a, b) + vec2(k), vec2(0.0)));
float blend = (sm - max(a, b)) * smoothstep(0.0, k * 0.5, -a);
return max(a, b) + blend;
}
void main() {
vec2 pixel = vec2(paddedX, paddedY) + qt_TexCoord0 * vec2(paddedW, paddedH);
// Phase 1: compute per-rect SDF, and track which rect owns the pixel for the
// per-shape path (BlobGroup). The single group draw (BlobGroup2) has no owner:
// myIndex stays -2, so the ownership test is skipped and this pass writes every
// pixel the quad covers. Excluded pairs still need the pairwise pass below.
float dArr[16];
int owner = -2;
float minDist = 1e10;
@@ -84,12 +82,8 @@ void main() {
vec4 radii =
rectData[i * 5 + 4]; // effective per-corner radii (tr, br, bl, tl)
// Offset center for asymmetric deformation
vec2 center = rect.xy + props.yz;
// AABB early-out: skip rects far from this pixel. The reach has to include the
// shadow, otherwise a rect that is only inside the shadow range would be
// skipped and its distance could not be used.
vec2 extent = sh.xy + vec2(smoothFactor * 1.5 + shadowRadius);
if (abs(pixel.x - center.x) > extent.x ||
abs(pixel.y - center.y) > extent.y) {
@@ -97,19 +91,13 @@ void main() {
continue;
}
// Apply pre-computed inverse deformation to the evaluation point
mat2 invDeform = mat2(invDm.xy, invDm.zw);
vec2 transformedPixel = center + invDeform * (pixel - center);
// Use pre-computed effective per-corner radii
float d = sdRoundedBox4(transformedPixel, center, rect.zw, radii);
// Use pre-computed minimum eigenvalue for SDF correction
d *= max(props.w, 0.01);
// Scale SDF on the axis facing a nearby border to narrow the smin blend
// zone in that direction only, without reducing k (which would cause sharp
// edges).
if (hasInverted != 0) {
vec2 screenHalf = sh.xy;
@@ -122,26 +110,21 @@ void main() {
float distX1 =
(invertedInner.x + invertedInner.z) - (center.x - screenHalf.x);
// 0 = far from border, 1 = at border (max compression)
float yProx = 1.0 - min(smoothstep(0.0, smoothFactor, distY0),
smoothstep(0.0, smoothFactor, distY1));
float xProx = 1.0 - min(smoothstep(0.0, smoothFactor, distX0),
smoothstep(0.0, smoothFactor, distX1));
// Smooth axis weights: gradient-based at corners, face-based inside.
vec2 q = abs(pixel - center) - screenHalf;
vec2 qp = max(q, vec2(0.0));
float cornerLen = length(qp);
// Gradient direction in corner region (smooth 90-degree rotation)
float gradX = qp.x / max(cornerLen, 0.001);
float gradY = qp.y / max(cornerLen, 0.001);
// Smooth face weights for inside/edge (no hard branch)
float faceY = smoothstep(-4.0, 4.0, q.y - q.x);
float faceX = 1.0 - faceY;
// Blend: gradient in corner region, face-based inside
float t = smoothstep(0.0, 2.0, cornerLen);
float xWeight = mix(faceX, gradX, t);
float yWeight = mix(faceY, gradY, t);
@@ -158,15 +141,11 @@ void main() {
}
}
// Phase 2: hard-min baseline over all rects.
float mergedSdf = 1e10;
for (int i = 0; i < rectCount; i++) {
mergedSdf = min(mergedSdf, dArr[i]);
}
// Phase 3: pair-wise smin contributions, skipping excluded pairs. Pair smin
// <= min, so taking the min over all non-excluded pair smins gives the
// smoothly-merged SDF.
for (int i = 0; i < rectCount; i++) {
if (dArr[i] >= 1e9)
continue;
@@ -176,24 +155,20 @@ void main() {
continue;
if ((excludeMask & (1 << j)) != 0)
continue;
// smin only deviates from min within smoothFactor
if (abs(dArr[i] - dArr[j]) >= smoothFactor)
// Circular smin deviates from min only where BOTH d values are < k.
if (max(dArr[i], dArr[j]) >= smoothFactor)
continue;
mergedSdf = min(mergedSdf, smin(dArr[i], dArr[j], smoothFactor));
}
}
if (hasInverted != 0) {
// Outer edge rounds with its own per-corner radii. With all-zero radii
// sdRoundedBox4 equals sdBox, so a frame that sets no outer radius is
// unchanged. smaxSharpA keeps this boundary authoritative for the fill.
float dOuter =
sdRoundedBox4(pixel, invertedOuter.xy, invertedOuter.zw,
invertedOuterRadii) - 1.0;
float dInner =
sdRoundedBox(pixel, invertedInner.xy, invertedInner.zw, invertedRadius);
// Border sinks: track the opposite rect edge, clamped to border thickness
float innerTop = invertedInner.y - invertedInner.w;
float innerBot = invertedInner.y + invertedInner.w;
float innerLeft = invertedInner.x - invertedInner.z;
@@ -203,65 +178,67 @@ void main() {
float outerLeft = invertedOuter.x - invertedOuter.z;
float outerRight = invertedOuter.x + invertedOuter.z;
float topZone =
1.0 - smoothstep(innerTop, innerTop + smoothFactor, pixel.y);
float botZone = smoothstep(innerBot - smoothFactor, innerBot, pixel.y);
float leftZone =
1.0 - smoothstep(innerLeft, innerLeft + smoothFactor, pixel.x);
float rightZone =
smoothstep(innerRight - smoothFactor, innerRight, pixel.x);
float sinkValue = 0.0;
for (int i = 0; i < rectCount; i++) {
vec4 rect = rectData[i * 5];
vec4 sinkProps = rectData[i * 5 + 1];
vec2 sinkSh = rectData[i * 5 + 3].xy;
// Sink onset / residual overlap: how far a rect must penetrate the border
// before the inner wall recedes to form its pocket. Too low and the wall
// recedes faster than the junction can stay convex, denting the inner edge
// inward near the rect's corners; too high and the rect nestles too deep
// before the wall yields. Tuned to the circular smin blend depth: half the
// gap-closing distance, (2 - sqrt(2)) * k / 2.
float preOff = smoothFactor * (2.0 - sqrt(2.0)) * 0.5;
float s = smoothFactor * 2.0;
// Screen-space center (with offset) and pre-computed AABB half-extents
vec2 ctr = rect.xy + sinkProps.yz;
if (topZone > 0.0 || botZone > 0.0 || leftZone > 0.0 || rightZone > 0.0) {
for (int i = 0; i < rectCount; i++) {
vec4 rect = rectData[i * 5];
vec4 sinkProps = rectData[i * 5 + 1];
vec2 sinkSh = rectData[i * 5 + 3].xy;
// Delay sink to absorb smin blend depth (cubic smin max = k/6)
float preOff = smoothFactor * (1.0 / 6.0);
vec2 ctr = rect.xy + sinkProps.yz;
// Top border: track rect's BOTTOM edge, only within border thickness
float topPen = clamp(innerTop - (ctr.y + sinkSh.y) - preOff, 0.0,
innerTop - outerTop);
float topPen = clamp(innerTop - (ctr.y + sinkSh.y) - preOff, 0.0,
innerTop - outerTop);
// Bottom border: track rect's TOP edge
float botPen = clamp((ctr.y - sinkSh.y) - innerBot - preOff, 0.0,
outerBot - innerBot);
float botPen = clamp((ctr.y - sinkSh.y) - innerBot - preOff, 0.0,
outerBot - innerBot);
// Left border: track rect's RIGHT edge
float leftPen = clamp(innerLeft - (ctr.x + sinkSh.x) - preOff, 0.0,
innerLeft - outerLeft);
float leftPen = clamp(innerLeft - (ctr.x + sinkSh.x) - preOff, 0.0,
innerLeft - outerLeft);
// Right border: track rect's LEFT edge
float rightPen = clamp((ctr.x - sinkSh.x) - innerRight - preOff, 0.0,
outerRight - innerRight);
float rightPen = clamp((ctr.x - sinkSh.x) - innerRight - preOff, 0.0,
outerRight - innerRight);
// Lateral distance from pixel to rect's extent along each edge
float hLat = max(abs(pixel.x - ctr.x) - sinkSh.x, 0.0);
float vLat = max(abs(pixel.y - ctr.y) - sinkSh.y, 0.0);
float hLat = max(abs(pixel.x - ctr.x) - sinkSh.x, 0.0);
float vLat = max(abs(pixel.y - ctr.y) - sinkSh.y, 0.0);
// Perpendicular proximity: full strength in border, fade inside inner
// area
float topZone =
1.0 - smoothstep(innerTop, innerTop + smoothFactor, pixel.y);
float botZone = smoothstep(innerBot - smoothFactor, innerBot, pixel.y);
float leftZone =
1.0 - smoothstep(innerLeft, innerLeft + smoothFactor, pixel.x);
float rightZone =
smoothstep(innerRight - smoothFactor, innerRight, pixel.x);
float s = smoothFactor * 2.0;
float sink = max(max(topPen * smoothstep(s, 0.0, hLat) * topZone,
botPen * smoothstep(s, 0.0, hLat) * botZone),
max(leftPen * smoothstep(s, 0.0, vLat) * leftZone,
rightPen * smoothstep(s, 0.0, vLat) * rightZone));
sinkValue = max(sinkValue, sink);
float sink = max(max(topPen * smoothstep(s, 0.0, hLat) * topZone,
botPen * smoothstep(s, 0.0, hLat) * botZone),
max(leftPen * smoothstep(s, 0.0, vLat) * leftZone,
rightPen * smoothstep(s, 0.0, vLat) * rightZone));
sinkValue = max(sinkValue, sink);
}
}
dInner -= sinkValue;
// dOuter is already a true circle (sdRoundedBox4). Keep it exact whenever the
// outer arc is the active boundary; a smax term added there would pull the
// arc's sides inward (curvature/tangent mismatch), so the corner would not be
// a circle. Only blend on the inner side, where the inner boundary is active.
float negInner = -dInner;
float dFrame = dOuter >= negInner ? dOuter
: smaxSharpA(dOuter, negInner, smoothFactor);
// The circular smax fillet has radius kFrame; when it exceeds the border
// thickness it can't complete inside the border, so the sharp outer-box term
// bleeds onto the inner edge and bulges the inner corners (worst when
// thickness < smoothFactor). Clamp kFrame to the thinnest side so the inner
// edge stays a clean constant-radius arc. Each inner corner is bounded by
// its thinner adjacent side, so the global min is correct for every corner.
float minThick = min(min(innerTop - outerTop, outerBot - innerBot),
min(innerLeft - outerLeft, outerRight - innerRight));
float kFrame = clamp(min(smoothFactor, minThick - 1.0), 1.0, smoothFactor);
float dFrame = smaxSharpA(dOuter, -dInner, kFrame);
mergedSdf = smin(mergedSdf, dFrame, smoothFactor);
if (dFrame < minDist) {
@@ -269,37 +246,22 @@ void main() {
}
}
// A pixel this far from every rect cannot have a value, so it is not written.
// The one-unit margin keeps the outermost pixels of the quad out of the AA: their
// derivatives are undefined (the quad neighbour is outside the primitive), which
// is what drew a line along the coverage edge.
float reach = max(smoothFactor, shadowRadius);
if (mergedSdf > reach - 1.0)
float pxWidth = max(length(vec2(dFdx(mergedSdf), dFdy(mergedSdf))), 1e-5);
if (mergedSdf > reach - max(2.0 * pxWidth, 1.0))
discard;
// Only the owner writes a pixel, which is what makes the per-shape path look
// like one surface: qt_Opacity is applied once per pixel instead of once per
// overlapping draw. The single group draw has no owner (myIndex == -2), so the
// test is skipped and the value below is the only value a pixel can have.
if (myIndex >= -1 && owner != myIndex)
discard;
// Fixed-width AA: fwidth is capped at one unit, so a bad derivative near the quad
// boundary cannot make a wide edge.
float fw = min(fwidth(mergedSdf), 1.0);
float materialAlpha = 1.0 - smoothstep(-fw, fw, mergedSdf);
float materialAlpha = clamp(0.5 - mergedSdf / pxWidth, 0.0, 1.0);
// Phase 2: the shadow, as a distance falloff from the merged surface. The
// quadratic reaches zero with zero slope at its outer edge, so that edge needs no
// AA of its own. Only the single group draw writes a shadow: in the per-shape
// path overlapping draws would darken the same area twice.
float shadowAlpha = 0.0;
if (shadowRadius > 0.0 && myIndex == -2) {
float t = clamp(mergedSdf / shadowRadius, 0.0, 1.0);
shadowAlpha = (1.0 - t) * (1.0 - t) * shadowColor.a;
}
// Material over shadow, premultiplied.
float totalAlpha = materialAlpha + shadowAlpha * (1.0 - materialAlpha);
vec3 rgb = color.rgb * materialAlpha
+ shadowColor.rgb * shadowAlpha * (1.0 - materialAlpha);
Binary file not shown.
+1
View File
@@ -14,6 +14,7 @@ qml_module(ZShell-components
roundedmask.hpp roundedmask.cpp
roundedclip.hpp roundedclip.cpp
snaplayout.hpp snaplayout.cpp
snapview.hpp snapview.cpp
wheelinverter.hpp
LIBRARIES
Qt::Quick
+445 -58
View File
@@ -4,6 +4,7 @@
#include <QQuickWindow>
#include <QScopeGuard>
#include <QtGui/private/qfont_p.h>
#include <algorithm>
#include <cmath>
Q_LOGGING_CATEGORY(lcQuickLayouts, "qt.quick.layouts")
@@ -16,6 +17,20 @@ qint64 originResidual(qreal logical, qreal dpr) {
return std::llround((device - std::floor(device)) * 1024.0) % 1024;
}
// Snap one size hint to the device grid. `mode` is >0 to round up, <0 to round
// down, 0 to round to nearest. Infinities and NaN are left alone: they mean
// "unbounded".
qreal snapSizeHint(qreal value, qreal dpr, int mode) {
if (qIsNaN(value) || std::isinf(value)) return value;
const double exact = value * dpr;
const double snapped = mode > 0 ? std::ceil(exact - 1e-9)
: mode < 0
? std::floor(exact + 1e-9)
: static_cast<double>(std::llround(exact));
return snapped / dpr;
}
QPointF absoluteOrigin(const QQuickItem* item) {
qreal x = 0.0;
qreal y = 0.0;
@@ -54,49 +69,13 @@ SnapGridLayoutItem::SnapGridLayoutItem(
int column,
int rowSpan,
int columnSpan,
Qt::Alignment alignment,
SnapLayout* layout)
: QQuickGridLayoutItem(item, row, column, rowSpan, columnSpan, alignment)
, m_layout(layout) {}
Qt::Alignment alignment)
: QQuickGridLayoutItem(item, row, column, rowSpan, columnSpan, alignment) {}
void SnapGridLayoutItem::setGeometry(const QRectF& rect) {
QQuickLayoutAttached* info = attachedLayoutObject(m_item, false);
QRectF placed = info ? rect.marginsRemoved(info->effectiveQMargins())
: rect;
const QQuickWindow* win = m_item->window();
const qreal dpr = win ? win->effectiveDevicePixelRatio() : 1.0;
if (dpr > 0.0) {
const QPointF origin = absoluteOrigin(m_layout);
const qreal left = (origin.x() + placed.x()) * dpr;
const qreal top = (origin.y() + placed.y()) * dpr;
const qreal right = (origin.x() + placed.right()) * dpr;
const qreal bottom = (origin.y() + placed.bottom()) * dpr;
const qint64 nearX = std::llround(left);
const qint64 nearY = std::llround(top);
const qint64 farX = std::llround(right);
const qint64 farY = std::llround(bottom);
placed = QRectF(
qreal(nearX) / dpr - origin.x(),
qreal(nearY) / dpr - origin.y(),
qreal(farX - nearX) / dpr,
qreal(farY - nearY) / dpr);
}
const QSizeF oldSize(m_item->width(), m_item->height());
const QSizeF newSize = placed.size();
m_item->setPosition(placed.topLeft());
if (newSize == oldSize) {
if (QQuickLayout* lay = qobject_cast<QQuickLayout*>(m_item)) {
if (lay->invalidatedArrangement()) lay->rearrange(newSize);
}
} else {
m_item->setSize(newSize);
}
engineRect = info ? rect.marginsRemoved(info->effectiveQMargins()) : rect;
placed = true;
}
void SnapLayoutPrivate::mirrorChange() {
@@ -119,6 +98,7 @@ SnapLayout::~SnapLayout() {
void SnapLayout::componentComplete() {
QQuickLayout::componentComplete();
applySpacing();
invalidate();
ensureLayoutItemsUpdated(QQuickLayout::ApplySizeHints);
@@ -128,13 +108,182 @@ void SnapLayout::componentComplete() {
void SnapLayout::itemChange(ItemChange change, const ItemChangeData& value) {
QQuickLayout::itemChange(change, value);
if (change == ItemDevicePixelRatioHasChanged || change == ItemSceneChange)
if (change == ItemChildRemovedChange && value.item)
d_func()->childSizes.remove(value.item);
if (change == ItemDevicePixelRatioHasChanged || change == ItemSceneChange) {
applySpacing();
invalidate();
}
}
void SnapLayout::geometryChange(
const QRectF& newGeometry, const QRectF& oldGeometry) {
Q_D(SnapLayout);
QQuickLayout::geometryChange(newGeometry, oldGeometry);
if (!isReady()) return;
const QQuickWindow* win = window();
const qreal dpr = win ? win->effectiveDevicePixelRatio() : 0.0;
if (dpr <= 0.0) return;
// Any change of our own geometry can move us: our size changed and an anchor
// on a bottom or right edge moved us with it, or an ancestor resized and the
// same anchors moved us. Children were placed relative to the origin the
// arrangement saw: their local positions carry the compensation for that
// origin's fraction of a device pixel, so after a move they land on a
// fraction - which is why a centered child is blurred by a different amount
// every time the panel opens. Re-arrange whenever the origin's residual
// changes; it is remembered on every rearrange, so this fires only when a
// re-arrange is really needed, and it converges as soon as the animation
// stops moving the layout.
const QPointF origin = absoluteOrigin(this);
if (originResidual(origin.x(), dpr) == d->originResidualX &&
originResidual(origin.y(), dpr) == d->originResidualY)
return;
rearrangeForOriginMove();
}
// Children are placed against the origin the arrangement saw, so a move that
// changes the origin's fraction of a device pixel needs a new arrangement.
// polish() defers that to the next frame, and a polish request does nothing
// while one is already pending - if no frame follows, or the pending polish is
// never delivered, the children stay placed against the old origin forever.
// Rearrange immediately instead; polish() is only the fallback for the cases
// where rearranging now is not possible.
void SnapLayout::rearrangeForOriginMove() {
Q_D(SnapLayout);
const QSizeF size(width(), height());
if (d->rearranging || size.width() <= 0.0 || size.height() <= 0.0) {
polish();
return;
}
rearrange(size);
}
QSizeF SnapLayout::sizeHint(Qt::SizeHint whichSizeHint) const {
Q_D(const SnapLayout);
return d->engine.sizeHint(whichSizeHint, QSizeF(), d->styleInfo);
const QSizeF engineHint =
d->engine.sizeHint(whichSizeHint, QSizeF(), d->styleInfo);
const QQuickWindow* win = window();
const qreal dpr = win ? win->effectiveDevicePixelRatio() : 1.0;
if (dpr <= 0.0) return engineHint;
const bool horiz = d->orientation == Qt::Horizontal;
// Qt builds implicit sizes out of whole logical pixels:
// QQuickLayout::effectiveSizeHints_helper takes `qCeil(item->implicitHeight())`
// for every child, so the sum can be an odd number of logical pixels, which is
// a half device pixel at a fractional ratio - 37 logical pixels at 1.5x is 55.5
// device pixels. A layout that sizes itself from its implicit size then has an
// edge on a fraction of a device pixel, and anchored to a bottom edge its top
// edge - and every child measured down from that top - lands off the grid.
//
// The preferred size is therefore summed here in device pixels: each child's
// own hint rounded up to the device grid, plus its Layout margins and the
// device-normalized spacing. That is exactly what the arrangement writes, so
// the layout is neither larger than its children (dead space, and a Flickable
// that reports more content height than the content occupies) nor smaller
// (the last child cut off).
qint64 mainExtentDev = 0;
qint64 crossDev = 0;
qint64 maxMainDev = 0;
int count = 0;
for (int i = 0; i < d->engine.itemCount(); ++i) {
const SnapGridLayoutItem* gridItem =
static_cast<const SnapGridLayoutItem*>(d->engine.itemAt(i));
QQuickItem* child = gridItem->layoutItem();
if (!child || shouldIgnoreItem(child)) continue;
QQuickLayoutAttached* info = attachedLayoutObject(child, false);
const QMarginsF margins = info ? info->effectiveQMargins()
: QMarginsF(0, 0, 0, 0);
// The engine's per-child hints are authoritative: they follow
// Layout.preferredWidth/Height when those are set, otherwise the child's
// implicit size, otherwise the first width and height it had, and they
// include the child's Layout margins. They are also cached, so a nested
// layout contributes its own preferred size even before it is polished.
const QSizeF* hints = gridItem->effectiveSizeHints();
const qreal mainMargin = horiz ? margins.left() + margins.right()
: margins.top() + margins.bottom();
const qreal crossMargin = horiz ? margins.top() + margins.bottom()
: margins.left() + margins.right();
qreal mainHint = (horiz ? hints[Qt::PreferredSize].width()
: hints[Qt::PreferredSize].height()) -
mainMargin;
qreal crossHint = (horiz ? hints[Qt::PreferredSize].height()
: hints[Qt::PreferredSize].width()) -
crossMargin;
// The engine hint is `qCeil` of the implicit size, so it can sit up to a
// whole logical pixel above it - a device pixel per child at 1.5x, which
// adds up to dead space in a long column. When the child's own implicit
// size is known and the hint is just that ceiling, measure the implicit
// size and let the device rounding be the only rounding.
const qreal ownMain = horiz ? child->implicitWidth()
: child->implicitHeight();
if (ownMain > 0.0 && mainHint <= ownMain + 1.0) mainHint = ownMain;
const qreal ownCross = horiz ? child->implicitHeight()
: child->implicitWidth();
if (ownCross > 0.0 && crossHint <= ownCross + 1.0) crossHint = ownCross;
if (!std::isfinite(mainHint) || mainHint < 0.0) mainHint = 0.0;
if (!std::isfinite(crossHint) || crossHint < 0.0) crossHint = 0.0;
// Rounding up keeps the hint from ever under-sizing its contents.
const qint64 mainDev = qint64(std::ceil(mainHint * dpr - 1e-9));
const qint64 mainMarginDev = qint64(std::ceil(mainMargin * dpr - 1e-9));
const qint64 crossDevForChild =
qint64(std::ceil(crossHint * dpr - 1e-9)) +
qint64(std::ceil(crossMargin * dpr - 1e-9));
mainExtentDev += mainDev + mainMarginDev;
maxMainDev = (std::max)(maxMainDev, mainDev + mainMarginDev);
crossDev = (std::max)(crossDev, crossDevForChild);
++count;
}
qreal space = d->spacingRequest;
if (qIsNaN(space)) space = d->styleInfo->spacing(d->orientation);
const qint64 gapDev = dpr > 0.0 ? qint64(std::llround(space * dpr))
: qint64(space);
if (uniformCellSizes() && count > 0)
mainExtentDev = maxMainDev * qint64(count);
if (count > 1) mainExtentDev += gapDev * qint64(count - 1);
const QSizeF preferred(
horiz ? qreal(mainExtentDev) / dpr : qreal(crossDev) / dpr,
horiz ? qreal(crossDev) / dpr : qreal(mainExtentDev) / dpr);
if (whichSizeHint == Qt::PreferredSize) return preferred;
if (whichSizeHint == Qt::MinimumSize) {
// Minimums round up, but never past the preferred size, or cells would
// grow past what the arrangement writes.
const QSizeF minimum(
snapSizeHint(engineHint.width(), dpr, 1),
snapSizeHint(engineHint.height(), dpr, 1));
return QSizeF(
(std::min)(minimum.width(), preferred.width()),
(std::min)(minimum.height(), preferred.height()));
}
// Maximums round down, but never below the preferred size: a layout is always
// allowed to be as large as its contents need.
const QSizeF maximum(
snapSizeHint(engineHint.width(), dpr, -1),
snapSizeHint(engineHint.height(), dpr, -1));
return QSizeF(
(std::max)(maximum.width(), preferred.width()),
(std::max)(maximum.height(), preferred.height()));
}
Qt::Orientation SnapLayout::orientation() const {
@@ -169,16 +318,33 @@ Qt::LayoutDirection SnapLayout::effectiveLayoutDirection() const {
qreal SnapLayout::spacing() const {
Q_D(const SnapLayout);
return d->engine.spacing(d->orientation, d->styleInfo);
if (!std::isnan(d->spacingRequest)) return d->spacingRequest;
return d->styleInfo->spacing(d->orientation);
}
void SnapLayout::applySpacing() {
Q_D(SnapLayout);
if (!isReady()) return;
const QQuickWindow* win = window();
const qreal dpr = win ? win->effectiveDevicePixelRatio() : 1.0;
qreal space = d->spacingRequest;
if (std::isnan(space)) space = d->styleInfo->spacing(d->orientation);
if (dpr > 0.0) space = qreal(std::llround(space * dpr)) / dpr;
d->engine.setSpacing(space, Qt::Horizontal | Qt::Vertical);
}
void SnapLayout::setSpacing(qreal space) {
Q_D(SnapLayout);
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wfloat-equal"
if (std::isnan(space) || spacing() == space) return;
if (std::isnan(space) || d->spacingRequest == space) return;
#pragma GCC diagnostic pop
d->engine.setSpacing(space, Qt::Horizontal | Qt::Vertical);
d->spacingRequest = space;
applySpacing();
invalidate();
emit spacingChanged();
}
@@ -242,6 +408,14 @@ void SnapLayout::updateLayoutItems() {
Q_D(SnapLayout);
if (!isReady()) return;
const auto items = childItems();
for (auto entry = d->childSizes.begin(); entry != d->childSizes.end();) {
if (items.contains(entry.key()))
++entry;
else
entry = d->childSizes.erase(entry);
}
d->engine.deleteItems();
insertLayoutItems();
}
@@ -286,8 +460,8 @@ void SnapLayout::insertLayoutItems() {
int gridRow = 0;
int gridColumn = index;
if (d->orientation == Qt::Vertical) qSwap(gridRow, gridColumn);
QGridLayoutItem* layoutItem = new SnapGridLayoutItem(
child, gridRow, gridColumn, 1, 1, alignment, this);
QGridLayoutItem* layoutItem =
new SnapGridLayoutItem(child, gridRow, gridColumn, 1, 1, alignment);
if (hStretch >= 0) {
if (!fillWidth)
@@ -315,6 +489,7 @@ void SnapLayout::removeGridItem(QGridLayoutItem* gridItem) {
void SnapLayout::itemDestroyed(QQuickItem* item) {
if (!isReady()) return;
Q_D(SnapLayout);
d->childSizes.remove(item);
if (QQuickGridLayoutItem* gridItem = d->engine.findLayoutItem(item)) {
removeGridItem(gridItem);
delete gridItem;
@@ -329,24 +504,33 @@ void SnapLayout::itemVisibilityChanged(QQuickItem* item) {
void SnapLayout::itemGeometryChanged(
QQuickItem* item, QQuickGeometryChange change, const QRectF& oldGeometry) {
Q_UNUSED(item);
Q_UNUSED(oldGeometry);
if (!isReady() || !change.positionChange()) return;
Q_UNUSED(item)
Q_UNUSED(change)
Q_UNUSED(oldGeometry)
if (!isReady()) return;
Q_D(SnapLayout);
const QQuickWindow* win = window();
const qreal dpr = win ? win->effectiveDevicePixelRatio() : 0.0;
if (dpr <= 0.0) return;
// An ancestor changing size can move us too - a layout anchored to a bottom
// edge slides when its parent grows - so only the origin's fraction of a
// device pixel matters, not which kind of change reached us.
Q_D(SnapLayout);
const QPointF origin = absoluteOrigin(this);
if (originResidual(origin.x(), dpr) == d->originResidualX &&
originResidual(origin.y(), dpr) == d->originResidualY)
return;
polish();
rearrangeForOriginMove();
}
void SnapLayout::updateAncestorListeners() {
Q_D(SnapLayout);
const QFlags<QQuickItemPrivate::ChangeType> mask =
QQuickItemPrivate::Geometry;
QVector<QQuickItem*> current;
for (QQuickItem* it = parentItem(); it; it = it->parentItem()) {
if (qobject_cast<SnapLayout*>(it)) break;
@@ -355,15 +539,34 @@ void SnapLayout::updateAncestorListeners() {
for (const QPointer<QQuickItem>& ancestor :
std::as_const(d->ancestorListeners)) {
if (ancestor && !current.contains(ancestor.data()))
if (ancestor && !current.contains(ancestor.data())) {
QQuickItemPrivate::get(ancestor.data())
->removeItemChangeListener(this, QQuickItemPrivate::Geometry);
->removeItemChangeListener(
this, d->ancestorMasks.value(ancestor.data()));
d->ancestorMasks.remove(ancestor.data());
}
}
// Qt appends one registration per addItemChangeListener call, so only the
// bits that are not registered yet may be added; re-adding the same mask on
// every rearrange would duplicate listeners without bound.
for (QQuickItem* ancestor : std::as_const(current)) {
if (!d->ancestorListeners.contains(ancestor))
const auto have = d->ancestorMasks.value(ancestor);
const auto missing = mask & ~have;
const auto extra = have & ~mask;
if (extra != 0) {
QQuickItemPrivate::get(ancestor)->removeItemChangeListener(
this, extra);
}
if (missing != 0) {
QQuickItemPrivate::get(ancestor)->addItemChangeListener(
this, QQuickItemPrivate::Geometry);
this, missing);
}
d->ancestorMasks[ancestor] = mask;
}
d->ancestorListeners.clear();
d->ancestorListeners.reserve(current.size());
for (QQuickItem* ancestor : std::as_const(current))
@@ -374,13 +577,193 @@ void SnapLayout::clearAncestorListeners() {
Q_D(SnapLayout);
for (const QPointer<QQuickItem>& ancestor :
std::as_const(d->ancestorListeners)) {
if (ancestor)
if (ancestor) {
QQuickItemPrivate::get(ancestor.data())
->removeItemChangeListener(this, QQuickItemPrivate::Geometry);
->removeItemChangeListener(
this, d->ancestorMasks.value(ancestor.data()));
}
}
d->ancestorMasks.clear();
d->ancestorListeners.clear();
}
void SnapLayout::arrangeChildren() {
Q_D(SnapLayout);
const QQuickWindow* win = window();
const qreal dpr = win ? win->effectiveDevicePixelRatio() : 1.0;
if (dpr <= 0.0) return;
const bool horiz = d->orientation == Qt::Horizontal;
const QPointF origin = absoluteOrigin(this);
const qint64 originXDev = std::llround(origin.x() * dpr);
const qint64 originYDev = std::llround(origin.y() * dpr);
qreal prevEndExact = 0.0;
qint64 prevNearDev = 0;
qint64 prevSizeDev = 0;
bool started = false;
// The layout's own main extent, rounded down: no child is placed past it, so
// a Flickable that takes this layout's height as its content height can still
// reach the last child.
const QSizeF self = size();
const qint64 layoutMainExtentDev =
qint64(std::floor((horiz ? self.width() : self.height()) * dpr + 1e-6));
for (int i = 0; i < d->engine.itemCount(); ++i) {
SnapGridLayoutItem* gridItem =
static_cast<SnapGridLayoutItem*>(d->engine.itemAt(i));
if (!gridItem->placed) continue;
QQuickItem* child = gridItem->layoutItem();
const QRectF& cell = gridItem->engineRect;
const qreal mainExact = (horiz ? cell.x() : cell.y()) * dpr;
const qreal mainSizeExact =
(horiz ? cell.width() : cell.height()) * dpr;
const qreal crossExact = (horiz ? cell.y() : cell.x()) * dpr;
const qint64 mainSizeDev = std::llround(mainSizeExact);
const qint64 crossSizeDev =
std::llround((horiz ? cell.height() : cell.width()) * dpr);
const qint64 engineWidth = horiz ? mainSizeDev : crossSizeDev;
const qint64 engineHeight = horiz ? crossSizeDev : mainSizeDev;
qint64 mainNear = 0;
if (!started) {
mainNear = std::llround(mainExact);
started = true;
} else {
mainNear = prevNearDev + prevSizeDev +
std::llround(mainExact - prevEndExact);
}
const qint64 mainFarExact = std::llround(mainExact + mainSizeExact);
// An aligned child whose cell is its own implicit size is sized from that
// implicit size. The engine recomputes cells from animated geometry, so a
// cell that is mathematically 41 can arrive as 40.9999999999; when the
// device pixel ratio puts 41 exactly on half a device pixel, that epsilon
// flips the rounding from pass to pass and the child visibly resizes while
// it only moves. Children that fill their axis, or whose cell has grown
// past their implicit size, keep the engine-derived size so that animated
// fills still step through device pixels.
QQuickLayoutAttached* info = attachedLayoutObject(child, false);
const qreal mainOwn = horiz ? child->implicitWidth()
: child->implicitHeight();
const qreal crossOwn = horiz ? child->implicitHeight()
: child->implicitWidth();
const bool fillsMain = info &&
(horiz ? info->fillWidth() : info->fillHeight());
const bool fillsCross =
info && (horiz ? info->fillHeight() : info->fillWidth());
const auto snappedOwn = [&](bool fills, qreal cellSize, qreal ownSize) {
if (fills || ownSize <= 0.0 || qAbs(cellSize - ownSize) >= 1.0)
return qint64(-1);
// Round up, like Qt's `qCeil` of implicit sizes: a child is never
// smaller than the content it holds, and this is the same rounding
// sizeHint() sums to compute the layout's own preferred size, so the
// children fill the layout without slack and without overflow.
return (std::max)(qint64(std::ceil(ownSize * dpr - 1e-9)),
qint64(0));
};
const qint64 mainOwnDev =
snappedOwn(fillsMain, horiz ? cell.width() : cell.height(), mainOwn);
const qint64 crossOwnDev = snappedOwn(
fillsCross, horiz ? cell.height() : cell.width(), crossOwn);
// The size along the main axis. An implicit-sized child keeps its own
// implicit size, which carries the device-pixel parity ZUtils chose for
// it. Rounding is not neutral: an odd logical size at 1.5x is exactly half
// a device pixel and always rounds up, so left unchecked every child adds
// half a pixel and a long list ends outside the layout - a Flickable reads
// the layout's height as the end of the content and the last rows become
// unreachable. Corrections are made in steps of two device pixels: an item
// that centers its own content (Layout.HCenter / Layout.VCenter,
// anchors.centerIn) lands on whole device pixels only while its device
// size keeps that parity, so a one-pixel correction moves its centered
// children onto half a pixel and they antialias. Drift from the engine's
// edge is bounded at two device pixels, and no child is placed past the
// layout's own edge - a layout with less room than its children needs
// overflows in Qt too, so the engine's own edge is never pulled back.
const qint64 mainFarCeil =
(std::max)(mainFarExact, layoutMainExtentDev);
const qint64 mainLimit =
(std::min)(mainFarExact + 2, mainFarCeil) - mainNear;
qint64 width = engineWidth;
qint64 height = engineHeight;
const qint64 mainSizeWritten =
mainOwnDev >= 0 ? mainOwnDev
: (std::max)(mainFarExact - mainNear, qint64(0));
if (horiz)
width = mainSizeWritten;
else
height = mainSizeWritten;
if (crossOwnDev >= 0) {
if (horiz)
height = crossOwnDev;
else
width = crossOwnDev;
}
// Keep the size already written unless the engine asks for a different
// one, or something else resized the child. An aligned size stays
// aligned wherever the layout moves, and resizing again costs the child
// a relayout: an Image reloads its texture, a nested layout rearranges.
const auto stored = d->childSizes.constFind(child);
if (stored != d->childSizes.constEnd() &&
stored.value().engineWidth == engineWidth &&
stored.value().engineHeight == engineHeight &&
std::llround(child->width() * dpr) == stored.value().width &&
std::llround(child->height() * dpr) == stored.value().height) {
width = stored.value().width;
height = stored.value().height;
}
// A remembered size can predate the layout being sized, so the limit is
// applied to whatever ends up being written, keeping the parity of the
// size this child is asking for.
qint64& mainAxisSize = horiz ? width : height;
const int mainParity = int(mainAxisSize % 2);
if (mainAxisSize > mainLimit) {
mainAxisSize = mainLimit;
if (int(mainAxisSize % 2) != mainParity) --mainAxisSize;
}
mainAxisSize = (std::max)(mainAxisSize, qint64(0));
d->childSizes[child] = SnapLayoutPrivate::ChildSize{
engineWidth, engineHeight, width, height};
prevEndExact = mainExact + mainSizeExact;
prevNearDev = mainNear;
prevSizeDev = horiz ? width : height;
const qint64 crossNear = std::llround(crossExact);
const qint64 nearX = originXDev + (horiz ? mainNear : crossNear);
const qint64 nearY = originYDev + (horiz ? crossNear : mainNear);
const QRectF snapped(
qreal(nearX) / dpr - origin.x(),
qreal(nearY) / dpr - origin.y(),
qreal(width) / dpr,
qreal(height) / dpr);
const QSizeF oldSize(child->width(), child->height());
const QSizeF newSize = snapped.size();
child->setPosition(snapped.topLeft());
if (newSize == oldSize) {
if (QQuickLayout* lay = qobject_cast<QQuickLayout*>(child)) {
if (lay->invalidatedArrangement()) lay->rearrange(newSize);
}
} else {
child->setSize(newSize);
}
}
}
void SnapLayout::rearrange(const QSizeF& size) {
Q_D(SnapLayout);
if (!isReady() || !size.isValid()) return;
@@ -408,7 +791,11 @@ void SnapLayout::rearrange(const QSizeF& size) {
}
QQuickLayout::rearrange(size);
for (int i = 0; i < d->engine.itemCount(); ++i)
static_cast<SnapGridLayoutItem*>(d->engine.itemAt(i))->placed = false;
d->engine.setGeometries(QRectF(QPointF(0, 0), size), d->styleInfo);
arrangeChildren();
d->rearranging = false;
for (auto childItem : std::as_const(d->invalidateAfterRearrange))
+40 -5
View File
@@ -1,6 +1,8 @@
#pragma once
#include <QHash>
#include <QPointer>
#include <QtMath>
#include <QtGui/private/qabstractlayoutstyleinfo_p.h>
#include <QtGui/private/qgridlayoutengine_p.h>
#include <QtQuickLayouts/private/qquickgridlayoutengine_p.h>
@@ -20,6 +22,20 @@ class SnapLayoutStyleInfo : public QAbstractLayoutStyleInfo {
bool hasChangedCore() const override;
};
class SnapGridLayoutEngine : public QGridLayoutEngine {
public:
SnapGridLayoutEngine() : QGridLayoutEngine(Qt::AlignVCenter, false) {}
QQuickGridLayoutItem* findLayoutItem(QQuickItem* item) const {
for (int i = int(q_items.size()) - 1; i >= 0; --i) {
QQuickGridLayoutItem* layoutItem =
static_cast<QQuickGridLayoutItem*>(q_items.at(i));
if (layoutItem->layoutItem() == item) return layoutItem;
}
return nullptr;
}
};
class SnapGridLayoutItem : public QQuickGridLayoutItem {
public:
SnapGridLayoutItem(
@@ -28,13 +44,12 @@ class SnapGridLayoutItem : public QQuickGridLayoutItem {
int column,
int rowSpan,
int columnSpan,
Qt::Alignment alignment,
SnapLayout* layout);
Qt::Alignment alignment);
void setGeometry(const QRectF& rect) override;
private:
SnapLayout* m_layout;
QRectF engineRect;
bool placed = false;
};
class SnapLayout : public QQuickLayout {
@@ -69,6 +84,9 @@ class SnapLayout : public QQuickLayout {
void itemChange(ItemChange change, const ItemChangeData& value) override;
void geometryChange(
const QRectF& newGeometry, const QRectF& oldGeometry) override;
void itemDestroyed(QQuickItem* item) override;
void itemVisibilityChanged(QQuickItem* item) override;
void itemGeometryChanged(
@@ -101,6 +119,13 @@ class SnapLayout : public QQuickLayout {
void insertLayoutItems();
void removeGridItem(QGridLayoutItem* gridItem);
void arrangeChildren();
// Re-arranges when the origin moved by a fraction of a device pixel.
void rearrangeForOriginMove();
void applySpacing();
void updateAncestorListeners();
void clearAncestorListeners();
};
@@ -113,17 +138,27 @@ class SnapLayoutPrivate : public QQuickLayoutPrivate {
void mirrorChange() override;
QQuickGridLayoutEngine engine;
SnapGridLayoutEngine engine;
SnapLayoutStyleInfo* styleInfo = nullptr;
Qt::Orientation orientation = Qt::Horizontal;
Qt::LayoutDirection layoutDirection = Qt::LeftToRight;
qreal spacingRequest = qQNaN();
bool rearranging = false;
int recurRearrangeCounter = 0;
QList<QQuickItem*> invalidateAfterRearrange;
QVector<QPointer<QQuickItem>> ancestorListeners;
QHash<QQuickItem*, QFlags<QQuickItemPrivate::ChangeType>> ancestorMasks;
qint64 originResidualX = -1;
qint64 originResidualY = -1;
struct ChildSize {
qint64 engineWidth = 0;
qint64 engineHeight = 0;
qint64 width = 0;
qint64 height = 0;
};
QHash<QQuickItem*, ChildSize> childSizes;
};
} // namespace ZShell::components
+806
View File
@@ -0,0 +1,806 @@
#include "snapview.hpp"
#include <QQuickWindow>
#include <QTimer>
#include <private/qquickflickable_p.h>
#include <private/qquickflickable_p_p.h>
#include <algorithm>
#include <cmath>
#include <limits>
namespace ZShell::components {
namespace {
// The same device-pixel helpers SnapLayout uses: the residual is the fractional
// part of an absolute device-pixel position, which is how a sub-pixel move of
// the view or its ancestors is noticed without reacting to whole-pixel moves.
qint64 originResidual(qreal logical, qreal dpr) {
const qreal device = logical * dpr;
return std::llround((device - std::floor(device)) * 1024.0) % 1024;
}
QPointF absoluteOrigin(const QQuickItem* item) {
qreal x = 0.0;
qreal y = 0.0;
for (const QQuickItem* it = item; it; it = it->parentItem()) {
x += it->x();
y += it->y();
}
return QPointF(x, y);
}
constexpr int AsyncBatchSize = 4;
// Sizes are compared with a tolerance rather than ==, because the snapped
// logical values are quotients of device pixels and never exact.
bool differs(qreal a, qreal b) {
return qAbs(a - b) > 0.001;
}
// Geometry is watched to notice sub-pixel moves; Destroyed is watched so a
// listener can be dropped while the item is still valid instead of at teardown.
constexpr QFlags<QQuickItemPrivate::ChangeType> WatchedChanges =
QQuickItemPrivate::Geometry | QQuickItemPrivate::Destroyed;
void addGeometryListener(QQuickItem* item, QQuickItemChangeListener* listener) {
if (item)
QQuickItemPrivate::get(item)->addItemChangeListener(
listener, WatchedChanges);
}
void removeGeometryListener(
QQuickItem* item, QQuickItemChangeListener* listener) {
if (item)
QQuickItemPrivate::get(item)->removeItemChangeListener(
listener, WatchedChanges);
}
} // namespace
SnapView::SnapView(QQuickItem* parent) : QQuickFlickable(parent) {
setClip(true);
// Asynchronous batches are spread over event loop turns, so the frames
// between them can render; polish() from updatePolish would run them all
// inside one polish flush.
m_asyncTimer = new QTimer(this);
m_asyncTimer->setSingleShot(true);
m_asyncTimer->setInterval(0);
connect(m_asyncTimer, &QTimer::timeout, this, &SnapView::polish);
}
SnapView::~SnapView() {
clearListeners();
disconnectModel();
}
QAbstractItemModel* SnapView::model() const {
return m_model.data();
}
void SnapView::setModel(QAbstractItemModel* model) {
if (m_model == model) return;
disconnectModel();
for (int index : m_items.keys())
destroyDelegate(index);
m_sizeDevCache.clear();
m_rowSizes.clear();
m_model = model;
invalidateRoleNames();
connectModel();
rebuildRows();
if (m_componentComplete) polish();
emit modelChanged();
}
QQmlComponent* SnapView::delegate() const {
return m_delegate.data();
}
void SnapView::setDelegate(QQmlComponent* delegate) {
if (m_delegate == delegate) return;
m_delegate = delegate;
for (int index : m_items.keys())
destroyDelegate(index);
m_pendingCreate.clear();
m_sizeDevCache.clear();
if (m_componentComplete) polish();
emit delegateChanged();
}
Qt::Orientation SnapView::orientation() const {
return m_orientation;
}
void SnapView::setOrientation(Qt::Orientation orientation) {
if (m_orientation == orientation) return;
m_orientation = orientation;
invalidateMainAxisCaches();
if (m_componentComplete) polish();
emit orientationChanged();
}
qreal SnapView::spacing() const {
return m_spacing;
}
void SnapView::setSpacing(qreal spacing) {
if (qFuzzyCompare(m_spacing, spacing)) return;
m_spacing = spacing;
if (m_componentComplete) polish();
emit spacingChanged();
}
qreal SnapView::cacheBuffer() const {
return m_cacheBuffer;
}
void SnapView::setCacheBuffer(qreal buffer) {
if (qFuzzyCompare(m_cacheBuffer, buffer)) return;
m_cacheBuffer = buffer;
if (m_componentComplete) polish();
emit cacheBufferChanged();
}
qreal SnapView::estimatedSize() const {
return m_estimatedSize;
}
void SnapView::setEstimatedSize(qreal size) {
if (qFuzzyCompare(m_estimatedSize, size)) return;
m_estimatedSize = size;
if (m_componentComplete) polish();
emit estimatedSizeChanged();
}
bool SnapView::snapSizes() const {
return m_snapSizes;
}
void SnapView::setSnapSizes(bool snapSizes) {
if (m_snapSizes == snapSizes) return;
m_snapSizes = snapSizes;
if (m_componentComplete) polish();
emit snapSizesChanged();
}
bool SnapView::asynchronous() const {
return m_asynchronous;
}
void SnapView::setAsynchronous(bool asynchronous) {
if (m_asynchronous == asynchronous) return;
m_asynchronous = asynchronous;
if (m_componentComplete) polish();
emit asynchronousChanged();
}
int SnapView::count() const {
return m_model ? m_model->rowCount() : 0;
}
int SnapView::currentIndex() const {
return m_currentIndex;
}
void SnapView::setCurrentIndex(int index) {
if (m_currentIndex == index) return;
m_currentIndex = index;
emit currentIndexChanged();
emit currentItemChanged();
}
QQuickItem* SnapView::currentItem() const {
return m_items.value(m_currentIndex, nullptr);
}
void SnapView::positionViewAtBeginning() {
positionViewAtIndex(0);
}
void SnapView::positionViewAtEnd() {
positionViewAtIndex(count() - 1);
}
void SnapView::positionViewAtIndex(int index) {
if (!m_componentComplete || index < 0 || index >= m_rows.size()) return;
// Summed here rather than read from m_rows, whose offsets only refresh on
// polish: positioning must be right even when called between passes.
const bool horiz = m_orientation == Qt::Horizontal;
const qreal extent = horiz ? width() : height();
const qreal spacing = effectiveSpacing();
qreal offset = 0.0;
qreal target = 0.0;
qreal total = 0.0;
for (int i = 0; i < m_rows.size(); ++i) {
if (i == index) target = offset;
const qreal size = rowSize(i);
total = offset + size;
offset += size + spacing;
}
const qreal position =
std::clamp(target, 0.0, (std::max)(0.0, total - extent));
const qint64 positionDev = std::llround(position * devicePixelRatio());
const qreal snapped = qreal(positionDev) / devicePixelRatio();
if (horiz)
setContentX(snapped);
else
setContentY(snapped);
}
qreal SnapView::devicePixelRatio() const {
const QQuickWindow* win = window();
const qreal dpr = win ? win->effectiveDevicePixelRatio() : 1.0;
return dpr > 0.0 ? dpr : 1.0;
}
void SnapView::componentComplete() {
QQuickFlickable::componentComplete();
m_componentComplete = true;
rebuildRows();
updateAncestorListeners();
polish();
}
void SnapView::geometryChange(
const QRectF& newGeometry, const QRectF& oldGeometry) {
QQuickFlickable::geometryChange(newGeometry, oldGeometry);
if (m_componentComplete && !m_placing &&
newGeometry.size() != oldGeometry.size()) {
polish();
}
}
void SnapView::itemChange(ItemChange change, const ItemChangeData& value) {
QQuickFlickable::itemChange(change, value);
if (change == ItemSceneChange) {
// Leaving the scene means the ancestors are going away: drop the
// listeners now, while both sides are still whole.
if (value.window) {
updateAncestorListeners();
polish();
} else {
clearListeners();
}
return;
}
if (change == ItemDevicePixelRatioHasChanged) {
updateAncestorListeners();
polish();
}
}
void SnapView::updatePolish() {
if (!m_componentComplete) return;
layoutAndPlace();
if (!m_pendingCreate.isEmpty() && m_asynchronous) m_asyncTimer->start();
}
void SnapView::itemGeometryChanged(
QQuickItem* item, QQuickGeometryChange change, const QRectF& oldGeometry) {
Q_UNUSED(oldGeometry)
if (!m_componentComplete || m_placing) return;
const qreal dpr = devicePixelRatio();
QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem;
const bool watched = std::any_of(
m_ancestorListeners.constBegin(),
m_ancestorListeners.constEnd(),
[item](const QPointer<QQuickItem>& p) { return p == item; });
if (item == content || watched) {
const QPointF origin = contentOrigin();
if (originResidual(origin.x(), dpr) != m_originResidualX ||
originResidual(origin.y(), dpr) != m_originResidualY) {
polish();
}
return;
}
if (change.sizeChange() && m_items.key(item, -1) >= 0) polish();
}
void SnapView::itemDestroyed(QQuickItem* item) {
m_sizeDevCache.remove(item);
const auto isItem = [item](const QPointer<QQuickItem>& p) {
return p == item;
};
m_delegateListeners.erase(
std::remove_if(
m_delegateListeners.begin(), m_delegateListeners.end(), isItem),
m_delegateListeners.end());
m_ancestorListeners.erase(
std::remove_if(
m_ancestorListeners.begin(), m_ancestorListeners.end(), isItem),
m_ancestorListeners.end());
const int index = m_items.key(item, -1);
if (index >= 0) {
m_items.remove(index);
emit currentItemChanged();
}
}
qreal SnapView::effectiveSpacing() const {
return qreal(std::llround(m_spacing * devicePixelRatio())) /
devicePixelRatio();
}
qreal SnapView::rowSize(int index) const {
QQuickItem* item = m_items.value(index, nullptr);
if (item) {
const qreal measured = m_orientation == Qt::Horizontal ? item->width()
: item->height();
if (measured > 0.0) return measured;
}
const qreal cached = m_rowSizes.value(index, 0.0);
if (cached > 0.0) return cached;
return m_estimatedSize > 0.0 ? m_estimatedSize : 0.0;
}
qint64 SnapView::snappedDeviceMainSize(QQuickItem* item) {
const qreal dpr = devicePixelRatio();
const qreal own = m_orientation == Qt::Horizontal ? item->width()
: item->height();
if (own <= 0.0) return 0;
// Reuse the cached device size while the item's own size is still within a
// device pixel of it, so an item that only moves keeps its size.
const qint64 cached = m_sizeDevCache.value(item, -1);
if (cached >= 0 && qAbs(own * dpr - qreal(cached)) <= 1.0) return cached;
const qint64 snapped = std::llround(own * dpr);
m_sizeDevCache[item] = snapped;
return snapped;
}
int SnapView::firstVisibleIndex() const {
if (m_rows.isEmpty()) return 0;
// Row ends are non-decreasing (offsets accumulate size plus spacing), so the
// visible range can be found by binary search instead of a linear scan.
const qreal start = m_orientation == Qt::Horizontal ? contentX()
: contentY();
const qreal from = (std::max)(0.0, start - m_cacheBuffer);
const auto it = std::lower_bound(
m_rows.constBegin(),
m_rows.constEnd(),
from,
[](const Row& row, qreal value) {
return row.exactOffset + row.exactSize < value;
});
return static_cast<int>(std::distance(m_rows.constBegin(), it));
}
int SnapView::lastVisibleIndex() const {
if (m_rows.isEmpty()) return 0;
const bool horiz = m_orientation == Qt::Horizontal;
const qreal start = horiz ? contentX() : contentY();
const qreal extent = horiz ? width() : height();
const qreal to = start + extent + m_cacheBuffer;
const auto it = std::lower_bound(
m_rows.constBegin(),
m_rows.constEnd(),
to,
[](const Row& row, qreal value) { return row.exactOffset <= value; });
if (it == m_rows.constBegin()) return 0;
return static_cast<int>(std::distance(m_rows.constBegin(), it)) - 1;
}
QPointF SnapView::contentOrigin() const {
// QQuickFlickablePrivate::get has no const overload; the item private is the
// base of the flickable private, so casting is the const path to it.
const auto* priv = static_cast<const QQuickFlickablePrivate*>(
QQuickItemPrivate::get(this));
return absoluteOrigin(priv->contentItem);
}
void SnapView::layoutAndPlace() {
const qreal dpr = devicePixelRatio();
const bool horiz = m_orientation == Qt::Horizontal;
const qreal spacing = effectiveSpacing();
// Cumulative exact offsets, so the rounding below has the same exact
// reference SnapLayout's arrangeChildren works from. Running this before
// updateDelegates gives the viewport range to recycle against, and running
// it again afterwards picks up sizes that changed when a re-keyed delegate
// got a new index.
const auto accumulate = [&]() {
qreal offset = 0.0;
for (int i = 0; i < m_rows.size(); ++i) {
qreal size = rowSize(i);
QQuickItem* item = m_items.value(i, nullptr);
if (item) {
const qreal measured = horiz ? item->width() : item->height();
if (measured > 0.0) m_rowSizes[i] = measured;
}
m_rows[i].exactOffset = offset;
m_rows[i].exactSize = size;
offset += size + spacing;
}
return m_rows.isEmpty() ? 0.0 : offset - spacing;
};
accumulate();
updateDelegates();
const qreal total = accumulate();
if (horiz)
setContentWidth(qreal(std::llround(total * dpr)) / dpr);
else
setContentHeight(qreal(std::llround(total * dpr)) / dpr);
QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem;
if (!content) return;
const QPointF origin = absoluteOrigin(content);
const qint64 originXDev = std::llround(origin.x() * dpr);
const qint64 originYDev = std::llround(origin.y() * dpr);
const qint64 crossDev = std::llround((horiz ? height() : width()) * dpr);
qint64 prevNear = 0;
qint64 prevSizeDev = 0;
qreal prevEndExact = 0.0;
bool started = false;
m_placing = true;
for (int i = 0; i < m_rows.size(); ++i) {
const Row& row = m_rows.at(i);
const qreal mainExact = row.exactOffset * dpr;
qint64 mainNear = 0;
if (!started) {
mainNear = std::llround(mainExact);
started = true;
} else {
mainNear =
prevNear + prevSizeDev + std::llround(mainExact - prevEndExact);
}
QQuickItem* item = m_items.value(i, nullptr);
qint64 mainDev = std::llround(row.exactSize * dpr);
if (item) {
const qint64 ownDev = snappedDeviceMainSize(item);
if (ownDev > 0) mainDev = ownDev;
}
prevEndExact = mainExact + row.exactSize * dpr;
prevSizeDev = mainDev;
prevNear = mainNear;
if (!item) continue;
const qreal mainSize = qreal(mainDev) / dpr;
const qreal crossSize = qreal(crossDev) / dpr;
const qreal posX = horiz
? qreal(originXDev + mainNear) / dpr - origin.x()
: qreal(originXDev) / dpr - origin.x();
const qreal posY =
horiz ? qreal(originYDev) / dpr - origin.y()
: qreal(originYDev + mainNear) / dpr - origin.y();
item->setPosition(QPointF(posX, posY));
// Only the cross axis is written unconditionally. Writing the main axis
// would replace the delegate's implicit-size fallback with an explicit
// value, so a delegate whose content changes size could never grow
// again. It is resized only when snapSizes asks for exactly that.
if (horiz) {
if (m_snapSizes && mainDev > 0 && differs(item->width(), mainSize))
item->setWidth(mainSize);
if (differs(item->height(), crossSize)) item->setHeight(crossSize);
} else {
if (differs(item->width(), crossSize)) item->setWidth(crossSize);
if (m_snapSizes && mainDev > 0 && differs(item->height(), mainSize))
item->setHeight(mainSize);
}
}
m_placing = false;
trackOriginResidual();
}
void SnapView::updateDelegates() {
const int first = firstVisibleIndex();
const int last = lastVisibleIndex();
for (int index : m_items.keys()) {
if (index < first || index > last) destroyDelegate(index);
}
QVector<int> missing;
for (int index = first; index <= last; ++index) {
if (m_items.contains(index))
updateDelegateData(index);
else
missing.append(index);
}
int budget = m_asynchronous ? AsyncBatchSize
: (std::numeric_limits<int>::max)();
m_pendingCreate.clear();
for (int index : std::as_const(missing)) {
if (budget <= 0) {
m_pendingCreate.append(index);
continue;
}
(void)createDelegate(index);
--budget;
}
}
QQuickItem* SnapView::createDelegate(int index) {
if (!m_delegate || !m_model || index < 0 || index >= m_model->rowCount())
return nullptr;
if (m_items.contains(index)) return m_items.value(index);
// Same as LazyListView: beginCreate + setInitialProperties + completeCreate,
// so delegates with pragma ComponentBehavior: Bound are accepted.
auto* compContext = m_delegate->creationContext();
if (!compContext) compContext = qmlContext(this);
if (!compContext) return nullptr;
QObject* obj = m_delegate->beginCreate(compContext);
QQuickItem* item = qobject_cast<QQuickItem*>(obj);
if (!item) {
qWarning(
"SnapView: delegate creation failed for row %d: %s",
index,
qPrintable(m_delegate->errorString()));
if (obj) m_delegate->completeCreate();
delete obj;
return nullptr;
}
m_delegate->setInitialProperties(item, delegateProperties(index));
QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem;
item->setParentItem(content ? content : this);
addGeometryListener(item, this);
m_delegateListeners.append(item);
m_items.insert(index, item);
m_delegate->completeCreate();
emit currentItemChanged();
return item;
}
void SnapView::destroyDelegate(int index) {
QQuickItem* item = m_items.take(index);
if (!item) return;
removeGeometryListener(item, this);
m_delegateListeners.removeAll(item);
m_sizeDevCache.remove(item);
item->deleteLater();
emit currentItemChanged();
}
void SnapView::updateDelegateData(int index) {
QQuickItem* item = m_items.value(index, nullptr);
if (!item) return;
const QVariantMap props = delegateProperties(index);
for (auto it = props.constBegin(); it != props.constEnd(); ++it) {
const QByteArray name = it.key().toUtf8();
// Scrolls refresh every visible delegate each pass; writing only the
// values that actually changed keeps that cheap.
if (item->property(name.constData()) == it.value()) continue;
item->setProperty(name.constData(), it.value());
}
}
QVariantMap SnapView::delegateProperties(int index) {
QVariantMap props;
if (!m_model || index < 0 || index >= m_model->rowCount()) return props;
const QModelIndex srcIndex = m_model->index(index, 0);
const auto& names = roleNames();
bool hasModelData = false;
for (auto it = names.constBegin(); it != names.constEnd(); ++it) {
const auto name = QString::fromUtf8(it.value());
props.insert(name, m_model->data(srcIndex, it.key()));
if (name == QStringLiteral("modelData")) hasModelData = true;
}
props.insert(QStringLiteral("index"), index);
if (!hasModelData) {
const auto role = names.isEmpty() ? Qt::DisplayRole
: names.constBegin().key();
props.insert(QStringLiteral("modelData"), m_model->data(srcIndex, role));
}
return props;
}
const QHash<int, QByteArray>& SnapView::roleNames() {
if (!m_roleNamesValid) {
m_roleNames = m_model ? m_model->roleNames() : QHash<int, QByteArray>();
m_roleNamesValid = true;
}
return m_roleNames;
}
void SnapView::invalidateRoleNames() {
m_roleNamesValid = false;
}
void SnapView::invalidateMainAxisCaches() {
// Cached sizes belong to the axis that is no longer the main one.
m_rowSizes.fill(0.0);
m_sizeDevCache.clear();
}
void SnapView::rebuildRows() {
m_rows.resize(count());
m_rowSizes.resize(count());
if (m_currentIndex >= count()) m_currentIndex = -1;
emit countChanged();
emit currentItemChanged();
}
void SnapView::trackOriginResidual() {
const qreal dpr = devicePixelRatio();
const QPointF origin = contentOrigin();
m_originResidualX = originResidual(origin.x(), dpr);
m_originResidualY = originResidual(origin.y(), dpr);
}
void SnapView::shiftDelegateIndices(int from, int delta) {
if (delta == 0) return;
QHash<int, QQuickItem*> shifted;
for (auto it = m_items.constBegin(); it != m_items.constEnd(); ++it) {
shifted.insert(
it.key() >= from ? it.key() + delta : it.key(), it.value());
}
m_items = shifted;
for (int& index : m_pendingCreate) {
if (index >= from) index += delta;
}
}
void SnapView::updateAncestorListeners() {
QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem;
QVector<QPointer<QQuickItem>> want;
if (content) want.append(content);
for (QQuickItem* it = parentItem(); it; it = it->parentItem())
want.append(it);
for (const QPointer<QQuickItem>& item :
std::as_const(m_ancestorListeners)) {
if (item && !want.contains(item))
removeGeometryListener(item.data(), this);
}
for (const QPointer<QQuickItem>& item : std::as_const(want)) {
const bool known = std::any_of(
m_ancestorListeners.constBegin(),
m_ancestorListeners.constEnd(),
[&item](const QPointer<QQuickItem>& p) { return p == item; });
if (!known) addGeometryListener(item.data(), this);
}
m_ancestorListeners = want;
trackOriginResidual();
}
void SnapView::clearListeners() {
for (const QPointer<QQuickItem>& item : std::as_const(m_ancestorListeners))
removeGeometryListener(item.data(), this);
m_ancestorListeners.clear();
m_delegateListeners.clear();
}
void SnapView::connectModel() {
if (!m_model) return;
m_modelConnections.append(
QObject::connect(
m_model,
&QAbstractItemModel::rowsInserted,
this,
&SnapView::onRowsInserted));
m_modelConnections.append(
QObject::connect(
m_model,
&QAbstractItemModel::rowsAboutToBeRemoved,
this,
&SnapView::onRowsAboutToBeRemoved));
m_modelConnections.append(
QObject::connect(
m_model,
&QAbstractItemModel::rowsRemoved,
this,
&SnapView::onRowsRemoved));
m_modelConnections.append(
QObject::connect(
m_model,
&QAbstractItemModel::dataChanged,
this,
&SnapView::onDataChanged));
m_modelConnections.append(
QObject::connect(
m_model,
&QAbstractItemModel::modelReset,
this,
&SnapView::onModelReset));
m_modelConnections.append(
QObject::connect(
m_model, &QAbstractItemModel::layoutChanged, this, [this]() {
rebuildRows();
polish();
}));
}
void SnapView::disconnectModel() {
for (const auto& conn : std::as_const(m_modelConnections))
disconnect(conn);
m_modelConnections.clear();
}
void SnapView::onRowsInserted(const QModelIndex& parent, int first, int last) {
if (parent.isValid()) return;
// Delegates at or after the inserted range now address a different row, and
// the inserted rows have no measured size yet.
shiftDelegateIndices(first, last - first + 1);
m_rowSizes.resize(count());
for (int index = first; index <= last; ++index)
m_rowSizes[index] = 0.0;
rebuildRows();
polish();
}
void SnapView::onRowsAboutToBeRemoved(
const QModelIndex& parent, int first, int last) {
if (parent.isValid()) return;
for (int index = first; index <= last; ++index)
destroyDelegate(index);
m_rowSizes.remove(first, last - first + 1);
shiftDelegateIndices(last + 1, -(last - first + 1));
}
void SnapView::onRowsRemoved(const QModelIndex& parent, int first, int last) {
if (parent.isValid()) return;
Q_UNUSED(first)
Q_UNUSED(last)
rebuildRows();
polish();
}
void SnapView::onDataChanged(
const QModelIndex& topLeft, const QModelIndex& bottomRight) {
if (topLeft.parent().isValid() || bottomRight.parent().isValid()) return;
for (int index = topLeft.row(); index <= bottomRight.row(); ++index)
updateDelegateData(index);
// A row's content can change its size, so re-place as well.
polish();
}
void SnapView::onModelReset() {
for (int index : m_items.keys())
destroyDelegate(index);
m_sizeDevCache.clear();
m_rowSizes.clear();
rebuildRows();
polish();
}
} // namespace ZShell::components
+237
View File
@@ -0,0 +1,237 @@
#pragma once
#include <QAbstractItemModel>
#include <QHash>
#include <QPointer>
#include <QQmlComponent>
#include <private/qquickflickable_p.h>
#include <private/qquickitem_p.h>
#include <qqmlintegration.h>
class QTimer;
namespace ZShell::components {
// A ListView replacement that lays delegates out the way SnapLayout lays out
// children: delegates are placed on device-pixel boundaries, the gaps between
// them are whole device pixels, siblings that move together keep their relative
// offsets, and a delegate that only moves is not resized. Like SnapLayout is to
// RowLayout/ColumnLayout, this is to ListView: a Flickable with its own
// placement, so there is no base-view positioner to fight with.
//
// Delegates receive `index` and `modelData` initial properties, so a delegate
// declares `required property int index` and `required property var modelData`.
// The cross-axis size (height in a horizontal view, width in a vertical one) is
// set by the view, as ListView does; the main-axis size comes from the delegate
// unless snapSizes is true.
class SnapView : public QQuickFlickable, public QQuickItemChangeListener {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(
QAbstractItemModel* model READ model WRITE setModel NOTIFY modelChanged
FINAL)
Q_PROPERTY(
QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY
delegateChanged FINAL)
// Layout
Q_PROPERTY(
Qt::Orientation orientation READ orientation WRITE setOrientation NOTIFY
orientationChanged FINAL)
Q_PROPERTY(
qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged FINAL)
Q_PROPERTY(
qreal cacheBuffer READ cacheBuffer WRITE setCacheBuffer NOTIFY
cacheBufferChanged FINAL)
Q_PROPERTY(
qreal estimatedSize READ estimatedSize WRITE setEstimatedSize NOTIFY
estimatedSizeChanged FINAL)
// Resize delegates to device pixels along the main axis too. Off by
// default, because delegates usually derive that size from their content
// and resizing them would break the binding. Placement is always
// device-pixel snapped.
Q_PROPERTY(
bool snapSizes READ snapSizes WRITE setSnapSizes NOTIFY snapSizesChanged
FINAL)
// Spread delegate creation over frames instead of creating everything in
// the first polish pass.
Q_PROPERTY(
bool asynchronous READ asynchronous WRITE setAsynchronous NOTIFY
asynchronousChanged FINAL)
// State
Q_PROPERTY(int count READ count NOTIFY countChanged FINAL)
Q_PROPERTY(
int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY
currentIndexChanged FINAL)
Q_PROPERTY(
QQuickItem* currentItem READ currentItem NOTIFY currentItemChanged FINAL)
signals:
void modelChanged();
void delegateChanged();
void orientationChanged();
void spacingChanged();
void cacheBufferChanged();
void estimatedSizeChanged();
void snapSizesChanged();
void asynchronousChanged();
void countChanged();
void currentIndexChanged();
void currentItemChanged();
public:
explicit SnapView(QQuickItem* parent = nullptr);
~SnapView() override;
[[nodiscard]] QAbstractItemModel* model() const;
void setModel(QAbstractItemModel* model);
[[nodiscard]] QQmlComponent* delegate() const;
void setDelegate(QQmlComponent* delegate);
[[nodiscard]] Qt::Orientation orientation() const;
void setOrientation(Qt::Orientation orientation);
[[nodiscard]] qreal spacing() const;
void setSpacing(qreal spacing);
[[nodiscard]] qreal cacheBuffer() const;
void setCacheBuffer(qreal buffer);
[[nodiscard]] qreal estimatedSize() const;
void setEstimatedSize(qreal size);
[[nodiscard]] bool snapSizes() const;
void setSnapSizes(bool snapSizes);
[[nodiscard]] bool asynchronous() const;
void setAsynchronous(bool asynchronous);
[[nodiscard]] int count() const;
[[nodiscard]] int currentIndex() const;
void setCurrentIndex(int index);
[[nodiscard]] QQuickItem* currentItem() const;
Q_INVOKABLE void positionViewAtBeginning();
Q_INVOKABLE void positionViewAtEnd();
Q_INVOKABLE void positionViewAtIndex(int index);
protected:
void componentComplete() override;
void geometryChange(
const QRectF& newGeometry, const QRectF& oldGeometry) override;
void updatePolish() override;
void itemChange(ItemChange change, const ItemChangeData& value) override;
// QQuickItemChangeListener
void itemGeometryChanged(
QQuickItem* item,
QQuickGeometryChange change,
const QRectF& oldGeometry) override;
void itemDestroyed(QQuickItem* item) override;
private:
struct Row {
qreal exactOffset = 0.0;
qreal exactSize = 0.0;
};
// Main-axis size last measured from a delegate, kept per model row so the
// content extent stays right while delegates outside the viewport are
// destroyed, the way ListView keeps contentHeight.
QVector<qreal> m_rowSizes;
// Placement, mirroring SnapLayout::arrangeChildren: exact positions come
// from cumulative row sizes, each near edge is rounded on its own so gaps
// stay whole device pixels, and siblings keep their relative offsets.
void layoutAndPlace();
void updateDelegates();
[[nodiscard]] QQuickItem* createDelegate(int index);
void destroyDelegate(int index);
void updateDelegateData(int index);
[[nodiscard]] qreal effectiveSpacing() const;
[[nodiscard]] qreal devicePixelRatio() const;
// Main-axis size of a row: the delegate's own size if it exists, otherwise
// the size cached when it was last visible, otherwise the estimate.
[[nodiscard]] qreal rowSize(int index) const;
[[nodiscard]] qint64 snappedDeviceMainSize(QQuickItem* item);
[[nodiscard]] int firstVisibleIndex() const;
[[nodiscard]] int lastVisibleIndex() const;
[[nodiscard]] QPointF contentOrigin() const;
void trackOriginResidual();
[[nodiscard]] QVariantMap delegateProperties(int index);
// The model's role names, cached because some models rebuild the hash on
// every call and this runs once per delegate per refresh.
[[nodiscard]] const QHash<int, QByteArray>& roleNames();
void invalidateRoleNames();
void rebuildRows();
// Sizes are measured along the main axis, so switching axis invalidates
// every cached size.
void invalidateMainAxisCaches();
// Re-keys the delegate map when rows shift, so an existing item keeps its
// place in the model instead of pointing at the wrong row.
void shiftDelegateIndices(int from, int delta);
void updateAncestorListeners();
void clearListeners();
void connectModel();
void disconnectModel();
void onRowsInserted(const QModelIndex& parent, int first, int last);
void onRowsAboutToBeRemoved(const QModelIndex& parent, int first, int last);
void onRowsRemoved(const QModelIndex& parent, int first, int last);
void onDataChanged(
const QModelIndex& topLeft, const QModelIndex& bottomRight);
void onModelReset();
QPointer<QAbstractItemModel> m_model;
QPointer<QQmlComponent> m_delegate;
Qt::Orientation m_orientation = Qt::Vertical;
qreal m_spacing = 0.0;
qreal m_cacheBuffer = 0.0;
qreal m_estimatedSize = -1.0;
bool m_snapSizes = false;
bool m_asynchronous = false;
int m_currentIndex = -1;
QVector<Row> m_rows;
QHash<int, QQuickItem*> m_items;
QVector<int> m_pendingCreate;
// Device-pixel main size of a delegate, so an item that only moves keeps
// the size it already has rather than re-rounding a drifting exact size.
QHash<QQuickItem*, qint64> m_sizeDevCache;
// Cached model role names; valid until the model or its roles change.
QHash<int, QByteArray> m_roleNames;
bool m_roleNamesValid = false;
// Fires the next asynchronous creation batch on the next event loop turn,
// so batches land on different frames instead of one polish flush.
QTimer* m_asyncTimer = nullptr;
// Items watched for sub-pixel moves. QPointer, as SnapLayout does, so a
// teardown order that destroys an item before the view cannot leave a
// dangling pointer to remove a listener from.
QVector<QPointer<QQuickItem>> m_delegateListeners;
QVector<QPointer<QQuickItem>> m_ancestorListeners;
qint64 m_originResidualX = -1;
qint64 m_originResidualY = -1;
bool m_placing = false;
bool m_componentComplete = false;
QList<QMetaObject::Connection> m_modelConnections;
};
} // namespace ZShell::components
+169 -48
View File
@@ -3,23 +3,120 @@
#include <qmath.h>
#include <qpainter.h>
#include <qpen.h>
#include <qquickwindow.h>
#include <algorithm>
#include <cmath>
namespace ZShell::components {
namespace {
qint64 toDev(qreal logical, qreal dpr) {
return std::llround(logical * dpr);
}
qreal fromDev(qint64 device, qreal dpr) {
return qreal(device) / dpr;
}
// A line center whose device position leaves a whole-pixel-wide stroke: an even
// width sits on pixel boundaries, an odd width on pixel centers. Same rule as
// snapLine() in Components/LineGraph.qml.
qreal snapLine(qreal logical, qint64 widthDev, qreal dpr) {
const qreal half = qreal(widthDev) / 2.0;
return (static_cast<qreal>(std::llround(logical * dpr - half)) + half) /
dpr;
}
// y of a cubic bezier at targetX, found by bisection on the parameter. Used to
// place one vertex per device pixel column, so a segment never interpolates its
// edge across several columns.
qreal cubicY(
const QPointF& p1,
const QPointF& c1,
const QPointF& c2,
const QPointF& p2,
qreal targetX) {
qreal lo = 0.0;
qreal hi = 1.0;
for (int it = 0; it < 16; ++it) {
const qreal t = (lo + hi) / 2.0;
const qreal u = 1.0 - t;
const qreal x = u * u * u * p1.x() + 3 * u * u * t * c1.x() +
3 * u * t * t * c2.x() + t * t * t * p2.x();
if (x < targetX)
lo = t;
else
hi = t;
}
const qreal t = (lo + hi) / 2.0;
const qreal u = 1.0 - t;
return u * u * u * p1.y() + 3 * u * u * t * c1.y() +
3 * u * t * t * c2.y() + t * t * t * p2.y();
}
// Catmull-Rom controls with the control y clamped to the segment's own y span,
// so a curve never overshoots past its endpoints.
void segmentControls(
const QVector<QPointF>& points,
int i,
bool curveSmoothing,
QPointF& c1,
QPointF& c2) {
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, points.size() - 1)];
if (curveSmoothing && points.size() > 2) {
const qreal k = 1.0 / 6.0;
c1 = p1 + (p2 - p0) * k;
c2 = p2 - (p3 - p1) * k;
const qreal minY = qMin(p1.y(), p2.y());
const qreal maxY = qMax(p1.y(), p2.y());
c1.setY(qBound(minY, c1.y(), maxY));
c2.setY(qBound(minY, c2.y(), maxY));
return;
}
const qreal cx = (p1.x() + p2.x()) * 0.5;
c1 = QPointF(cx, p1.y());
c2 = QPointF(cx, p2.y());
}
} // namespace
SparklineContent::SparklineContent(QQuickItem* parent)
: QQuickPaintedItem(parent) {
setAntialiasing(true);
}
void SparklineContent::itemChange(
ItemChange change, const ItemChangeData& value) {
QQuickPaintedItem::itemChange(change, value);
if (change == ItemSceneChange && value.window)
m_dpr = value.window->effectiveDevicePixelRatio();
else if (change == ItemDevicePixelRatioHasChanged && window())
m_dpr = window()->effectiveDevicePixelRatio();
else
return;
invalidate();
}
qreal SparklineContent::stepX() const {
if (m_historyLength < 2 || m_viewportW <= 0.0) return 0.0;
if (m_sliding) return m_viewportW / static_cast<qreal>(m_historyLength - 1);
const int len = qMax(
2,
(m_line1 && m_line1->count() >= 2) ? m_line1->count()
: (m_line2 ? m_line2->count() : 2));
return m_viewportW / static_cast<qreal>(len - 1);
const qreal logical =
m_sliding ? m_viewportW / static_cast<qreal>(m_historyLength - 1)
: m_viewportW / static_cast<qreal>(len - 1);
// A whole device pixel per step: every sample lands on a pixel column, and
// one slide step moves the content by an exact pixel count.
return fromDev((std::max)(qint64(1), toDev(logical, m_dpr)), m_dpr);
}
void SparklineContent::setViewportSize(
@@ -130,79 +227,97 @@ void SparklineContent::applyGeometry() {
SparklineContent::LineGeometry SparklineContent::buildGeometry(
internal::CircularBuffer* buffer) const {
LineGeometry geom;
if (!buffer || buffer->count() < 2 || m_maxValue <= 0.0 ||
m_viewportW <= 0.0)
// Every input has to be a usable number: a NaN from QML (an animated maxValue
// fed by an empty buffer, for instance) would otherwise reach llround and the
// path stroker as garbage, which draws a shape that ignores the curve.
if (!buffer || buffer->count() < 2 || !std::isfinite(m_maxValue) ||
m_maxValue <= 0.0 || !std::isfinite(m_viewportW) ||
m_viewportW <= 0.0 || !std::isfinite(m_viewportH) || m_viewportH <= 0.0)
return geom;
if (m_historyLength < 2) return geom;
const qreal dpr = m_dpr > 0.0 ? m_dpr : 1.0;
const int len = buffer->count();
const qreal step = stepX();
if (step <= 0.0) return geom;
if (!std::isfinite(step) || step <= 0.0) return geom;
const qint64 stepDev = toDev(step, dpr);
const qint64 lineDev = std::isfinite(m_lineWidth)
? (std::max)(qint64(1), toDev(m_lineWidth, dpr))
: qint64(1);
const qint64 heightDev = toDev(m_viewportH, dpr);
qreal startX = 0.0;
if (m_sliding)
startX = m_viewportW - (len - 1) * step + step;
else
startX = 0.0;
const qint64 startDev = toDev(startX, dpr);
const qreal h = m_viewportH;
const qreal strokePad = qCeil(m_lineWidth / 2);
const qreal curvePad = 3.0;
const qreal plotTop = strokePad + curvePad;
const qreal plotBottom = h - strokePad;
const qreal fillBottom = h;
const qreal plotH = qMax<qreal>(1.0, plotBottom - plotTop);
// Plot bounds in device pixels: half a stroke plus the curve overshoot at
// the top, a half stroke and one pixel of antialiasing feather at the
// bottom, so neither edge of the stroke is cut by the item's own rect.
const qint64 curvePadDev = toDev(m_curveSmoothing ? 3.0 : 0.0, dpr);
const qint64 plotTopDev = lineDev / 2 + curvePadDev;
const qint64 plotBottomDev =
(std::max)(plotTopDev + 1, heightDev - (lineDev + 1) / 2 - 1);
const qint64 plotHDev = plotBottomDev - plotTopDev;
const qreal fillBottom = fromDev(heightDev, dpr);
QVector<QPointF> points;
points.reserve(len);
for (int i = 0; i < len; ++i) {
const qreal value =
qBound<qreal>(0.0, buffer->smoothedAt(i), m_maxValue);
const qreal raw = buffer->smoothedAt(i);
const qreal x = startX + i * step;
const qreal y = plotTop + (1.0 - (value / m_maxValue)) * plotH;
points.append(QPointF(x, y));
// A non-finite sample poisons the whole path: llround of it is undefined,
// and the stroke and fill then ignore everything after that vertex. Treat
// it as no traffic instead.
const qreal value =
std::isfinite(raw) ? qBound<qreal>(0.0, raw, m_maxValue) : 0.0;
// Vertices on a pixel column, and on the y grid the stroke width needs.
// Item-local, so the parent's placement cannot move the shape. A flat run
// then lands on whole rows; slopes are interpolated, not snapped.
const qreal y = fromDev(
plotTopDev + static_cast<qint64>(std::llround(
(1.0 - value / m_maxValue) *
static_cast<double>(plotHDev))),
dpr);
points.append(QPointF(
fromDev(startDev + qint64(i) * stepDev, dpr),
snapLine(y, lineDev, dpr)));
}
QPainterPath linePath;
linePath.moveTo(points[0]);
linePath.moveTo(points.first());
if (m_curveSmoothing && points.size() > 2) {
const qreal tension = 1.0;
const qreal k = tension / 6.0;
const int n = static_cast<int>(points.size());
for (int i = 0; i < points.size() - 1; ++i) {
const QPointF& p1 = points[i];
const QPointF& p2 = points[i + 1];
QPointF c1;
QPointF c2;
segmentControls(points, i, m_curveSmoothing, c1, c2);
for (int i = 0; i < n - 1; ++i) {
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;
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 {
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);
// One vertex per device pixel column, as Components/LineGraph.qml does: a
// segment spanning several columns would otherwise interpolate its edge
// across all of them. y stays continuous here - snapping it per column
// would step the slope one pixel at a time and read as a staircase.
const qint64 firstCol = std::llround(p1.x() * dpr) + 1;
const qint64 lastCol = std::llround(p2.x() * dpr) - 1;
for (qint64 col = firstCol; col <= lastCol; ++col) {
const qreal x = fromDev(col, dpr);
linePath.lineTo(x, cubicY(p1, c1, c2, p2, x));
}
linePath.lineTo(p2);
}
geom.stroke = linePath;
QPainterPath fillPath = linePath;
fillPath.lineTo(startX + (len - 1) * step, fillBottom);
fillPath.lineTo(startX, fillBottom);
fillPath.lineTo(points.last().x(), fillBottom);
fillPath.lineTo(points.first().x(), fillBottom);
fillPath.closeSubpath();
geom.fill = fillPath;
geom.hasData = true;
@@ -223,6 +338,12 @@ void SparklineContent::paint(QPainter* painter) {
painter->setRenderHint(QPainter::Antialiasing, true);
const qreal dpr = m_dpr > 0.0 ? m_dpr : 1.0;
const qreal lineWidth =
std::isfinite(m_lineWidth)
? fromDev((std::max)(qint64(1), toDev(m_lineWidth, dpr)), dpr)
: 1.0;
auto drawOne =
[&](const LineGeometry& geom, const QColor& color, qreal fillAlpha) {
if (!geom.hasData) return;
@@ -233,7 +354,7 @@ void SparklineContent::paint(QPainter* painter) {
painter->setBrush(fillColor);
painter->drawPath(geom.fill);
QPen pen(color, m_lineWidth);
QPen pen(color, lineWidth);
pen.setCapStyle(Qt::RoundCap);
pen.setJoinStyle(Qt::RoundJoin);
painter->setPen(pen);
@@ -32,6 +32,11 @@ class SparklineContent : public QQuickPaintedItem {
[[nodiscard]] qreal stepX() const;
protected:
// The stroke width, sample spacing and plot bounds are quantized in device
// pixels, so they have to be built with the ratio the window actually has.
void itemChange(ItemChange change, const ItemChangeData& value) override;
private:
struct LineGeometry {
QPainterPath stroke;
@@ -60,6 +65,7 @@ class SparklineContent : public QQuickPaintedItem {
qreal m_viewportW = 0.0;
qreal m_viewportH = 0.0;
qreal m_dpr = 1.0;
LineGeometry m_geom1;
LineGeometry m_geom2;
+9
View File
@@ -1,5 +1,6 @@
#include "wavyline.hpp"
#include "wavylinerenderer.hpp"
#include <QQuickWindow>
namespace ZShell::controls {
@@ -25,6 +26,14 @@ QCanvasPainterItemRenderer* WavyLine::createItemRenderer() const {
return new WavyLineRenderer;
}
void WavyLine::itemChange(ItemChange change, const ItemChangeData& value) {
QCanvasPainterItem::itemChange(change, value);
if (change == ItemSceneChange && value.window)
m_dpr = value.window->effectiveDevicePixelRatio();
else if (change == ItemDevicePixelRatioHasChanged && window())
m_dpr = window()->effectiveDevicePixelRatio();
}
int WavyLine::lineWidth() const {
return m_lineWidth;
}
+6
View File
@@ -103,6 +103,11 @@ class WavyLine : public QCanvasPainterItem {
[[nodiscard]] QCanvasPainterItemRenderer* createItemRenderer()
const override;
// Keeps the renderer's device pixel ratio current: strokes and sample
// spacing are quantized in device pixels, so they must be built with the
// ratio the window actually has.
void itemChange(ItemChange change, const ItemChangeData& value) override;
private:
friend class WavyLineRenderer;
@@ -118,6 +123,7 @@ class WavyLine : public QCanvasPainterItem {
qreal m_fullAngle;
qreal m_radius;
qreal m_value;
qreal m_dpr = 1.0;
};
} // namespace ZShell::controls
+101 -14
View File
@@ -1,6 +1,8 @@
#include "wavylinerenderer.hpp"
#include "wavyline.hpp"
#include <qcanvaspainter.h>
#include <algorithm>
#include <cmath>
namespace ZShell::controls {
@@ -19,13 +21,48 @@ void WavyLineRenderer::synchronizeData(QCanvasPainterItem* item) {
m_fullAngle = wavyLine->m_fullAngle;
m_radius = wavyLine->m_radius;
m_value = wavyLine->m_value;
m_dpr = wavyLine->m_dpr > 0.0 ? wavyLine->m_dpr : 1.0;
}
namespace {
qint64 toDev(qreal logical, qreal dpr) {
return std::llround(logical * dpr);
}
qreal fromDev(qint64 device, qreal dpr) {
return qreal(device) / dpr;
}
// A center coordinate whose device position shares the parity of the stroke's
// device width: an even width centered on a pixel boundary and an odd width
// centered on a pixel center are the two cases where both edges of the stroke
// land on whole device pixels. Floored, so the caller gets the same value from
// the same device size every time; a rounded half could flip between two grid
// points depending on floating point noise.
qreal snapCenterDev(qint64 sizeDev, qint64 widthDev) {
const double halfParity = (widthDev % 2) == 0 ? 0.0 : 0.5;
return std::floor(static_cast<double>(sizeDev) / 2.0 - halfParity)
+ halfParity;
}
qint64 snapAmplitude(qint64 widthDev, qreal multiplier) {
return std::llround(static_cast<double>(widthDev) * multiplier);
}
} // namespace
void WavyLineRenderer::paint(QCanvasPainter* painter) {
painter->clearRect(0, 0, width(), height());
// The stroke is quantized in the item's own device pixel space, never
// against its position: the parent layout already places the item on the
// device grid, so a local whole-device value stays whole-device wherever
// the item moves, and a move cannot change the painted path.
const qreal dpr = m_dpr;
m_lineWidthDev = (std::max)(qint64(1), toDev(m_lineWidth, dpr));
m_amplitudeDev = snapAmplitude(m_lineWidthDev, m_amplitudeMultiplier);
painter->setStrokeStyle(m_color);
painter->setLineWidth(m_lineWidth);
painter->setLineWidth(fromDev(m_lineWidthDev, dpr));
painter->setLineCap(QCanvasPainter::LineCap::Round);
if (m_pathType == WavyLine::Arc) {
@@ -36,18 +73,36 @@ void WavyLineRenderer::paint(QCanvasPainter* painter) {
}
void WavyLineRenderer::paintLinear(QCanvasPainter* painter) {
const auto amplitude = m_lineWidth * m_amplitudeMultiplier;
const qreal dpr = m_dpr;
const auto lineWidth = fromDev(m_lineWidthDev, dpr);
const auto phase = m_waveProgress * 2 * M_PI;
const auto centerY = height() / 2;
const qint64 heightDev = toDev(height(), dpr);
const qreal cyDev = snapCenterDev(heightDev, m_lineWidthDev);
const auto centerY = cyDev / dpr;
const auto len = m_fullLength > 0 ? m_fullLength : 1;
const auto start = m_lineWidth / 2.0;
const auto fullEnd = width() - m_lineWidth / 2.0;
const auto start = lineWidth / 2.0;
const auto fullEnd = width() - lineWidth / 2.0;
const auto drawEnd = start + (fullEnd - start) * m_value;
// Keep the crest inside the item's own texture, with one device pixel for
// the antialiasing feather, so a tight item cannot cut it flat.
const qint64 halfDev = (m_lineWidthDev + 1) / 2;
const qint64 roomDev = static_cast<qint64>(
std::floor((std::min)(cyDev, qreal(heightDev) - cyDev)));
const qint64 amplitudeDev = (std::max)(qint64(0), roomDev - halfDev - 1);
const auto amplitude =
fromDev((std::min)(m_amplitudeDev, amplitudeDev), dpr);
painter->beginPath();
bool first = true;
for (int x = m_lineWidth / 2; x <= drawEnd; ++x) {
// One vertex per device pixel column. A segment spanning several columns
// interpolates its edge across all of them, which is what makes a sloped
// stroke look soft rather than crisply anti-aliased.
const qint64 firstCol = toDev(start, dpr);
const qint64 lastCol = toDev(drawEnd, dpr);
for (qint64 col = firstCol; col <= lastCol; ++col) {
const auto x = fromDev(col, dpr);
const auto theta =
m_frequency * 2 * M_PI * (x + m_startX) / len + phase;
const auto waveY = centerY + amplitude * qSin(theta);
@@ -67,16 +122,44 @@ void WavyLineRenderer::paintArc(QCanvasPainter* painter) {
return;
}
const qreal dpr = m_dpr;
const auto amplitude = fromDev(m_amplitudeDev, dpr);
const auto startAngleRad = m_startAngle * M_PI / 180.0;
const auto fullAngleRad = m_fullAngle * M_PI / 180.0;
const auto amplitude = m_lineWidth * m_amplitudeMultiplier;
const auto cx = width() / 2.0;
const auto cy = height() / 2.0;
const auto radius =
m_radius > 0
? m_radius
: (qMin(width(), height()) - m_lineWidth - 2 * amplitude) / 2.0;
// The center comes from the item's own device size and the stroke parity,
// the same rule Components/CircularProgress.qml applies to its background
// Shape arc, so the two rings share one center.
const qint64 widthDev = toDev(width(), dpr);
const qint64 heightDev = toDev(height(), dpr);
const qreal cxDev = snapCenterDev(widthDev, m_lineWidthDev);
const qreal cyDev = snapCenterDev(heightDev, m_lineWidthDev);
const auto cx = cxDev / dpr;
const auto cy = cyDev / dpr;
// The path is painted into a texture exactly the item's size, so anything
// past its edge is cut flat. Keep the outer stroke edge, plus the wave
// crest, inside that rect and leave one device pixel for the antialiasing
// feather. Derived from the item's own size and its local center, so it
// does not depend on where the item is placed.
const qint64 halfDev = (m_lineWidthDev + 1) / 2;
const qint64 roomDev = static_cast<qint64>(
std::floor((std::min)((std::min)(cxDev, qreal(widthDev) - cxDev),
(std::min)(cyDev, qreal(heightDev) - cyDev))));
const qint64 maxRadiusDev = roomDev - m_amplitudeDev - halfDev - 1;
// The radius is a whole device pixel count. Derived from the item's own
// device size, so it does not depend on where the item is.
qint64 radiusDev;
if (m_radius > 0) {
radiusDev = toDev(m_radius, dpr);
} else {
const qint64 inner = (std::min)(widthDev, heightDev) - m_lineWidthDev -
2 * m_amplitudeDev;
radiusDev = (std::max)(qint64(1), inner / 2);
}
radiusDev = (std::max)(qint64(1), (std::min)(radiusDev, maxRadiusDev));
const auto radius = fromDev(radiusDev, dpr);
if (radius <= 0) {
return;
@@ -91,7 +174,11 @@ void WavyLineRenderer::paintArc(QCanvasPainter* painter) {
return;
}
const auto N = qMax(64, qCeil(radius * drawAngleRad));
// Sample every device pixel of arc length, not every logical one: at a
// fractional ratio a logical step is more than one device pixel wide, and
// the edge would then be interpolated across the pixels it crosses.
const auto N =
qMax(64, qCeil(static_cast<double>(toDev(radius, dpr)) * drawAngleRad));
const auto dTheta = drawAngleRad / N;
painter->beginPath();
@@ -27,6 +27,9 @@ class WavyLineRenderer final : public QCanvasPainterItemRenderer {
qreal m_fullAngle;
qreal m_radius;
qreal m_value;
qreal m_dpr = 1.0;
qint64 m_lineWidthDev = 4;
qint64 m_amplitudeDev = 0;
};
} // namespace ZShell::controls
+1
View File
@@ -18,6 +18,7 @@ class Notifs : public settings::ObjectNode {
CONFIG_PROPERTY(bool, actionOnClick, false)
CONFIG_PROPERTY(int, appNotifCooldown, 0)
CONFIG_PROPERTY(qreal, clearThreshold, 0.3)
CONFIG_PROPERTY(int, clearVelocity, 1000)
CONFIG_PROPERTY(int, defaultExpireTimeout, 5000)
CONFIG_PROPERTY(int, expandThreshold, 20)
CONFIG_PROPERTY(bool, expire, true)
+6
View File
@@ -29,3 +29,9 @@ qml_module(ZShell-internal
PkgConfig::GL
ZShell-config
)
qt_add_custom_brush_shaders(ZShell-internal "internal_brush_shaders"
PREFIX "/"
FILES
shaders/strokeholemask.frag
)
@@ -0,0 +1,22 @@
#version 440
QC_INCLUDE "customfrag.glsl"
float sdRoundRect(vec2 p, vec2 b, float r) {
vec2 q = abs(p) - b + vec2(r);
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
}
void main() {
float keep = 1.0;
if (data4.x > 0.5) {
vec2 p = fragCoord - data1.xy;
float feather = max(data3.y, 0.0001);
keep = smoothstep(-feather, feather, sdRoundRect(p, data1.zw, data3.x));
}
float a = keep * data2.w * globalAlpha * antialiasingAlpha();
fragColor = vec4(data2.rgb * a, a);
}
+39 -12
View File
@@ -71,11 +71,23 @@ static QCanvasPath buildStrokePath(
void StrokeCanvasItem::undoLastGroup() {
if (m_strokes.isEmpty()) return;
const int lastGroup = m_strokes.last().groupId;
QVector<Stroke> group;
for (int i = static_cast<int>(m_strokes.size()) - 1;
i >= 0 && m_strokes[i].groupId == lastGroup;
--i)
m_strokes.remove(i);
group.prepend(m_strokes.takeAt(i));
m_redoStack.append(group);
update();
}
void StrokeCanvasItem::redo() {
if (m_redoStack.isEmpty()) return;
m_strokes.append(m_redoStack.takeLast());
update();
}
@@ -106,19 +118,14 @@ void StrokeCanvasItem::setHoverPoint(const QPointF& point) {
emit hoverPointChanged();
}
void StrokeCanvasItem::showHover(qreal x, qreal y) {
const QPointF newPoint{x, y};
const bool pointChanged = (m_hoverPoint != newPoint);
const bool visibleChanged = !m_hoverVisible;
void StrokeCanvasItem::showHover() {
if (m_hoverVisible) return;
if (pointChanged || visibleChanged) {
m_hoverVisible = true;
m_hoverPoint = newPoint;
if (pointChanged) emit hoverPointChanged();
if (visibleChanged) emit hoverVisibleChanged();
update();
}
m_hoverVisible = true;
update();
emit hoverVisibleChanged();
}
void StrokeCanvasItem::hideHover() {
@@ -139,6 +146,24 @@ void StrokeCanvasItem::setPenWidth(qreal width) {
emit penWidthChanged();
}
void StrokeCanvasItem::setHole(const QRectF& hole) {
if (m_hole == hole) return;
m_hole = hole;
update();
emit holeChanged();
}
void StrokeCanvasItem::setHoleRadius(qreal radius) {
if (qFuzzyCompare(m_holeRadius, radius)) return;
m_holeRadius = radius;
update();
emit holeRadiusChanged();
}
void StrokeCanvasItem::beginStroke(qreal x, qreal y, bool polyline) {
m_isDrawing = true;
m_currentStroke.points.clear();
@@ -199,6 +224,7 @@ void StrokeCanvasItem::endStroke() {
m_currentStroke.points.clear();
m_strokes.append(m_currentStroke);
m_currentStroke = {};
m_redoStack.clear();
update();
}
@@ -206,6 +232,7 @@ void StrokeCanvasItem::endStroke() {
void StrokeCanvasItem::clear() {
m_strokes.clear();
m_currentStroke = {};
m_redoStack.clear();
update();
}
+16 -1
View File
@@ -3,6 +3,7 @@
#include <QCanvasPainterItem>
#include <QColor>
#include <QPointF>
#include <QRectF>
#include <QVector>
#include <qcolor.h>
#include <qcontainerfwd.h>
@@ -27,6 +28,10 @@ class StrokeCanvasItem : public QCanvasPainterItem {
hoverPointChanged)
Q_PROPERTY(
qreal penWidth READ penWidth WRITE setPenWidth NOTIFY penWidthChanged)
Q_PROPERTY(QRectF hole READ hole WRITE setHole NOTIFY holeChanged)
Q_PROPERTY(
qreal holeRadius READ holeRadius WRITE setHoleRadius NOTIFY
holeRadiusChanged)
public:
explicit StrokeCanvasItem(QQuickItem* parent = nullptr);
@@ -37,15 +42,20 @@ class StrokeCanvasItem : public QCanvasPainterItem {
void setHoverVisible(bool visible);
void setHoverPoint(const QPointF& point);
Q_INVOKABLE void showHover(qreal x, qreal y);
Q_INVOKABLE void showHover();
Q_INVOKABLE void hideHover();
Q_INVOKABLE void undoLastGroup();
Q_INVOKABLE void redo();
[[nodiscard]] QColor penColor() const { return m_penColor; }
[[nodiscard]] qreal penWidth() const { return m_penWidth; }
[[nodiscard]] QRectF hole() const { return m_hole; }
[[nodiscard]] qreal holeRadius() const { return m_holeRadius; }
void setPenColor(const QColor& color);
void setPenWidth(qreal width);
void setHole(const QRectF& hole);
void setHoleRadius(qreal radius);
Q_INVOKABLE void clear();
@@ -56,6 +66,8 @@ class StrokeCanvasItem : public QCanvasPainterItem {
signals:
void penColorChanged();
void penWidthChanged();
void holeChanged();
void holeRadiusChanged();
void hoverVisibleChanged();
void hoverPointChanged();
@@ -72,9 +84,12 @@ class StrokeCanvasItem : public QCanvasPainterItem {
qreal m_penWidth = 4.0;
bool m_isDrawing = false;
QRectF m_hole;
qreal m_holeRadius = 0.0;
int m_nextGroupId = 0;
QVector<Stroke> m_strokes;
Stroke m_currentStroke;
QVector<QVector<Stroke>> m_redoStack;
};
}; // namespace ZShell::internal
+104 -11
View File
@@ -1,11 +1,98 @@
#include "strokecanvasrenderer.hpp"
#include "strokecanvasitem.hpp"
#include <QCanvasPainter>
#include <QQuickWindow>
#include <QVector4D>
#include <qcolor.h>
#include <QSet>
namespace ZShell::internal {
static void drawStroke(
using Qt::StringLiterals::operator""_s;
static const QString kStrokeHoleShader = u":/shaders/strokeholemask.frag.qsb"_s;
namespace {
struct HoleUniforms {
QVector4D data1; // center.xy, halfSize.xy
QVector4D data3; // x: radius, y: feather in logical px
QVector4D data4; // x: hole enabled
};
static HoleUniforms holeUniforms(
const QRectF& hole, qreal radius, qreal devicePixelRatio) {
const bool active = hole.isValid() && hole.width() > 0 && hole.height() > 0;
if (!active) return {QVector4D(), QVector4D(), QVector4D()};
const QPointF center = hole.center();
const QPointF half(hole.width() * 0.5, hole.height() * 0.5);
const qreal maxRadius = qMin(half.x(), half.y());
return {
QVector4D(
static_cast<float>(center.x()),
static_cast<float>(center.y()),
static_cast<float>(half.x()),
static_cast<float>(half.y())),
QVector4D(
static_cast<float>(qMin(radius, maxRadius)),
static_cast<float>(1.0 / devicePixelRatio),
0.0f,
0.0f),
QVector4D(1.0, 0.0, 0.0, 0.0),
};
}
static void applyHole(QCanvasCustomBrush& brush, const HoleUniforms& u) {
brush.setData1(u.data1);
brush.setData3(u.data3);
brush.setData4(u.data4);
}
}; // namespace
void StrokeCanvasRenderer::updateBrushHole() {
const HoleUniforms uniforms =
holeUniforms(m_hole, m_holeRadius, m_devicePixelRatio);
if (uniforms.data1 == m_lastHoleData1 &&
uniforms.data3 == m_lastHoleData3 && uniforms.data4 == m_lastHoleData4)
return;
m_lastHoleData1 = uniforms.data1;
m_lastHoleData3 = uniforms.data3;
m_lastHoleData4 = uniforms.data4;
++m_holeToken;
for (auto& entry : m_brushes) {
if (entry.holeToken == m_holeToken) continue;
applyHole(entry.brush, uniforms);
entry.holeToken = m_holeToken;
}
}
QCanvasCustomBrush& StrokeCanvasRenderer::brushFor(const QColor& color) {
const QRgb key = color.rgba();
if (auto it = m_brushes.find(key); it != m_brushes.end()) return it->brush;
const HoleUniforms uniforms =
holeUniforms(m_hole, m_holeRadius, m_devicePixelRatio);
BrushEntry entry;
entry.brush.setFragmentShader(kStrokeHoleShader);
applyHole(entry.brush, uniforms);
entry.brush.setData2(
QVector4D(color.redF(), color.greenF(), color.blueF(), color.alphaF()));
entry.holeToken = m_holeToken;
return m_brushes.insert(key, entry)->brush;
}
void StrokeCanvasRenderer::drawStroke(
QCanvasPainter* painter,
const QVector<QPointF>& points,
const QColor& color,
@@ -13,8 +100,10 @@ static void drawStroke(
bool polyline) {
if (points.isEmpty()) return;
painter->setStrokeStyle(color);
painter->setFillStyle(color);
QCanvasCustomBrush& brush = brushFor(color);
painter->setStrokeStyle(brush);
painter->setFillStyle(brush);
painter->setLineWidth(width);
painter->setLineCap(QCanvasPainter::LineCap::Round);
painter->setLineJoin(QCanvasPainter::LineJoin::Round);
@@ -65,7 +154,7 @@ static void drawStroke(
painter->stroke();
}
static void drawHoverCursor(
void StrokeCanvasRenderer::drawHoverCursor(
QCanvasPainter* painter,
const QPointF& point,
qreal penWidth,
@@ -74,7 +163,7 @@ static void drawHoverCursor(
const qreal radius = penWidth * 0.5;
if (isDrawing) {
painter->setFillStyle(penColor);
painter->setFillStyle(brushFor(penColor));
painter->beginPath();
painter->circle(point, radius);
painter->fill();
@@ -85,7 +174,7 @@ static void drawHoverCursor(
const bool useDashes = penWidth > 10.0;
auto drawOutline = [&](const QColor& color, qreal width) {
painter->setStrokeStyle(color);
painter->setStrokeStyle(brushFor(color));
painter->setLineWidth(width);
painter->setLineCap(QCanvasPainter::LineCap::Round);
@@ -117,7 +206,7 @@ static void drawHoverCursor(
};
auto drawCrosshair = [&](const QColor& color, qreal width) {
painter->setStrokeStyle(color);
painter->setStrokeStyle(brushFor(color));
painter->setLineWidth(width);
painter->setLineCap(QCanvasPainter::LineCap::Round);
@@ -148,8 +237,11 @@ void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem* item) {
m_penColor = canvas->m_penColor;
m_penWidth = canvas->m_penWidth;
m_hole = canvas->m_hole;
m_holeRadius = canvas->m_holeRadius;
m_devicePixelRatio =
canvas->window() ? canvas->window()->effectiveDevicePixelRatio() : 1.0;
// Detect removed strokes: present in renderer but no longer in canvas
QSet<int> canvasGroupIds;
for (const auto& stroke : canvas->m_strokes)
canvasGroupIds.insert(stroke.groupId);
@@ -162,7 +254,6 @@ void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem* item) {
}
}
// Detect added strokes: present in canvas but not yet in renderer
QSet<int> rendererGroupIds;
for (const auto& stroke : m_strokes)
rendererGroupIds.insert(stroke.groupId);
@@ -180,6 +271,8 @@ void StrokeCanvasRenderer::synchronizeData(QCanvasPainterItem* item) {
}
void StrokeCanvasRenderer::paint(QCanvasPainter* painter) {
updateBrushHole();
for (int id : m_pendingGroupRemovals)
painter->removePathGroup(id);
m_pendingGroupRemovals.clear();
@@ -187,8 +280,8 @@ void StrokeCanvasRenderer::paint(QCanvasPainter* painter) {
painter->clearRect(0, 0, width(), height());
for (const auto& stroke : m_strokes) {
painter->setStrokeStyle(stroke.color);
painter->setFillStyle(stroke.color);
painter->setStrokeStyle(brushFor(stroke.color));
painter->setFillStyle(brushFor(stroke.color));
painter->setLineWidth(stroke.width);
painter->setLineCap(QCanvasPainter::LineCap::Round);
painter->setLineJoin(QCanvasPainter::LineJoin::Round);
@@ -1,26 +1,58 @@
#pragma once
#include <QCanvasCustomBrush>
#include <QCanvasPainterItemRenderer>
#include <QHash>
#include <QRectF>
#include <QVector4D>
#include <qcontainerfwd.h>
#include "stroke.hpp"
namespace ZShell::internal {
struct BrushEntry {
QCanvasCustomBrush brush;
qint64 holeToken = -1; // hole generation this brush was built with
};
class StrokeCanvasRenderer final : public QCanvasPainterItemRenderer {
public:
void synchronizeData(QCanvasPainterItem* item) override;
void paint(QCanvasPainter* painter) override;
private:
QCanvasCustomBrush& brushFor(const QColor& color);
void updateBrushHole();
void drawStroke(
QCanvasPainter* painter,
const QVector<QPointF>& points,
const QColor& color,
qreal width,
bool polyline);
void drawHoverCursor(
QCanvasPainter* painter,
const QPointF& point,
qreal penWidth,
QColor penColor,
bool isDrawing);
QColor m_penColor;
qreal m_penWidth = 4.0;
bool m_hoverVisible = false;
QPointF m_hoverPoint;
bool m_isDrawing = false;
QRectF m_hole;
qreal m_holeRadius = 0.0;
qreal m_devicePixelRatio = 1.0;
QVector4D m_lastHoleData1;
QVector4D m_lastHoleData3;
QVector4D m_lastHoleData4;
qint64 m_holeToken = 0;
QVector<Stroke> m_strokes;
Stroke m_currentStroke;
QVector<int> m_pendingGroupRemovals;
QHash<QRgb, BrushEntry> m_brushes;
};
}; // namespace ZShell::internal
+3
View File
@@ -23,11 +23,14 @@ qml_module(ZShell-services
lyriccandidate.hpp lyriccandidate.cpp
lyrics.hpp lyrics.cpp
dropimporter.hpp dropimporter.cpp
notificationstore.hpp notificationstore.cpp
LIBRARIES
Qt6::Core
Qt6::Qml
Qt6::Quick
Qt6::Concurrent
Qt6::Network
Qt6::Sql
PkgConfig::Pipewire
PkgConfig::Aubio
PkgConfig::Cava
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,370 @@
#pragma once
#include <QDateTime>
#include <QHash>
#include <QJSValue>
#include <QList>
#include <QObject>
#include <QPointer>
#include <qqmlintegration.h>
#include <QQmlListProperty>
#include <QSet>
#include <QString>
#include <QTimer>
namespace ZShell::config {
class Notifs;
}
namespace ZShell::Services {
class NotificationGroup;
class NotificationStore;
class NotifImageProvider;
class NotificationActionRow : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE(
"NotificationActionRow instances come from a NotificationStore")
Q_PROPERTY(bool invokable READ invokable NOTIFY invokableChanged)
Q_PROPERTY(QString identifier READ identifier CONSTANT)
Q_PROPERTY(QString text READ text CONSTANT)
public:
NotificationActionRow(
QObject* action,
const QString& identifier,
const QString& text,
QObject* parent = nullptr);
[[nodiscard]] bool invokable() const;
[[nodiscard]] QString identifier() const;
[[nodiscard]] QString text() const;
Q_INVOKABLE void invoke();
signals:
void invokableChanged();
private:
QPointer<QObject> m_action;
QString m_identifier;
QString m_text;
};
class NotificationRow : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("NotificationRow instances come from a NotificationStore")
Q_PROPERTY(
QQmlListProperty<NotificationActionRow> actions READ actions NOTIFY
actionsChanged)
Q_PROPERTY(QString appIcon READ appIcon NOTIFY appIconChanged)
Q_PROPERTY(QString appName READ appName NOTIFY appNameChanged)
Q_PROPERTY(QString body READ body NOTIFY bodyChanged)
Q_PROPERTY(bool closed READ closed NOTIFY closedChanged)
Q_PROPERTY(
bool hasActionIcons READ hasActionIcons NOTIFY hasActionIconsChanged)
Q_PROPERTY(QString image READ image NOTIFY imageChanged)
Q_PROPERTY(QString imageSource READ imageSource NOTIFY imageSourceChanged)
Q_PROPERTY(int notificationId READ notificationId CONSTANT)
Q_PROPERTY(int progress READ progress NOTIFY progressChanged)
Q_PROPERTY(bool popup READ popup WRITE setPopup NOTIFY popupChanged)
Q_PROPERTY(bool resident READ resident NOTIFY residentChanged)
Q_PROPERTY(QString summary READ summary NOTIFY summaryChanged)
Q_PROPERTY(QDateTime time READ time CONSTANT)
Q_PROPERTY(QString timeStr READ timeStr NOTIFY timeStrChanged)
Q_PROPERTY(int urgency READ urgency NOTIFY urgencyChanged)
public:
explicit NotificationRow(QObject* parent = nullptr);
[[nodiscard]] QQmlListProperty<NotificationActionRow> actions();
[[nodiscard]] QString appIcon() const;
[[nodiscard]] QString appName() const;
[[nodiscard]] QString body() const;
[[nodiscard]] bool closed() const;
[[nodiscard]] bool hasActionIcons() const;
[[nodiscard]] QString image() const;
[[nodiscard]] QString imageSource() const;
[[nodiscard]] int notificationId() const;
[[nodiscard]] int progress() const;
[[nodiscard]] bool popup() const;
void setPopup(bool popup);
[[nodiscard]] bool resident() const;
[[nodiscard]] QString summary() const;
[[nodiscard]] QDateTime time() const;
[[nodiscard]] QString timeStr() const;
[[nodiscard]] int urgency() const;
Q_INVOKABLE void close();
Q_INVOKABLE void dismiss();
Q_INVOKABLE void dismissPopup();
Q_INVOKABLE void holdPopup();
Q_INVOKABLE bool isSameAppIcon(const QString& path) const;
Q_INVOKABLE bool isSameImage(const QString& path) const;
Q_INVOKABLE void lock(QObject* item);
Q_INVOKABLE void pausePopup();
Q_INVOKABLE void ref(QObject* item);
Q_INVOKABLE void releasePopup();
Q_INVOKABLE void resumePopup();
Q_INVOKABLE void unref(QObject* item);
Q_INVOKABLE void unlock(QObject* item);
signals:
void actionsChanged();
void appIconChanged();
void appNameChanged();
void bodyChanged();
void closedChanged();
void hasActionIconsChanged();
void imageChanged();
void imageSourceChanged();
void popupChanged();
void progressChanged();
void residentChanged();
void summaryChanged();
void timeStrChanged();
void urgencyChanged();
private:
friend class NotificationGroup;
friend NotificationStore;
[[nodiscard]] bool expired(qint64 now) const;
void refreshTimeStr();
QList<NotificationActionRow*> m_actions;
NotificationGroup* m_group = nullptr;
NotificationGroup* m_popupGroup = nullptr;
NotificationStore* m_store = nullptr;
QString m_appIcon;
QString m_appName;
QString m_body;
QString m_digest;
QString m_image;
QString m_imageSource;
QString m_summary;
QString m_timeStr;
qint64 m_deadline = 0;
qint64 m_rowId = -1;
qint64 m_timeMs = 0;
QPointer<QObject> m_notification;
QSet<QObject*> m_locks;
QJSValue m_jsNotification;
// The store has already dropped this row from its lists, but a delegate is
// still animating it out; the delete waits for the last unlock.
bool m_deleteAfterUnlock = false;
bool m_closed = false;
bool m_hasActionIcons = false;
bool m_liveConnected = false;
bool m_popup = false;
bool m_popupPaused = false;
bool m_resident = false;
int m_notificationId = 0;
int m_progress = 0;
int m_urgency = 1;
};
class NotificationGroup : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("NotificationGroup instances come from a NotificationStore")
Q_PROPERTY(
QQmlListProperty<NotificationRow> activeNotifs READ activeNotifs NOTIFY
activeNotifsChanged)
Q_PROPERTY(QString appIcon READ appIcon NOTIFY appIconChanged)
Q_PROPERTY(QString appName READ appName CONSTANT)
Q_PROPERTY(int count READ count NOTIFY countChanged)
Q_PROPERTY(QString firstSummary READ firstSummary NOTIFY firstSummaryChanged)
Q_PROPERTY(QString image READ image NOTIFY imageChanged)
Q_PROPERTY(
QQmlListProperty<NotificationRow> notifs READ notifs NOTIFY
notifsChanged)
Q_PROPERTY(QString timeStr READ timeStr NOTIFY timeStrChanged)
Q_PROPERTY(int urgency READ urgency NOTIFY urgencyChanged)
public:
NotificationGroup(const QString& appName, QObject* parent = nullptr);
[[nodiscard]] QQmlListProperty<NotificationRow> activeNotifs();
[[nodiscard]] QString appIcon() const;
[[nodiscard]] QString appName() const;
[[nodiscard]] int count() const;
[[nodiscard]] QString firstSummary() const;
[[nodiscard]] QString image() const;
[[nodiscard]] QQmlListProperty<NotificationRow> notifs();
[[nodiscard]] QString timeStr() const;
[[nodiscard]] int urgency() const;
Q_INVOKABLE bool isSameAppIcon(const QString& path) const;
Q_INVOKABLE bool isSameImage(const QString& path) const;
Q_INVOKABLE void lock(QObject* item);
Q_INVOKABLE void unlock(QObject* item);
signals:
void activeNotifsChanged();
void appIconChanged();
void countChanged();
void firstSummaryChanged();
void imageChanged();
void notifsChanged();
void timeStrChanged();
void urgencyChanged();
private:
friend NotificationStore;
void addRow(NotificationRow* row);
void refreshActive();
void release();
void removeRow(NotificationRow* row);
void sync();
QList<NotificationRow*> m_active;
QList<NotificationRow*> m_notifs;
// The group reads image/appIcon through the row it took them from, so a
// row swapping between its raw source and the cached blob keeps the card
// in sync instead of holding a stale copy.
QPointer<NotificationRow> m_iconSource;
QPointer<NotificationRow> m_imageSource;
QSet<QObject*> m_locks;
QString m_appName;
QString m_firstSummary;
QString m_timeStr;
int m_count = 0;
int m_urgency = 1;
bool m_releasing = false;
};
class NotificationStore : public QObject {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(
bool autoExpire READ autoExpire WRITE setAutoExpire NOTIFY
autoExpireChanged)
Q_PROPERTY(
QQmlListProperty<NotificationGroup> groups READ groups NOTIFY
groupsChanged)
Q_PROPERTY(
QQmlListProperty<NotificationGroup> popupGroups READ popupGroups NOTIFY
popupGroupsChanged)
Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED)
Q_PROPERTY(
int popupTimeout READ popupTimeout WRITE setPopupTimeout NOTIFY
popupTimeoutChanged)
Q_PROPERTY(
QQmlListProperty<NotificationRow> rows READ rows NOTIFY rowsChanged)
public:
explicit NotificationStore(QObject* parent = nullptr);
~NotificationStore() override;
[[nodiscard]] QString path() const;
void setPath(const QString& path);
[[nodiscard]] bool autoExpire() const;
void setAutoExpire(bool autoExpire);
[[nodiscard]] QQmlListProperty<NotificationGroup> groups();
[[nodiscard]] QQmlListProperty<NotificationGroup> popupGroups();
[[nodiscard]] int popupTimeout() const;
void setPopupTimeout(int timeout);
[[nodiscard]] QQmlListProperty<NotificationRow> rows();
Q_INVOKABLE NotificationRow* insert(
const QJSValue& notification, bool popup);
Q_INVOKABLE void clear();
signals:
void autoExpireChanged();
void groupsChanged();
void pathChanged();
void popupGroupsChanged();
void popupTimeoutChanged();
void rowsChanged();
private slots:
void handleLiveUpdate();
private:
friend NotificationRow;
void attachNotification(
NotificationRow* row, QObject* notification, const QJSValue& js);
void addGroup(NotificationRow* row);
void applyNotifConfig(const config::Notifs* notifs);
void closeDb();
void copyFields(NotificationRow* row, QObject* notification);
void flush();
void handleClosed(NotificationRow* row);
void insertDbRow(NotificationRow* row);
void loadRows();
void markDirty(NotificationRow* row);
void migrateLegacyJson();
[[nodiscard]] bool openDb();
void orphanCleanup();
void prune();
void addPopupGroup(NotificationRow* row);
void removePopupGroupRow(NotificationRow* row);
void updatePopupGroup(NotificationRow* row);
void registerProvider();
void removeGroupRow(NotificationRow* row);
void removeRow(NotificationRow* row);
// Coalesced rowsChanged: a burst of inserts/removes triggers one QML
// rebind on the next event loop turn instead of one per notification.
void scheduleRowsChanged();
[[nodiscard]] NotificationRow* rowForNotificationId(int id) const;
void startImageCache(NotificationRow* row);
void storeImageBlob(
const QPointer<NotificationRow>& row,
const QString& digest,
const QByteArray& png);
void tick();
void trimRows();
void updateDbRow(NotificationRow* row);
static QList<NotificationActionRow*> parseActions(
const QJSValue& actions, NotificationRow* row);
int m_popupTimeout = 5000;
qint64 m_nextPrune = 0;
qint64 m_nextTimeStrRefresh = 0;
bool m_autoExpire = true;
bool m_clearing = false;
bool m_rowsSignalPending = false;
QString m_uuid;
QString m_path;
QList<NotificationGroup*> m_groups;
QList<NotificationGroup*> m_popupGroups;
QList<NotificationRow*> m_rows;
QHash<QObject*, NotificationRow*> m_liveRows;
QHash<QString, NotificationGroup*> m_groupsByName;
QHash<QString, NotificationGroup*> m_popupGroupsByName;
QSet<NotificationRow*> m_dirty;
NotifImageProvider* m_provider = nullptr;
QTimer* m_flush = nullptr;
QTimer* m_tick = nullptr;
};
} // namespace ZShell::Services
+7
View File
@@ -0,0 +1,7 @@
function(qt_add_shaders target name)
set(options BATCHABLE OPTIMIZED NOHLSL NOMSL PRECOMPILE)
set(oneValueArgs GLSL HLSL MSL PREFIX BASE OUTPUT_TARGETS)
set(multiValueArgs FILES DEFINES OUTPUTS)
cmake_parse_arguments(_ "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN})
endfunction()
+9 -7
View File
@@ -545,7 +545,7 @@ def generate(
except (PermissionError, OSError, BlockingIOError):
pass
def smart_mode(image_path: Path) -> str:
def smart_mode(image_path: Path | io.BytesIO) -> str:
is_dark = ""
with Image.open(image_path) as img:
@@ -739,7 +739,7 @@ def generate(
return rendered_outputs
def seed_from_image(image_path: Path) -> Hct:
def seed_from_image(image_path: Path | io.BytesIO) -> Hct:
image = Image.open(image_path)
pixel_len = image.width * image.height
image_data = image.getdata()
@@ -837,11 +837,13 @@ def generate(
image_path = image_path or Path(WALL_PATH)
blob = scheme_thumb_from_db(str(DB_PATH), str(image_path))
if blob is not None:
open_thumb = lambda: io.BytesIO(blob)
else:
thumb_path = generate_thumbnail(image_path, THUMB_DIR)
open_thumb = lambda: thumb_path
def open_thumb():
if blob is not None:
return io.BytesIO(blob)
else:
thumb_path = generate_thumbnail(image_path, THUMB_DIR)
return thumb_path
seed = seed_from_image(open_thumb())
name = "dynamic"
+598
View File
@@ -0,0 +1,598 @@
#!/usr/bin/env python3
"""Generate a searchable preview page for installed Material Symbols fonts.
Usage:
symbols-preview.py [out.html] [--styles rounded,outlined,sharp] [--source url|local|embed]
--source local (the default) uses local() only: nothing extra on disk, and all four
axes apply, because ~/.config/fontconfig/conf.d/90-material-symbols-variable.conf
re-scans the installed fonts with "variable: True" so Firefox stops treating the
local() match as static. Without that fontconfig edit, Firefox maps only `wght`.
local() matches the full font name (name ID 4) and PostScript name (ID 6), NOT the
family name, which is why local("Material Symbols Rounded") silently fails.
--source url writes one subsetted variable WOFF2 per style next to the HTML and
loads them with url(). Webfonts apply all four axes regardless of the fontconfig
flag, so this is the fallback if the advertising is ever removed.
--source embed puts the subset in the page as a data URL: self-contained, ~2 MB/style.
"""
import argparse
import base64
import json
import os
import subprocess
import tempfile
from fontTools import ttLib
STYLES = ("rounded", "outlined", "sharp")
FAMILY = {"rounded": "Material Symbols Rounded", "outlined": "Material Symbols Outlined", "sharp": "Material Symbols Sharp"}
FEATURES = "rlig,rclt"
def installed():
"""Return {style: ttf path} for every installed Material Symbols style."""
out = subprocess.run(["fc-list", "--format", "%{file}|%{family}\n"], capture_output=True, text=True).stdout
found = {}
for line in out.splitlines():
path, _, families = line.partition("|")
for style, family in FAMILY.items():
if style in found:
continue
if family in families and path.strip().endswith(".ttf"):
found[style] = path.strip()
return found
def names_of(font):
"""Icon name -> codepoint, read from the font's rlig/rclt ligature lookups."""
cmap = {}
for table in font["cmap"].tables:
if table.isUnicode():
cmap.update(table.cmap)
rev = {g: u for u, g in cmap.items()}
names = {}
for lookup in font["GSUB"].table.LookupList.Lookup:
for sub in lookup.SubTable:
root = sub.ExtSubTable if lookup.LookupType == 7 else sub
ligatures = getattr(root, "ligatures", None)
if not ligatures:
continue
for first, entries in ligatures.items():
for lig in entries:
comps = (first,) + tuple(lig.Component)
if any(c not in rev for c in comps):
continue
code = rev.get(lig.LigGlyph)
if code is not None and code >= 0xE000:
names["".join(chr(rev[c]) for c in comps)] = code
return names, cmap, rev
def local_names(font):
"""local() matches full font name (id 4) and PostScript name (id 6), not family."""
name = font["name"]
def pick(name_id):
for record in name.names:
if record.nameID == name_id and record.platformID in (1, 3):
return record.toUnicode()
return None
return [n for n in (pick(4), pick(6), pick(1)) if n]
def axes_of(font):
if "fvar" not in font:
return {}
return {a.axisTag: (a.minValue, a.defaultValue, a.maxValue) for a in font["fvar"].axes}
# display order; any axis not listed still gets a slider, appended after these
AXIS_ORDER = ("FILL", "wght", "GRAD", "opsz")
def axis_sliders(axes):
"""Return (slider markup, JS axis specs) driven by the font's own fvar table."""
order = [t for t in AXIS_ORDER if t in axes] + [t for t in axes if t not in AXIS_ORDER]
html, js = [], {}
for tag in order:
lo, def_, hi = axes[tag]
integral = lo.is_integer() and hi.is_integer() and def_.is_integer()
# narrow-range axes (e.g. FILL 0..1) need fractional steps to be useful;
# wide axes stay at whole-number steps
step = 0.01 if hi - lo <= 2 else (1 if integral else 0.01)
sid = tag.lower()
html.append(
f' <div class="slider"><span>{tag}</span>'
f'<input id="{sid}" type="range"><output id="{sid}v"></output></div>'
)
js[sid] = {"tag": tag, "min": lo if integral else round(lo, 2),
"max": hi if integral else round(hi, 2),
"def": def_ if integral else round(def_, 2),
"step": step}
return "\n".join(html), js
def subset(font_path, glyphs, out_path):
tmp = tempfile.mkdtemp()
glyphs_file = os.path.join(tmp, "glyphs.txt")
open(glyphs_file, "w").write(",".join(glyphs))
subprocess.run(
["pyftsubset", font_path, f"--glyphs-file={glyphs_file}",
f"--layout-features={FEATURES}", "--flavor=woff2", f"--output-file={out_path}"],
check=True,
)
return os.path.getsize(out_path)
def all_glyphs(font, rev):
keep = {g for g, u in rev.items() if chr(u).isalnum() or chr(u) == "_"}
for lookup in font["GSUB"].table.LookupList.Lookup:
for sub in lookup.SubTable:
root = sub.ExtSubTable if lookup.LookupType == 7 else sub
ligatures = getattr(root, "ligatures", None)
if not ligatures:
continue
for first, entries in ligatures.items():
keep.add(first)
for lig in entries:
keep.add(lig.LigGlyph)
keep.update(lig.Component)
return sorted(keep)
TEMPLATE = """<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Material Symbols — {count} icons</title>
<style>
{font_faces}
:root {{
--pad-max: 28px;
--cell-min: 112px;
--gap: 8px;
--cols-min: 4;
--grid-min: calc(var(--cols-min) * (var(--cell-min) + var(--gap)) - var(--gap));
--sidebar: 260px;
/* padding shrinks with the available width and reaches 0 exactly where reflow starts.
100% resolves against .grid/.top's containing block (main), which already excludes
the sidebar, so the sidebar is not subtracted here. */
--pad: clamp(0px, calc((100% - var(--grid-min)) / 2), var(--pad-max));
--sym: "Sym-Rounded";
--bg: #121214; --panel: #1a1a1e; --line: #2b2b31; --fg: #ececec; --dim: #a2a2ac;
--accent: #8ab4f8; --hover: #222228;
}}
html[data-theme="light"] {{
--bg: #fafafa; --panel: #ffffff; --line: #e2e2e6; --fg: #1c1c1f; --dim: #6a6a72;
--accent: #1a73e8; --hover: #f0f0f3;
}}
* {{ box-sizing: border-box; }}
body {{ margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.45 system-ui, sans-serif; }}
/* sidebar */
aside {{
position: fixed; inset: 0 auto 0 0; width: 260px; background: var(--panel);
border-right: 1px solid var(--line); padding: 18px 16px 24px; overflow-y: auto; z-index: 20;
transition: transform .22s ease;
}}
aside h1 {{ font-size: 15px; margin: 0 0 2px; }}
aside .sub {{ font-size: 12px; color: var(--dim); margin: 0 0 18px; }}
.group {{ margin: 0 0 20px; }}
.group > label {{ display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .06em;
color: var(--dim); margin: 0 0 8px; }}
.seg {{ display: flex; gap: 4px; background: var(--bg); border: 1px solid var(--line);
border-radius: 10px; padding: 4px; }}
.seg button {{ flex: 1; padding: 7px 0; border: 0; border-radius: 7px; background: transparent;
color: var(--dim); font: inherit; font-size: 13px; cursor: pointer; }}
.seg button[aria-pressed="true"] {{ background: var(--accent); color: #fff; }}
html[data-theme="light"] .seg button[aria-pressed="true"] {{ color: #fff; }}
.slider {{ display: grid; grid-template-columns: 46px 1fr 42px; align-items: center; gap: 10px;
margin-bottom: 12px; font-size: 12px; color: var(--dim); }}
.slider output {{ text-align: right; color: var(--fg); font-variant-numeric: tabular-nums; }}
input[type=range] {{ accent-color: var(--accent); width: 100%; }}
.row {{ display: flex; align-items: center; justify-content: space-between; gap: 10px;
font-size: 12px; color: var(--dim); margin-bottom: 10px; }}
.ghost {{ padding: 7px 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg);
color: var(--fg); font: inherit; font-size: 13px; cursor: pointer; }}
.ghost:hover {{ background: var(--hover); }}
/* hide sidebar on narrow viewports, reveal via toggle */
#menu {{ display: none; }}
@media (max-width: 900px) {{
:root {{ --sidebar: 0px; }}
aside {{ transform: translateX(-100%); box-shadow: 0 0 40px rgba(0,0,0,.35); }}
body.nav-open aside {{ transform: none; }}
#menu {{ display: inline-block; }}
}}
/* content */
main {{ margin-left: var(--sidebar); }}
.top {{ position: sticky; top: 0; z-index: 10; background: var(--bg);
padding: 14px var(--pad);
display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); }}
.top input {{ flex: 1; min-width: 0; padding: 9px 12px; background: var(--panel); color: var(--fg);
border: 1px solid var(--line); border-radius: 9px; font: inherit; }}
.count {{ font-size: 12px; color: var(--dim); white-space: nowrap; }}
.pager {{ display: flex; align-items: center; gap: 6px; }}
.pager.bottom {{ justify-content: center; padding: 6px var(--pad) 30px; }}
.page {{ font-size: 12px; color: var(--dim); min-width: 64px; text-align: center;
font-variant-numeric: tabular-nums; }}
.ghost:disabled {{ opacity: .35; cursor: default; }}
/* padding shrinks with the viewport until it hits 0; only then does the grid reflow */
.grid {{ display: grid; gap: var(--gap); grid-template-columns: repeat(auto-fill, minmax(var(--cell-min), 1fr));
padding: 16px var(--pad) 28px; }}
.cell {{ display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 12px 4px;
border: 1px solid var(--line); border-radius: 12px; background: var(--panel); cursor: pointer;
transition: border-color .15s, background .15s; }}
.cell:hover {{ border-color: var(--accent); background: var(--hover); }}
.icon {{ font-family: var(--sym); font-feature-settings: "rlig" 1, "rclt" 1; line-height: 1; }}
.name {{ font-size: 11px; color: var(--dim); text-align: center; word-break: break-all; }}
.cp {{ font-size: 10px; color: var(--accent); opacity: .8; display: none; }}
html[data-cp="1"] .cp {{ display: block; }}
.cell.miss {{ opacity: .3; cursor: default; }}
#toast {{ position: fixed; right: 16px; bottom: 16px; background: var(--panel); color: var(--fg);
border: 1px solid var(--line); border-radius: 9px; padding: 8px 12px; font-size: 12px;
opacity: 0; transition: opacity .2s; pointer-events: none; }}
#toast.show {{ opacity: 1; }}
</style>
</head>
<body>
<aside>
<h1>Material Symbols</h1>
<p class="sub">{count} icons · styles: {style_list}</p>
<div class="group">
<label>Style</label>
<div class="seg" id="styles">{style_buttons}</div>
</div>
<div class="group">
<label>Variable axes</label>
{axis_sliders}
</div>
<div class="group">
<label>Layout</label>
<div class="slider"><span>size</span><input id="size" type="range"><output id="sizev"></output></div>
<div class="slider"><span>cell</span><input id="cell" type="range"><output id="cellv"></output></div>
<div class="slider"><span>per pg</span><input id="per" type="range"><output id="perv"></output></div>
</div>
<div class="group">
<label>Options</label>
<div class="row"><span>Show codepoints</span><button class="ghost" id="cp">off</button></div>
<div class="row"><span>Theme</span><button class="ghost" id="theme">dark</button></div>
</div>
<button class="ghost" id="reset">Reset</button>
</aside>
<main>
<div class="top">
<button class="ghost" id="menu">Controls</button>
<input id="q" type="search" placeholder="Search icon names…" autocomplete="off">
<span class="count" id="count"></span>
<div class="pager">
<button class="ghost" id="prev">‹</button>
<span class="page" id="page"></span>
<button class="ghost" id="next">›</button>
</div>
</div>
<div class="grid" id="grid"></div>
<div class="pager bottom">
<button class="ghost" id="prev2">‹ Prev</button>
<span class="page" id="page2"></span>
<button class="ghost" id="next2">Next ›</button>
</div>
</main>
<div id="toast"></div>
<script>
const ICONS = {icons};
const STYLES = {styles};
const AXES = {axes};
const el = id => document.getElementById(id);
const val = id => el(id).value;
const grid = el("grid");
const toast = el("toast");
const SLIDERS = Object.assign({{}}, AXES, {{
size: {{ min: 16, max: 64, step: 1, def: 24 }},
cell: {{ min: 70, max: 200, step: 1, def: 112 }},
per: {{ min: 24, max: 600, step: 24, def: 120 }},
}});
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
// remember options across reloads: sliders, style, theme, codepoint toggle
const STATE_KEY = "symbols-preview-v1";
let saved = {{}};
try {{ saved = JSON.parse(localStorage.getItem(STATE_KEY)) || {{}}; }} catch (e) {{}}
function save() {{
const st = {{ style: styleKey, theme: document.documentElement.dataset.theme,
cp: document.documentElement.dataset.cp }};
for (const id of Object.keys(SLIDERS)) st[id] = val(id);
try {{ localStorage.setItem(STATE_KEY, JSON.stringify(st)); }} catch (e) {{}}
}}
for (const [id, s] of Object.entries(SLIDERS)) {{
const e = el(id);
e.min = s.min; e.max = s.max; e.step = s.step;
e.value = clamp(Number(saved[id] ?? s.def), s.min, s.max);
}}
// cell objects exist for every icon, but only the current page is ever in the DOM
const cells = ICONS.map(([name, cp]) => {{
const cell = document.createElement("div");
cell.className = "cell";
const icon = document.createElement("div");
icon.className = "icon";
icon.textContent = name;
const label = document.createElement("div");
label.className = "name";
label.textContent = name;
const hex = document.createElement("div");
hex.className = "cp";
hex.textContent = cp;
cell.append(icon, label, hex);
cell.title = name + " " + cp;
cell.onclick = async () => {{
if (cell.classList.contains("miss")) return;
await navigator.clipboard.writeText(name);
toast.textContent = "copied " + name;
toast.classList.add("show");
setTimeout(() => toast.classList.remove("show"), 1400);
}};
return cell;
}});
let styleKey = "{default_style}";
let haveSet = null;
let filtered = [];
let page = 0;
let rendered = [];
function settings() {{
// Axis tags MUST be quoted: Firefox drops unquoted forms like `FILL 1` as
// invalid (computed = "normal"); only `"FILL" 1` is accepted.
return Object.keys(AXES)
.map(id => `"${{AXES[id].tag}}" ${{val(id)}}`).join(", ");
}}
function paint() {{
// only the rendered page needs variation settings — applying to all 4k+ cells
// is what made slider drags stutter
const vs = settings();
const px = val("size");
for (const cell of rendered) {{
const icon = cell.firstElementChild;
icon.style.fontVariationSettings = vs;
icon.style.fontSize = px + "px";
}}
}}
function apply() {{
paint();
for (const id of Object.keys(SLIDERS)) el(id + "v").textContent = val(id);
document.documentElement.style.setProperty("--cell-min", val("cell") + "px");
}}
// Every token must appear somewhere in the name, so "edit notif" matches
// edit_notifications. Hyphens are treated as underscores. Results are ranked:
// name prefix > word-boundary prefix > substring, then shorter names first.
function score(name, tokens) {{
let total = 0;
for (const t of tokens) {{
let s = 0;
if (name.startsWith(t)) s = 8;
else if (name.split("_").some(w => w.startsWith(t))) s = 5;
else if (name.includes(t)) s = 2;
else return -1;
total += s - name.length / 1000;
}}
return total;
}}
function filter() {{
const q = val("q").trim().toLowerCase().replace(/-/g, "_");
const tokens = q ? q.split(/[_\\s]+/).filter(Boolean) : [];
const hits = [];
for (let i = 0; i < ICONS.length; i++) {{
const s = tokens.length ? score(ICONS[i][0], tokens) : 0;
if (s >= 0) hits.push([i, s]);
}}
if (tokens.length)
hits.sort((a, b) => b[1] - a[1] || ICONS[a[0]][0].length - ICONS[b[0]][0].length);
filtered = hits.map(h => h[0]);
}}
function pages() {{ return Math.max(1, Math.ceil(filtered.length / Number(val("per")))); }}
function render() {{
page = clamp(page, 0, pages() - 1);
const n = Number(val("per"));
const start = page * n;
const frag = document.createDocumentFragment();
rendered = [];
for (const i of filtered.slice(start, start + n)) {{
const cell = cells[i];
cell.classList.toggle("miss", haveSet !== null && !haveSet.has(ICONS[i][0]));
frag.append(cell);
rendered.push(cell);
}}
grid.replaceChildren(frag);
paint();
const m = filtered.length;
el("count").textContent = m ? (start + 1) + "-" + Math.min(start + n, m) + " of " + m
: "0 of " + ICONS.length;
const label = (page + 1) + " / " + pages();
el("page").textContent = label;
el("page2").textContent = label;
el("prev").disabled = el("prev2").disabled = page <= 0;
el("next").disabled = el("next2").disabled = page >= pages() - 1;
}}
function go(delta) {{ page += delta; render(); }}
function setStyle(key, persist = true) {{
styleKey = key;
document.documentElement.style.setProperty("--sym", STYLES[key].family);
haveSet = STYLES[key].names ? new Set(STYLES[key].names) : null;
document.querySelectorAll("#styles button").forEach(b =>
b.setAttribute("aria-pressed", String(b.dataset.style === key)));
render();
if (persist) save();
}}
for (const id of Object.keys(SLIDERS)) el(id).addEventListener("input", () => {{
if (id === "per") page = 0;
apply(); render(); save();
}});
let timer = null;
el("q").addEventListener("input", () => {{
clearTimeout(timer);
timer = setTimeout(() => {{ filter(); page = 0; render(); }}, 120);
}});
el("styles").addEventListener("click", e => {{
if (e.target.dataset.style) setStyle(e.target.dataset.style);
}});
el("cp").onclick = e => {{
const on = document.documentElement.dataset.cp === "1";
document.documentElement.dataset.cp = on ? "0" : "1";
e.target.textContent = on ? "off" : "on";
save();
}};
el("theme").onclick = e => {{
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = next;
e.target.textContent = next;
save();
}};
el("menu").onclick = () => document.body.classList.toggle("nav-open");
for (const id of ["prev", "prev2"]) el(id).onclick = () => go(-1);
for (const id of ["next", "next2"]) el(id).onclick = () => go(1);
el("reset").onclick = () => {{
try {{ localStorage.removeItem(STATE_KEY); }} catch (e) {{}}
for (const [id, s] of Object.entries(SLIDERS)) el(id).value = s.def;
document.documentElement.dataset.theme = "dark";
el("theme").textContent = "dark";
document.documentElement.dataset.cp = "0";
el("cp").textContent = "off";
page = 0;
apply(); filter(); setStyle("{default_style}", false);
}};
document.addEventListener("keydown", e => {{
if (e.key === "/" && document.activeElement !== el("q")) {{
e.preventDefault(); el("q").focus(); return;
}}
if (document.activeElement && document.activeElement.tagName === "INPUT") return;
if (e.key === "ArrowLeft") {{ e.preventDefault(); go(-1); }}
if (e.key === "ArrowRight") {{ e.preventDefault(); go(1); }}
}});
// init from saved state
document.documentElement.dataset.theme = saved.theme === "light" ? "light" : "dark";
el("theme").textContent = document.documentElement.dataset.theme;
document.documentElement.dataset.cp = saved.cp === "1" ? "1" : "0";
el("cp").textContent = document.documentElement.dataset.cp === "1" ? "on" : "off";
apply(); filter();
setStyle(saved.style && STYLES[saved.style] ? saved.style : "{default_style}", false);
</script>
</body>
</html>
"""
def build(styles, source="local", out_path="/tmp/opencode/symbols-preview.html"):
"""source: local | url | embed
local() is the default: with the fontconfig advertisement in place
(conf.d/90-material-symbols-variable.conf), Firefox treats the local() match
as variable and applies all four axes. url()/embed remain as fallbacks for
machines without that config.
"""
fonts = {}
for key in styles:
fonts[key] = ttLib.TTFont(installed()[key])
# name -> codepoint, from the first style; other styles checked for coverage
primary_key = styles[0]
primary_names = names_of(fonts[primary_key])[0]
per_style = {key: names_of(fonts[key])[0] for key in styles}
icons = sorted(primary_names.items())
outdir = os.path.dirname(os.path.abspath(out_path)) or "."
faces, style_js, written = [], {}, []
for key in styles:
font = fonts[key]
locs = ", ".join(f'local("{n}")' for n in local_names(font))
parts = []
if source in ("url", "embed"):
cmap_data = names_of(font)
if source == "url":
fname = f"symbols-{key}.woff2"
dest = os.path.join(outdir, fname)
written.append((fname, subset(installed()[key], all_glyphs(font, cmap_data[2]), dest)))
# url() first: applies all axes without needing the fontconfig advertisement
parts.append(f'url("{fname}") format("woff2")')
parts.append(locs)
else:
tmp = os.path.join(tempfile.mkdtemp(), f"{key}.woff2")
size = subset(installed()[key], all_glyphs(font, cmap_data[2]), tmp)
data = base64.b64encode(open(tmp, "rb").read()).decode()
written.append((f"(embedded {key})", size))
parts.append(f'url(data:font/woff2;base64,{data}) format("woff2")')
else:
parts.append(locs)
faces.append(f' @font-face {{ font-family: "Sym-{key.title()}"; src: {", ".join(parts)}; }}')
same = set(per_style[key]) == set(primary_names)
style_js[key] = {"family": f"Sym-{key.title()}", "names": None if same else sorted(per_style[key])}
axes = axes_of(fonts[primary_key])
slider_html, axis_js = axis_sliders(axes)
buttons = "".join(
f'<button data-style="{key}" aria-pressed="false">{FAMILY[key].replace("Material Symbols ", "")}</button>'
for key in styles
)
html = TEMPLATE.format(
font_faces="\n".join(faces),
count=len(icons),
style_list=", ".join(FAMILY[k] for k in styles),
style_buttons=buttons,
axis_sliders=slider_html,
default_style=primary_key,
icons=json.dumps([[n, f"{hex(c)}"] for n, c in icons]),
styles=json.dumps(style_js),
axes=json.dumps(axis_js),
)
open(out_path, "w").write(html)
counts = {k: (len(icons) if v["names"] is None else len(v["names"])) for k, v in style_js.items()}
return out_path, len(html), counts, written
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("out", nargs="?", default="/tmp/opencode/symbols-preview.html")
ap.add_argument("--styles", default=",".join(STYLES))
ap.add_argument("--source", choices=("local", "url", "embed"), default="local",
help="local = system font via local() (all axes work with the fontconfig "
"advertisement); url = sibling woff2 subsets (all axes work anywhere); "
"embed = self-contained but multi-MB page")
args = ap.parse_args()
want = [s for s in args.styles.split(",") if s in STYLES]
have = installed()
styles = [s for s in want if s in have]
if not styles:
raise SystemExit("no installed Material Symbols style found")
for s in want:
if s not in have:
print(f" skipping {s}: not installed")
path, size, counts, written = build(styles, source=args.source, out_path=args.out)
print(f"{path}: {size} bytes, source={args.source}")
print(" styles:", ", ".join(f"{k} ({v} icons)" for k, v in counts.items()))
for name, bytes_ in written:
print(f" wrote {name}: {bytes_} bytes")
for s in styles:
print(f" {FAMILY[s]}: {installed()[s]}")
if __name__ == "__main__":
main()