feat: initial commit for pixel-snapping components and helpers

This commit is contained in:
2026-10-29 17:34:57 +01:00
parent 9d8bde1413
commit 9cbb90f9e1
163 changed files with 2671 additions and 951 deletions
+3 -4
View File
@@ -99,6 +99,7 @@ ProgressBar {
}
Line {
id: dot
property real implicitSize
anchors.right: parent.right
@@ -124,8 +125,7 @@ ProgressBar {
anchors.left: parent.left
Behavior on implicitWidth {
Anim {
}
Anim {}
}
Component.onCompleted: implicitWidth = Qt.binding(() => parent.width * root.visualPosition)
@@ -222,8 +222,7 @@ ProgressBar {
}
}
Behavior on color {
CAnim {
}
CAnim {}
}
Anim on waveProgress {
duration: root.waveDuration
+6 -6
View File
@@ -2,10 +2,10 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Templates
import ZShell.Components
import ZShell
import qs.Components
import ZShell.Components
import ZShell.Config
import qs.Components
import qs.Services
Slider {
@@ -28,7 +28,7 @@ Slider {
signal interaction(v: real)
implicitHeight: 12
implicitHeight: ZUtils.snap(12, root)
implicitWidth: 200
contentItem: Item {
@@ -123,7 +123,7 @@ Slider {
bottomRightRadius: Tokens.rounding.extraSmall / 2
color: root.fgColor
implicitHeight: root.height
implicitWidth: root.filledWidth
implicitWidth: ZUtils.snap(root.filledWidth, root)
radius: root.radius
topRightRadius: Tokens.rounding.extraSmall / 2
}
@@ -137,8 +137,8 @@ Slider {
frequency: root.waveFrequency
fullLength: root.width - handle.implicitWidth - handle.anchors.leftMargin
implicitHeight: lineWidth * amplitudeMultiplier * 2 + lineWidth
implicitWidth: root.filledWidth
lineWidth: root.height * 0.7
implicitWidth: ZUtils.snap(root.filledWidth, root)
lineWidth: ZUtils.snap(root.height * 0.7, root)
startX: x
Behavior on color {
+3 -2
View File
@@ -1,4 +1,5 @@
import QtQuick
import ZShell
import ZShell.Config
import qs.Services
@@ -15,9 +16,9 @@ ButtonBase {
const h = label.implicitHeight + padding * 2;
if (h % 2 !== 0)
return h + 1;
return h;
return ZUtils.snap(h, root);
}
implicitWidth: implicitHeight
implicitWidth: ZUtils.snap(implicitHeight, root)
inactiveColor: {
if (!isToggle && type === IconButton.Filled)
return Colors.palette.m3primary;
+3 -1
View File
@@ -1,5 +1,6 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Components
import ZShell.Config
import qs.Services
@@ -34,10 +35,11 @@ ButtonBase {
}
verticalPadding: Tokens.padding.extraSmall
RowLayout {
SnapLayout {
id: row
anchors.centerIn: parent
spacing: Tokens.spacing.small
MaterialIcon {
+89 -66
View File
@@ -1,6 +1,8 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtCanvas2D
import ZShell
Item {
id: root
@@ -36,54 +38,62 @@ Item {
property real fillAlpha: 0.15
property real lineWidth: 2
property real markerDotRadius: 4
property real labelFontSize: 10
property real labelFontSize: 8
Canvas {
Canvas2D {
id: canvas
anchors.fill: parent
renderStrategy: Canvas.Immediate
alphaBlending: true
fillColor: "transparent"
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
function snapV(v) {
return ZUtils.snap(v, canvas);
}
function strokeCurve(ctx, pts) {
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
if (root.curveSmoothing && pts.length > 2) {
const k = 1 / 6;
const n = pts.length;
const dpr = ZUtils.dprFor(canvas);
for (let i = 0; i < pts.length - 1; i++) {
const p1 = pts[i];
const p2 = pts[i + 1];
const [c1x, c1y, c2x, c2y] = segmentControls(pts, i);
for (let i = 0; i < n - 1; i++) {
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(i + 2, n - 1)];
let c1x = p1.x + (p2.x - p0.x) * k;
let c1y = p1.y + (p2.y - p0.y) * k;
let c2x = p2.x - (p3.x - p1.x) * k;
let c2y = p2.y - (p3.y - p1.y) * k;
const minY = Math.min(p1.y, p2.y);
const maxY = Math.max(p1.y, p2.y);
c1y = Math.max(minY, Math.min(maxY, c1y));
c2y = Math.max(minY, Math.min(maxY, c2y));
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, p2.x, p2.y);
}
} else {
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i];
const p1 = pts[i + 1];
const cx = (p0.x + p1.x) * 0.5;
ctx.bezierCurveTo(cx, p0.y, cx, p1.y, p1.x, p1.y);
const firstCol = Math.ceil(p1.x * dpr) + 1;
const lastCol = Math.floor(p2.x * dpr) - 1;
for (let col = firstCol; col <= lastCol; ++col) {
const x = col / dpr;
ctx.lineTo(x, bezierY(p1, c1x, c1y, c2x, c2y, p2, x));
}
ctx.lineTo(p2.x, p2.y);
}
}
// Control points for segment i (pts[i] -> pts[i+1]), matching strokeCurve
function bezierY(p1, c1x, c1y, c2x, c2y, p2, targetX) {
let tlo = 0;
let thi = 1;
for (let it = 0; it < 16; it++) {
const tm = (tlo + thi) / 2;
const u = 1 - tm;
const x = u * u * u * p1.x + 3 * u * u * tm * c1x + 3 * u * tm * tm * c2x + tm * tm * tm * p2.x;
if (x < targetX)
tlo = tm;
else
thi = tm;
}
const t = (tlo + thi) / 2;
const u = 1 - t;
return u * u * u * p1.y + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * p2.y;
}
function segmentControls(pts, i) {
const p0 = pts[Math.max(i - 1, 0)];
const p1 = pts[i];
@@ -109,7 +119,6 @@ Item {
return [cx, p1.y, cx, p2.y];
}
// Point on the curve at a given x (x is monotonic per segment)
function pointAtX(pts, targetX) {
let j = pts.length - 2;
for (let i = 0; i < pts.length - 1; i++) {
@@ -138,10 +147,16 @@ Item {
const t = (tlo + thi) / 2;
const u = 1 - t;
const y = u * u * u * p1.y + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * p2.y;
return { x: targetX, y };
return {
x: targetX,
y
};
}
function cutPoint(pts, targetX) {
return pointAtX(pts, targetX);
}
// Part of the curve left/right of a cut x, including the cut point
function cutLeft(pts, targetX) {
if (targetX <= pts[0].x)
return [];
@@ -158,7 +173,7 @@ Item {
const left = pts.slice(0, j + 1);
if (targetX > pts[j].x)
left.push(pointAtX(pts, targetX));
left.push(cutPoint(pts, targetX));
return left;
}
@@ -184,17 +199,20 @@ Item {
onPaint: {
const ctx = getContext("2d");
if (!ctx)
return;
ctx.reset();
ctx.clearRect(0, 0, width, height);
const W = width;
const H = height;
if (W < 2 || H < 2)
return;
const padLeft = root.labelFontSize * 3.4;
const padRight = 2;
const padTop = 2;
const padBottom = root.labelFontSize + 8;
const padLeft = root.labelFontSize * 5;
const padRight = 0;
const padTop = 0;
const padBottom = root.labelFontSize + 14;
const plot = {
x: padLeft,
y: padTop,
@@ -204,8 +222,6 @@ Item {
if (plot.w < 2 || plot.h < 2)
return;
// Auto-fit: NaN yMin/yMax derive from the data, snapped to the
// horizontal step grid, honoring minHorizontalLines
let lo = root.yMin;
let hi = root.yMax;
const loAuto = Number.isNaN(lo);
@@ -279,11 +295,17 @@ Item {
const xAt = i => plot.x + (i / root.xSteps) * plot.w;
const yAt = v => plot.y + (1 - (v - scaleBottom) / range) * plot.h;
const crisp = v => Math.floor(v) + 0.5;
// 1. Grid lines
const dpr = ZUtils.dprFor(canvas);
const devW = w => Math.max(1, Math.round(w * dpr));
const snapLine = (v, w) => {
const dw = devW(w);
return (Math.round(v * dpr - dw / 2) + dw / 2) / dpr;
};
const gridW = devW(1) / dpr;
ctx.strokeStyle = Qt.alpha(root.gridColor, root.gridAlpha);
ctx.lineWidth = 1;
ctx.lineWidth = gridW;
if (root.verticalLineStep > 0) {
for (let i = 0; i <= root.xSteps; i += root.verticalLineStep) {
@@ -292,7 +314,7 @@ Item {
if (i === root.xSteps && !root.lastXLine)
continue;
const x = crisp(xAt(i));
const x = snapLine(xAt(i), 1);
ctx.beginPath();
ctx.moveTo(x, plot.y);
ctx.lineTo(x, H);
@@ -300,12 +322,11 @@ Item {
}
}
// Lines extend down into yPaddingBottom, snapped to the grid
const vStart = lo - (root.yPaddingBottom > 0 ? Math.floor(root.yPaddingBottom / root.horizontalLineStep) * root.horizontalLineStep : 0);
if (root.horizontalLineStep > 0) {
for (let v = vStart; v <= hi + 0.001; v += root.horizontalLineStep) {
const y = crisp(yAt(v));
const y = snapLine(yAt(v), 1);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(plot.x + plot.w, y);
@@ -313,9 +334,8 @@ Item {
}
}
// 2. Labels
ctx.fillStyle = Qt.alpha(root.labelColor, root.labelAlpha);
ctx.font = root.labelFontSize + "px sans-serif";
ctx.font = root.labelFontSize + "pt 'sans-serif'";
ctx.textAlign = "left";
ctx.textBaseline = "top";
@@ -328,7 +348,7 @@ Item {
const text = root.xLabel(i);
if (text)
ctx.fillText(text, xAt(i) + 4, plot.y + plot.h + 4);
ctx.fillText(text, xAt(i) + 4, plot.y + plot.h + 8);
}
}
@@ -341,7 +361,6 @@ Item {
}
}
// 3. Curve, split into segments at null gaps
const segments = [];
let current = null;
for (let i = 0; i < series.length; i++) {
@@ -352,7 +371,7 @@ Item {
continue;
}
const pt = {
x: xAt(i),
x: snapV(xAt(i)),
y: yAt(series[i])
};
if (!current)
@@ -369,8 +388,6 @@ Item {
const markerPx = hasMarker ? xAt(root.markerX) : 0;
let gapPx = 0;
if (hasMarker) {
// markerGap is measured from the dot edge, perpendicular to the
// curve; project onto x via the local curve slope
let slope = 0;
const seg = segments.find(s => s[0].x <= markerPx && markerPx <= s[s.length - 1].x);
if (seg && seg.length > 2 && markerPx - 1 >= seg[0].x && markerPx + 1 <= seg[seg.length - 1].x)
@@ -382,7 +399,6 @@ Item {
if (seg.length < 2)
continue;
// Fill stays continuous under the marker
ctx.fillStyle = Qt.alpha(root.lineColor, root.fillAlpha);
strokeCurve(ctx, seg);
ctx.lineTo(seg[seg.length - 1].x, plot.y + plot.h);
@@ -390,7 +406,6 @@ Item {
ctx.closePath();
ctx.fill();
// Stroke has a gap around the marker
const pieces = hasMarker ? [cutLeft(seg, markerPx - gapPx), cutRight(seg, markerPx + gapPx)] : [seg];
for (const pts of pieces) {
@@ -398,24 +413,32 @@ Item {
continue;
ctx.strokeStyle = root.lineColor;
ctx.lineWidth = root.lineWidth;
ctx.lineWidth = ZUtils.snap(root.lineWidth, root);
strokeCurve(ctx, pts);
ctx.stroke();
}
}
// 4. Marker: dashed vertical + dot
if (root.markerX >= 0 && root.markerX <= root.xSteps) {
const x = markerPx;
ctx.setLineDash([4, 4]);
ctx.strokeStyle = Qt.alpha(root.markerColor, 0.6);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(crisp(x), plot.y);
ctx.lineTo(crisp(x), H);
ctx.stroke();
ctx.setLineDash([]);
ctx.lineWidth = gridW;
const lineX = snapLine(x, 1);
let dashY = plot.y;
let dashOn = true;
while (dashY < H) {
const dashEnd = Math.min(dashY + 4, H);
if (dashOn) {
ctx.beginPath();
ctx.moveTo(lineX, snapV(dashY));
ctx.lineTo(lineX, snapV(dashEnd));
ctx.stroke();
}
dashY = dashEnd;
dashOn = !dashOn;
}
const i0 = Math.floor(root.markerX);
const v0 = series[i0];
@@ -430,7 +453,7 @@ Item {
if (y != null) {
ctx.fillStyle = root.markerColor;
ctx.beginPath();
ctx.arc(x, y, root.markerDotRadius, 0, Math.PI * 2);
ctx.circle(snapV(x), y, root.markerDotRadius);
ctx.fill();
}
}
+6 -2
View File
@@ -4,6 +4,7 @@ import QtQuick
import QtQuick.Layouts
import Quickshell
import ZShell.Config
import ZShell.Components
import qs.Drawers
import qs.Services
@@ -111,12 +112,14 @@ MouseArea {
color: Colors.palette.m3surfaceContainerLow
radius: parent.radius
ColumnLayout {
SnapLayout {
id: column
anchors.fill: parent
anchors.margins: Tokens.padding.extraSmall
spacing: Tokens.spacing.extraSmall
orientation: Qt.Vertical
Repeater {
id: repeater
@@ -153,11 +156,12 @@ MouseArea {
}
}
RowLayout {
SnapLayout {
id: menuOptionRow
anchors.fill: parent
anchors.margins: Tokens.padding.medium
spacing: Tokens.spacing.small
MaterialIcon {
+2 -1
View File
@@ -1,5 +1,6 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Components
import ZShell.Config
import qs.Services
@@ -53,7 +54,7 @@ Row {
onClicked: root.active?.clicked()
}
RowLayout {
SnapLayout {
id: textRow
anchors.centerIn: parent
+5 -2
View File
@@ -1,4 +1,5 @@
import ZShell
import ZShell.Components
import QtQuick
import QtQuick.Layouts
import qs.Components
@@ -47,7 +48,7 @@ CustomRect {
z: -1
}
RowLayout {
SnapLayout {
id: layout
anchors.fill: parent
@@ -88,9 +89,11 @@ CustomRect {
}
}
ColumnLayout {
SnapLayout {
Layout.fillWidth: true
spacing: 0
orientation: Qt.Vertical
CustomText {
id: title