Stop lists and sliders from losing input under the user

WifiList and the notification toasts built their model from a plain
computed array, so any background property tick (a scan result, an
unrelated notification arriving) reassigned the whole array and the
Repeater destroyed and recreated every delegate -- including one with
an open, focused password field or an in-progress reply. Switched
both to a ScriptModel, which diffs by identity instead of resetting.

ValueSlider had its pointer-to-value mapping offset by 16px (the
hit-area margin was applied with the wrong sign), so 0% was
unreachable and every click landed to the right of where it was
placed -- affects every slider in the shell. SliderRow used -1 as a
sentinel for "nothing pending," which collides with legitimate
negative preference values like pointer sensitivity.

Dock intellihide read the globally focused workspace instead of each
monitor's own, so an empty workspace on one screen could hide the
dock on another; ActivityPanel rebuilt every row once a second during
a recording because the elapsed-time read lived in the model
construction instead of each row's own binding.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
This commit is contained in:
Gabriel Brown
2026-08-18 21:22:58 -04:00
parent 70d8d32ee2
commit e6b4d3c1a1
7 changed files with 99 additions and 33 deletions
@@ -25,10 +25,18 @@ PanelWindow {
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
// Deliberately does not read Capture.recordingSeconds (or anything else
// that ticks once a second): this array is a plain JS array, not an
// identity-preserving model, so any dependency that changes every second
// would make the whole thing re-derive every second, and the Repeater
// below would destroy and recreate every row -- including one the user
// might be hovering or about to click. The set of activities should only
// change when an activity actually starts or stops. Elapsed time is
// rendered by each row's own Text binding instead, further down.
readonly property var activities: { readonly property var activities: {
const result = []; const result = [];
if (PrivacyState.recordingActive) if (PrivacyState.recordingActive)
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama · " + root.elapsed(), tone: "danger", stoppable: true }); result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama", tone: "danger", stoppable: true });
if (PrivacyState.screenSharingActive) if (PrivacyState.screenSharingActive)
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false }); result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
if (PrivacyState.cameraActive) if (PrivacyState.cameraActive)
@@ -38,11 +46,12 @@ PanelWindow {
return result; return result;
} }
function elapsed(): string { // Pure formatter, no ticking property read here -- callers decide what
const total = Capture.recordingSeconds; // seconds value to pass, and only they take on the per-second dependency.
const seconds = String(total % 60).padStart(2, "0"); function formatElapsed(totalSeconds: int): string {
const minutes = Math.floor(total / 60) % 60; const seconds = String(totalSeconds % 60).padStart(2, "0");
const hours = Math.floor(total / 3600); const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`; return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
} }
@@ -164,7 +173,11 @@ PanelWindow {
Text { Text {
width: parent.width width: parent.width
text: activityRow.modelData.detail // Only this Text re-evaluates every second while
// recording -- Capture.recordingSeconds is read
// here, not in the parent `activities` array, so
// the row itself is never torn down for a tick.
text: activityRow.modelData.kind === "recording" ? activityRow.modelData.detail + " · " + root.formatElapsed(Capture.recordingSeconds) : activityRow.modelData.detail
color: Theme.fgDim color: Theme.fgDim
elide: Text.ElideRight elide: Text.ElideRight
font.family: Theme.fontFamily font.family: Theme.fontFamily
+30 -4
View File
@@ -48,12 +48,22 @@ PanelWindow {
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
// ── Intellihide ───────────────────────────────────────────────────────── // ── Intellihide ─────────────────────────────────────────────────────────
// This instance's own monitor, the same lookup Workspaces.qml uses to
// scope a per-screen bar to its own screen. Falls back to null when this
// Dock is created standalone (no `screen` set) rather than via Variants.
readonly property HyprlandMonitor monitor: root.screen ? Hyprland.monitorFor(root.screen) : null
// Hyprland does not expose live toplevel geometry, so exact overlap cannot // Hyprland does not expose live toplevel geometry, so exact overlap cannot
// be computed. "Is anything on this workspace at all" is the robust proxy, // be computed. "Is anything on this workspace at all" is the robust proxy,
// and it is what Dash-to-Dock's all-windows intellihide felt like in // and it is what Dash-to-Dock's all-windows intellihide felt like in
// practice: an empty workspace keeps the dock out. // practice: an empty workspace keeps the dock out.
//
// Deliberately this instance's own monitor's active workspace, not the
// globally-focused one -- with one Dock per screen, keying off the global
// focus would make focusing an empty workspace on monitor A hide the dock
// on monitor B even though B's own workspace is still busy.
readonly property bool workspaceOccupied: { readonly property bool workspaceOccupied: {
const ws = Hyprland.focusedWorkspace; const ws = root.monitor ? root.monitor.activeWorkspace : Hyprland.focusedWorkspace;
return !!ws && ws.toplevels.values.length > 0; return !!ws && ws.toplevels.values.length > 0;
} }
@@ -83,15 +93,31 @@ PanelWindow {
onTriggered: root.revealed = false onTriggered: root.revealed = false
} }
// Other modules (the bar, the capture overlay) read this. // Other modules (the bar, the capture overlay) read this. It is one
onRevealedChanged: ShellState.dockRevealed = revealed // shared flag but there is one Dock per monitor, so only the instance on
// the currently-focused monitor is allowed to write it -- otherwise
// whichever instance last changed reveal state would stomp the others,
// and a reader would see an arbitrary monitor's value. This scopes the
// flag to mean "is the dock revealed on the monitor the user is on",
// which is what a capture overlay or the bar actually care about.
// (A true per-monitor flag would need ShellState.dockRevealed itself to
// become keyed by screen, which is out of scope here -- see the report.)
readonly property bool isFocusedMonitorInstance: root.monitor === null || root.monitor === Hyprland.focusedMonitor
onRevealedChanged: root._syncShellState()
onIsFocusedMonitorInstanceChanged: root._syncShellState()
function _syncShellState(): void {
if (root.isFocusedMonitorInstance)
ShellState.dockRevealed = root.revealed;
}
// wantRevealed's first evaluation emits no change signal when it lands on // wantRevealed's first evaluation emits no change signal when it lands on
// false (the default), so the initial state has to be taken explicitly — // false (the default), so the initial state has to be taken explicitly —
// otherwise a shell started on a busy workspace would leave the dock up. // otherwise a shell started on a busy workspace would leave the dock up.
Component.onCompleted: { Component.onCompleted: {
revealed = wantRevealed; revealed = wantRevealed;
ShellState.dockRevealed = revealed; root._syncShellState();
} }
// ── Input region ──────────────────────────────────────────────────────── // ── Input region ────────────────────────────────────────────────────────
@@ -1,7 +1,6 @@
// A single banner: a notification card that slides in and times itself out. // A single banner: a notification card that slides in and times itself out.
import QtQuick import QtQuick
import Quickshell.Services.Notifications
import qs.config import qs.config
import qs.services import qs.services
@@ -14,28 +13,25 @@ Item {
// into a keyboard focus request on the layer surface. // into a keyboard focus request on the layer surface.
signal replyFocusChanged(bool focused) signal replyFocusChanged(bool focused)
// Mirrors the signal above so the dismiss timer below can read it.
property bool replyFocused: false
implicitHeight: card.implicitHeight implicitHeight: card.implicitHeight
// Critical notifications stay until dismissed (the setting is 0). An app // Critical notifications stay until dismissed (the setting is 0). An app
// asking for 0 means "never expire" per the freedesktop spec; -1 means // asking for 0 means "never expire" per the freedesktop spec; -1 means
// "server decides", which is our default. // "server decides", which is our default. Shared with Notifs.qml, which
readonly property int timeoutMs: { // gives a DND-hidden transient notification the same lifetime.
if (root.notification.urgency === NotificationUrgency.Critical) readonly property int timeoutMs: Notifs.notificationTimeoutMs(root.notification)
return Settings.notificationTimeoutCriticalMs;
if (root.notification.expireTimeout === 0)
return 0;
if (root.notification.expireTimeout > 0)
return Math.round(root.notification.expireTimeout * 1000);
return Settings.notificationTimeoutMs;
}
HoverHandler { HoverHandler {
id: hover id: hover
} }
Timer { Timer {
// Hovering holds the banner open; the countdown restarts on leave. // Hovering, or actively typing a reply, holds the banner open; the
running: root.timeoutMs > 0 && !hover.hovered // countdown restarts (from the top, same as hover) once both let go.
running: root.timeoutMs > 0 && !hover.hovered && !root.replyFocused
interval: root.timeoutMs interval: root.timeoutMs
onTriggered: Notifs.dropPopup(root.notification) onTriggered: Notifs.dropPopup(root.notification)
} }
@@ -45,7 +41,10 @@ Item {
width: parent.width width: parent.width
notification: root.notification notification: root.notification
onDismissed: Notifs.dropPopup(root.notification) onDismissed: Notifs.dropPopup(root.notification)
onReplyFocusChanged: focused => root.replyFocusChanged(focused) onReplyFocusChanged: focused => {
root.replyFocused = focused;
root.replyFocusChanged(focused);
}
// Slide in from the right edge. Runs once, on creation. // Slide in from the right edge. Runs once, on creation.
NumberAnimation on x { NumberAnimation on x {
@@ -45,7 +45,17 @@ PanelWindow {
spacing: Theme.itemSpacing spacing: Theme.itemSpacing
Repeater { Repeater {
model: Notifs.popups.slice(0, Settings.maxVisibleToasts) // Notifs.popups.slice() is a fresh array on every change (a new
// arrival, a dismissal, a sibling toast timing out). Handing that
// straight to Repeater would reset the model and rebuild every
// delegate each time, blowing away whichever toast has a reply
// field mid-typing. ScriptModel diffs by object identity
// (Notification instances are unique QObjects), so only genuinely
// added/removed notifications add/remove delegates — unrelated
// toasts, and their slide-in animations, are untouched.
model: ScriptModel {
values: Notifs.popups.slice(0, Settings.maxVisibleToasts)
}
Toast { Toast {
required property var modelData required property var modelData
@@ -137,7 +137,18 @@ Item {
} }
Repeater { Repeater {
model: root.networks // root.networks is a fresh array on every signal-strength tick (the
// sort comparator reads signalStrength/connected/known, so any of
// those changing on ANY network recomputes the whole list). Handing
// that straight to Repeater would reset the model and rebuild every
// delegate each tick, blowing away whichever row has its password
// Section open and focused. ScriptModel diffs by object identity
// (WifiNetwork instances are unique QObjects) and turns a reorder
// into move operations, so existing delegates -- and their expanded
// state -- survive.
model: ScriptModel {
values: root.networks
}
Column { Column {
id: entry id: entry
@@ -51,9 +51,11 @@ Item {
return typeof value === "number" ? value : root.minimum; return typeof value === "number" ? value : root.minimum;
} }
// Shown while dragging; -1 means "nothing pending, show what is stored". // Shown while dragging; null means "nothing pending, show what is stored".
property real pending: -1 // Not -1: several schema entries (e.g. pointerSensitivity) are legitimately
readonly property real shown: root.pending >= 0 ? root.pending : root.stored // negative, and -1 would be indistinguishable from a real committed value.
property var pending: null
readonly property real shown: root.pending !== null ? root.pending : root.stored
// Below this the label and a usable slider cannot share a line without one // Below this the label and a usable slider cannot share a line without one
// of them becoming useless. // of them becoming useless.
@@ -176,7 +178,7 @@ Item {
id: commitTimer id: commitTimer
interval: 140 interval: 140
onTriggered: { onTriggered: {
if (root.pending < 0) if (root.pending === null)
return; return;
SystemSettings.commitPreference(root.setting, root.pending); SystemSettings.commitPreference(root.setting, root.pending);
// Hand the display back to the stored value. If the write was // Hand the display back to the stored value. If the write was
@@ -188,6 +190,6 @@ Item {
Timer { Timer {
id: releaseTimer id: releaseTimer
interval: 160 interval: 160
onTriggered: root.pending = -1 onTriggered: root.pending = null
} }
} }
@@ -103,7 +103,12 @@ Item {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
function apply(mouseX) { function apply(mouseX) {
const ratio = Math.max(0, Math.min(1, (mouseX + 8) / track.width)); // mouseX is local to this MouseArea, whose anchors.margins: -8
// pushes its own origin 8px before the track's left edge. So
// MouseArea-local x=8 is track-local x=0 -- subtract the
// margin to land back in the track's own coordinate space
// before turning it into a fraction.
const ratio = Math.max(0, Math.min(1, (mouseX - 8) / track.width));
root.moved(ratio); root.moved(ratio);
} }