Review everything shipped this weekend, and fix what the reviewers caught

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 13:29:42 -04:00
parent ffce48964e
commit 07db1068f1
42 changed files with 959 additions and 219 deletions
@@ -167,7 +167,6 @@ Rectangle {
activeFocusOnTab: true
verticalAlignment: TextInput.AlignVCenter
maximumLength: 7
text: String(root.swatchColor).toLowerCase()
color: root.invalid ? Theme.danger : Theme.fg
selectionColor: Theme.alpha(Theme.accent, 0.4)
selectedTextColor: Theme.fg
@@ -178,6 +177,14 @@ Rectangle {
Accessible.role: Accessible.EditableText
Accessible.name: root.role + " colour, hex"
// Seeded once, then written only by the guarded handlers
// below and by onSwatchColorChanged. A binding here would
// be a second writer for the same text, silently dropped
// the first time one of them assigned to it -- so which of
// the two was in charge depended on what had happened
// earlier in the session.
Component.onCompleted: hexInput.text = String(root.swatchColor).toLowerCase()
onTextEdited: root.invalid = false
onAccepted: root.commit()
@@ -17,8 +17,15 @@ Item {
implicitWidth: 40
implicitHeight: 40
// A Canvas reads Theme inside onPaint, where nothing records a dependency
// on it, so a palette change would leave the ring drawn in the old warn
// tone until the next tick -- or for good, on a countdown sitting still.
// Naming the colour as a property gives the repaint something to watch.
readonly property color ringColor: Theme.warn
onSecondsLeftChanged: ring.requestPaint()
onTotalSecondsChanged: ring.requestPaint()
onRingColorChanged: ring.requestPaint()
onVisibleChanged: if (root.visible) ring.requestPaint()
Canvas {
@@ -12,6 +12,7 @@
// mirroring, rather than drawing it wherever its stale coordinates point.
import QtQuick
import Quickshell
import qs.config
import qs.widgets
import "../../services/DisplayLayout.js" as DisplayLayout
@@ -198,7 +199,20 @@ Item {
}
Repeater {
model: root.tiles
// Keyed on the output name rather than fed the array directly.
// Every drag step and every arrow key rewrites draftLayout,
// which rebuilds `tiles` into a fresh array of fresh objects --
// a new model, which resets the Repeater and destroys the tile
// being dragged on its first millimetre of travel. The drag
// ended there, silently, and nothing was ever applied; the
// arrow keys lost focus after a single step for the same
// reason. Keyed, the same rebuild is "this row moved", and the
// tile under the pointer keeps its handler and its focus.
model: ScriptModel {
values: root.tiles
objectProp: "name"
comparisonMode: ObjectComparison.Structure
}
Rectangle {
id: tile
@@ -91,7 +91,17 @@ Column {
property int draggingIndex: -1
property var workingOrder: []
readonly property var displayed: root.draggingIndex >= 0 ? root.workingOrder : root.pinned
// The order the strip is drawing, as rows the Repeater can identify. A
// plain array of ids is a NEW model every time the order changes, and
// reordering is the one thing this strip does: handing that to a Repeater
// resets it and destroys the delegate the pointer is holding, mid-press, on
// the first cell crossed. The gesture dies there and draggingIndex stays
// set, which latches the strip. Keyed on the pin id -- the only thing a pin
// is -- a reorder becomes a row move instead, and the delegate under the
// hand survives it. One-property objects because ScriptModel identifies a
// row by a property of it, which a bare string has none of.
readonly property var displayed: (root.draggingIndex >= 0 ? root.workingOrder : root.pinned)
.map(id => ({ id: id }))
function beginDrag(index: int): void {
root.workingOrder = root.pinned.slice();
@@ -119,12 +129,23 @@ Column {
root.commit(next);
}
// The drag let go of without a decision. Used when the cell being dragged
// is destroyed under the pointer -- an unpin from somewhere else, a
// settings file rewritten underneath -- where the working order is a
// rearrangement of a list that no longer exists, and writing it back would
// undo whatever really happened.
function cancelDrag(): void {
root.draggingIndex = -1;
root.workingOrder = [];
}
// ── Keyboard ────────────────────────────────────────────────────────────
//
// The Repeater's model is a plain array, so committing a move rebuilds
// every delegate and the focused one is destroyed mid-keystroke. The
// focused position is remembered here instead of in the delegate, and the
// cell that lands on it takes focus back as it is created.
// Which POSITION has the keyboard, remembered here rather than left to the
// delegate that happens to hold focus. Unpinning really does destroy the
// focused cell, and a keyed model still cannot keep focus on a row that is
// gone; remembering the index means the cell that lands on it takes focus
// back as it arrives, so Delete twice in a row deletes twice.
property int keyboardIndex: -1
@@ -159,7 +180,13 @@ Column {
spacing: root.cellSpacing
Repeater {
model: root.displayed
// Keyed on the pin id: see `displayed`. This is the same reason
// DockBody keys the dock's own icons.
model: ScriptModel {
values: root.displayed
objectProp: "id"
comparisonMode: ObjectComparison.Structure
}
delegate: Item {
id: cell
@@ -167,9 +194,10 @@ Column {
required property var modelData
required property int index
readonly property string appId: cell.modelData ? cell.modelData.id : ""
readonly property bool dragging: root.draggingIndex === cell.index
readonly property string appName: root.nameFor(cell.modelData)
readonly property string iconSource: root.iconFor(cell.modelData)
readonly property string appName: root.nameFor(cell.appId)
readonly property string iconSource: root.iconFor(cell.appId)
width: root.cellSize
height: root.cellSize
@@ -194,6 +222,12 @@ Column {
onActiveFocusChanged: if (cell.activeFocus) root.keyboardIndex = cell.index
Component.onCompleted: if (root.keyboardIndex === cell.index) cell.forceActiveFocus()
// A cell that goes away while it is the one being dragged takes
// the release with it -- there is no MouseArea left to report
// one -- and the strip would sit there with draggingIndex still
// set, refusing every later press.
Component.onDestruction: if (cell.dragging) root.cancelDrag()
Connections {
target: root
function onKeyboardIndexChanged(): void {
@@ -1,5 +1,5 @@
// A slider whose track carries a meaning, and whose value is committed once the
// drag settles rather than on every pixel.
// gesture is over rather than on every pixel.
//
// Two rows on the Displays page need something SliderRow cannot give them:
//
@@ -8,7 +8,14 @@
// like, and this is the one slider whose numbers most people cannot picture.
// * The SDR trims, which write through the display transaction. A transaction
// per pixel of drag would arm a fifteen-second countdown dozens of times;
// the value is applied once, when the pointer stops moving.
// the value is applied once, when the button comes up.
//
// On release, not on an idle timer. Holding a slider still for a third of a
// second is not the end of a gesture, and the commit an idle timer made
// mid-hold armed a display transaction that turned this row's `enabled` false
// with the button still down -- the control went dead under the hand using it.
// The wheel has no release, so it keeps the timer, and that is all the timer is
// for now.
//
// Not schema-bound and not a SettingRow: the caller says what the value is and
// what to do with a new one, and the row stacks its control below the label in
@@ -59,6 +66,16 @@ Item {
return colors[Math.min(index, colors.length - 1)];
}
// Hands the pending value to the caller. Called on release, and by the
// wheel's idle timer, which is the one input that never has one.
function commit(): void {
commitTimer.stop();
if (root.pending === null)
return;
root.committed(root.pending);
releaseTimer.restart();
}
function quantise(ratio: real): real {
const raw = root.minimum + ratio * (root.maximum - root.minimum);
const snapped = Math.round(raw / root.step) * root.step;
@@ -166,7 +183,6 @@ Item {
function move(positionX: real): void {
root.pending = root.quantise(
Math.max(0, Math.min(1, (positionX - 8) / track.width)));
commitTimer.restart();
}
onPressed: event => drag.move(event.x)
@@ -174,6 +190,14 @@ Item {
if (drag.pressed)
drag.move(event.x);
}
onReleased: root.commit()
// A grab taken away mid-drag is not a decision. The row goes
// back to showing whatever is really in effect rather than
// keeping a number nobody asked it to apply.
onCanceled: {
commitTimer.stop();
root.pending = null;
}
onWheel: event => {
const direction = event.angleDelta.y > 0 ? 1 : -1;
root.pending = Math.max(root.minimum,
@@ -208,15 +232,13 @@ Item {
color: Theme.alpha(Theme.fg, 0.065)
}
// The wheel's release. A notch is a whole gesture on its own, and a hand
// spinning through a range means one commit at the end of the spin rather
// than one per notch -- there is no button coming up to say when that is.
Timer {
id: commitTimer
interval: root.commitDelay
onTriggered: {
if (root.pending === null)
return;
root.committed(root.pending);
releaseTimer.restart();
}
onTriggered: root.commit()
}
// Hands the readout back to whatever is really in effect. If the change was
@@ -60,6 +60,24 @@ SettingsPage {
setting: "overAmplification"
divider: false
}
// Turning over-amplification off has to bring the level down with it.
// The preference only moves the top of the range; PipeWire keeps
// whatever was set, so a device left at 140% stays there with a slider
// that now ends at 100 and shows itself pinned at full. Nothing the
// page can do afterwards expresses the difference, and the reading is
// wrong until something else happens to move the volume.
Connections {
target: Settings
function onOverAmplificationChanged(): void {
if (Settings.overAmplification)
return;
const audio = root.currentOutput?.audio ?? null;
if (audio && audio.volume > 1)
audio.volume = 1;
}
}
}
SettingsCard {
@@ -165,6 +165,12 @@ SettingRow {
root.moveTo(drag.ratioAt(event.x));
}
onReleased: root.apply()
// A grab taken away mid-drag never releases, so nothing
// would ever apply the reading left on screen -- and the
// next commit would measure its ratio against a number the
// palette was never re-saturated to. Back to the last
// reading that was really applied.
onCanceled: root.shown = root.committed
onWheel: event => {
root.moveTo((root.shown + (event.angleDelta.y > 0 ? 0.05 : -0.05))
/ root.maximum);