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
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
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
indent: tabs
|
||||
list_expansion: favour-expansion
|
||||
definitions: [Plugins/cmake/qml-module.cmake, Plugins/gersemi/qt-add-shaders.cmake]
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -421,7 +421,6 @@ Item {
|
||||
|
||||
var now = Date.now();
|
||||
var isTouchpad = !isMouseWheel(wheel) && (wheel.pixelDelta.y !== 0 || (wheel.phase !== Qt.NoScrollPhase && wheel.angleDelta.y === 0));
|
||||
console.log("wheel", wheel.angleDelta.y, wheel.pixelDelta.y, wheel.phase);
|
||||
if (!isTouchpad) {
|
||||
velocity = 0;
|
||||
lastWheelTimestamp = 0;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ Singleton {
|
||||
|
||||
readonly property var appCooldownMap: new Map()
|
||||
property alias dnd: props.dnd
|
||||
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)
|
||||
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+4
-7
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,6 +58,7 @@ Item {
|
||||
shouldBeActive: root.hasCurrent
|
||||
|
||||
sourceComponent: Content {
|
||||
wrapper: root
|
||||
horizontal: root.horizontal
|
||||
popouts: popoutState
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 + "%"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -34,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 {
|
||||
@@ -44,7 +46,7 @@ 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 {
|
||||
@@ -59,8 +61,11 @@ CustomRect {
|
||||
}
|
||||
|
||||
CustomListView {
|
||||
id: list
|
||||
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
orientation: Qt.Vertical
|
||||
spacing: Tokens.spacing.small
|
||||
|
||||
add: Transition {
|
||||
@@ -78,7 +83,7 @@ CustomRect {
|
||||
to: 1
|
||||
}
|
||||
}
|
||||
delegate: NotifGroup {}
|
||||
delegate: NotifRow {}
|
||||
displaced: Transition {
|
||||
Anim {
|
||||
properties: "opacity,scale"
|
||||
@@ -92,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 {
|
||||
@@ -123,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 NotificationRow 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, " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +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
|
||||
@@ -21,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;
|
||||
@@ -54,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,74 +114,106 @@ Item {
|
||||
component NotifWrapper: Item {
|
||||
id: wrapper
|
||||
|
||||
property int idx
|
||||
required property int index
|
||||
required property NotificationRow 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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,515 +0,0 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import Quickshell
|
||||
import Quickshell.Services.Notifications
|
||||
import ZShell.Components
|
||||
import ZShell.Services
|
||||
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 NotificationRow 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.pausePopup()
|
||||
onExited: {
|
||||
if (!pressed)
|
||||
root.modelData.resumePopup();
|
||||
}
|
||||
onPositionChanged: event => {
|
||||
if (pressed) {
|
||||
const diffY = event.y - startY;
|
||||
if (Math.abs(diffY) > Config.notifs.expandThreshold)
|
||||
root.expanded = diffY > 0;
|
||||
}
|
||||
}
|
||||
onPressed: event => {
|
||||
root.modelData.pausePopup();
|
||||
startY = event.y;
|
||||
if (event.button === Qt.MiddleButton)
|
||||
root.modelData.close();
|
||||
}
|
||||
onReleased: event => {
|
||||
if (!containsMouse)
|
||||
root.modelData.resumePopup();
|
||||
|
||||
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.progress / 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: {
|
||||
if (modelData.invokable)
|
||||
modelData.invoke();
|
||||
else if (!root.modelData.resident)
|
||||
root.modelData.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ CustomRect {
|
||||
}
|
||||
|
||||
SnapLayout {
|
||||
orientation: Qt.Vertical
|
||||
id: usageColumn
|
||||
orientation: Qt.Vertical
|
||||
|
||||
anchors.centerIn: parent
|
||||
anchors.verticalCenterOffset: Tokens.padding.extraSmall
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,7 @@ Item {
|
||||
|
||||
Behavior on offsetScale {
|
||||
Anim {
|
||||
duration: Tokens.anim.durations.expressiveDefaultSpatial
|
||||
easing: Tokens.anim.expressiveDefaultSpatial
|
||||
type: Anim.DefaultSpatial
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import ZShell.Config
|
||||
import ZShell.Components
|
||||
import ZShell.Services
|
||||
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 NotificationRow 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,125 +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
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property NotificationRow notif
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: actionList.implicitHeight
|
||||
|
||||
SnapLayout {
|
||||
id: actionList
|
||||
|
||||
anchors.fill: parent
|
||||
spacing: Tokens.spacing.extraSmall
|
||||
|
||||
Repeater {
|
||||
model: [
|
||||
{
|
||||
isClose: true
|
||||
},
|
||||
...Array.from(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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,88 +7,101 @@ 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: ColumnLayout {
|
||||
spacing: 20
|
||||
sourceComponent: SnapLayout {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,10 +110,10 @@ Item {
|
||||
id: view
|
||||
|
||||
anchors.fill: parent
|
||||
contentHeight: notifList.implicitHeight
|
||||
contentWidth: width
|
||||
|
||||
flickableDirection: Flickable.VerticalFlick
|
||||
clip: true
|
||||
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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import ZShell.Components
|
||||
import ZShell.Services
|
||||
import ZShell.Config
|
||||
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 NotificationRow 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
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,22 @@ 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
|
||||
|
||||
BlobRect::BlobRect(QQuickItem* parent) : BlobShape(parent) {
|
||||
@@ -27,11 +43,12 @@ BlobRect::~BlobRect() {
|
||||
}
|
||||
|
||||
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 ||
|
||||
std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) +
|
||||
std::abs(m_dm11 - 1.0f) >
|
||||
k_identityEps;
|
||||
identityResidue(m_dm00, m_dm01, m_dm11) > 0.0f;
|
||||
}
|
||||
|
||||
void BlobRect::resetDeformMatrix() {
|
||||
@@ -55,10 +72,15 @@ void BlobRect::updatePolish() {
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -110,6 +132,16 @@ void BlobRect::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: 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_dm00, m_dm01, 0, 0, m_dm01, m_dm11, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
|
||||
emit rawDeformMatrixChanged();
|
||||
@@ -119,12 +151,15 @@ void BlobRect::updatePhysics() {
|
||||
const QPointF scenePos = mapToScene(QPointF(width() / 2.0, height() / 2.0));
|
||||
|
||||
if (!m_physicsActive && !m_retiring) {
|
||||
const float stuck = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) +
|
||||
std::abs(m_dm11 - 1.0f);
|
||||
if (stuck > k_identityEps) {
|
||||
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) {
|
||||
@@ -215,6 +250,21 @@ 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();
|
||||
|
||||
@@ -15,6 +15,22 @@ 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) {
|
||||
@@ -26,11 +42,12 @@ BlobRect2::~BlobRect2() {
|
||||
}
|
||||
|
||||
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 ||
|
||||
std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) +
|
||||
std::abs(m_dm11 - 1.0f) >
|
||||
k_identityEps;
|
||||
identityResidue(m_dm00, m_dm01, m_dm11) > 0.0f;
|
||||
}
|
||||
|
||||
bool BlobRect2::isMoving() const {
|
||||
@@ -60,10 +77,15 @@ void BlobRect2::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();
|
||||
@@ -117,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();
|
||||
@@ -126,12 +159,15 @@ void BlobRect2::updatePhysics() {
|
||||
}
|
||||
|
||||
if (!m_physicsActive && !m_retiring) {
|
||||
const float stuck = std::abs(m_dm00 - 1.0f) + std::abs(m_dm01) +
|
||||
std::abs(m_dm11 - 1.0f);
|
||||
if (stuck > k_identityEps) {
|
||||
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();
|
||||
@@ -228,6 +264,23 @@ 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();
|
||||
|
||||
@@ -43,19 +43,26 @@ float sdBox(vec2 p, vec2 center, vec2 halfSize) {
|
||||
}
|
||||
|
||||
float smin(float a, float b, float k) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -148,7 +155,8 @@ void main() {
|
||||
continue;
|
||||
if ((excludeMask & (1 << j)) != 0)
|
||||
continue;
|
||||
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));
|
||||
}
|
||||
@@ -179,7 +187,13 @@ void main() {
|
||||
smoothstep(innerRight - smoothFactor, innerRight, pixel.x);
|
||||
|
||||
float sinkValue = 0.0;
|
||||
float preOff = smoothFactor * (1.0 / 6.0);
|
||||
// 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;
|
||||
|
||||
if (topZone > 0.0 || botZone > 0.0 || leftZone > 0.0 || rightZone > 0.0) {
|
||||
@@ -215,9 +229,16 @@ void main() {
|
||||
|
||||
dInner -= sinkValue;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,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;
|
||||
@@ -102,9 +116,174 @@ void SnapLayout::itemChange(ItemChange change, const ItemChangeData& value) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -325,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;
|
||||
@@ -351,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))
|
||||
@@ -370,10 +577,13 @@ 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();
|
||||
}
|
||||
|
||||
@@ -393,6 +603,13 @@ void SnapLayout::arrangeChildren() {
|
||||
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));
|
||||
@@ -419,16 +636,83 @@ void SnapLayout::arrangeChildren() {
|
||||
std::llround(mainExact - prevEndExact);
|
||||
}
|
||||
|
||||
const qint64 mainFarDev = std::llround(mainExact + mainSizeExact);
|
||||
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;
|
||||
|
||||
const auto stored = d->childSizes.constFind(child);
|
||||
qint64 width = engineWidth;
|
||||
qint64 height = engineHeight;
|
||||
const qint64 mainSizeWritten =
|
||||
mainOwnDev >= 0 ? mainOwnDev
|
||||
: (std::max)(mainFarExact - mainNear, qint64(0));
|
||||
if (horiz)
|
||||
width = std::max(mainFarDev - mainNear, qint64(0));
|
||||
width = mainSizeWritten;
|
||||
else
|
||||
height = std::max(mainFarDev - mainNear, qint64(0));
|
||||
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 &&
|
||||
@@ -436,11 +720,22 @@ void SnapLayout::arrangeChildren() {
|
||||
std::llround(child->height() * dpr) == stored.value().height) {
|
||||
width = stored.value().width;
|
||||
height = stored.value().height;
|
||||
} else {
|
||||
d->childSizes[child] = SnapLayoutPrivate::ChildSize{
|
||||
engineWidth, engineHeight, width, 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;
|
||||
|
||||
@@ -84,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(
|
||||
@@ -118,6 +121,9 @@ class SnapLayout : public QQuickLayout {
|
||||
|
||||
void arrangeChildren();
|
||||
|
||||
// Re-arranges when the origin moved by a fraction of a device pixel.
|
||||
void rearrangeForOriginMove();
|
||||
|
||||
void applySpacing();
|
||||
|
||||
void updateAncestorListeners();
|
||||
@@ -142,6 +148,7 @@ class SnapLayoutPrivate : public QQuickLayoutPrivate {
|
||||
QList<QQuickItem*> invalidateAfterRearrange;
|
||||
|
||||
QVector<QPointer<QQuickItem>> ancestorListeners;
|
||||
QHash<QQuickItem*, QFlags<QQuickItemPrivate::ChangeType>> ancestorMasks;
|
||||
qint64 originResidualX = -1;
|
||||
qint64 originResidualY = -1;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ class Notifs;
|
||||
|
||||
namespace ZShell::Services {
|
||||
|
||||
class NotificationGroup;
|
||||
class NotificationStore;
|
||||
class NotifImageProvider;
|
||||
|
||||
@@ -58,12 +59,15 @@ class NotificationRow : public QObject {
|
||||
QML_ELEMENT
|
||||
QML_UNCREATABLE("NotificationRow instances come from a NotificationStore")
|
||||
|
||||
Q_PROPERTY(QQmlListProperty<NotificationActionRow> actions READ actions NOTIFY actionsChanged)
|
||||
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(
|
||||
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)
|
||||
@@ -97,9 +101,17 @@ class NotificationRow : public QObject {
|
||||
[[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:
|
||||
@@ -119,12 +131,15 @@ class NotificationRow : public QObject {
|
||||
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;
|
||||
@@ -144,6 +159,10 @@ class NotificationRow : public QObject {
|
||||
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;
|
||||
@@ -156,15 +175,100 @@ class NotificationRow : public QObject {
|
||||
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(
|
||||
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)
|
||||
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);
|
||||
@@ -176,17 +280,23 @@ class NotificationStore : public QObject {
|
||||
[[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 NotificationRow* insert(
|
||||
const QJSValue& notification, bool popup);
|
||||
Q_INVOKABLE void clear();
|
||||
|
||||
signals:
|
||||
void autoExpireChanged();
|
||||
void groupsChanged();
|
||||
void pathChanged();
|
||||
void popupGroupsChanged();
|
||||
void popupTimeoutChanged();
|
||||
void rowsChanged();
|
||||
|
||||
@@ -198,18 +308,24 @@ class NotificationStore : public QObject {
|
||||
|
||||
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 persistPopup(const NotificationRow* row);
|
||||
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.
|
||||
@@ -238,10 +354,16 @@ class NotificationStore : public QObject {
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user