diff --git a/config/dot/quickshell/modules/bar/ActivityPanel.qml b/config/dot/quickshell/modules/bar/ActivityPanel.qml index 7f877af..e84b4fc 100644 --- a/config/dot/quickshell/modules/bar/ActivityPanel.qml +++ b/config/dot/quickshell/modules/bar/ActivityPanel.qml @@ -25,10 +25,18 @@ PanelWindow { WlrLayershell.layer: WlrLayer.Overlay 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: { const result = []; 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) result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false }); if (PrivacyState.cameraActive) @@ -38,11 +46,12 @@ PanelWindow { return result; } - function elapsed(): string { - const total = Capture.recordingSeconds; - const seconds = String(total % 60).padStart(2, "0"); - const minutes = Math.floor(total / 60) % 60; - const hours = Math.floor(total / 3600); + // Pure formatter, no ticking property read here -- callers decide what + // seconds value to pass, and only they take on the per-second dependency. + function formatElapsed(totalSeconds: int): string { + const seconds = String(totalSeconds % 60).padStart(2, "0"); + 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}`; } @@ -164,7 +173,11 @@ PanelWindow { Text { 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 elide: Text.ElideRight font.family: Theme.fontFamily diff --git a/config/dot/quickshell/modules/dock/Dock.qml b/config/dot/quickshell/modules/dock/Dock.qml index d1acd21..1e16bb7 100644 --- a/config/dot/quickshell/modules/dock/Dock.qml +++ b/config/dot/quickshell/modules/dock/Dock.qml @@ -48,12 +48,22 @@ PanelWindow { implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin // ── 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 // 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 // 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: { - const ws = Hyprland.focusedWorkspace; + const ws = root.monitor ? root.monitor.activeWorkspace : Hyprland.focusedWorkspace; return !!ws && ws.toplevels.values.length > 0; } @@ -83,15 +93,31 @@ PanelWindow { onTriggered: root.revealed = false } - // Other modules (the bar, the capture overlay) read this. - onRevealedChanged: ShellState.dockRevealed = revealed + // Other modules (the bar, the capture overlay) read this. It is one + // 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 // 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. Component.onCompleted: { revealed = wantRevealed; - ShellState.dockRevealed = revealed; + root._syncShellState(); } // ── Input region ──────────────────────────────────────────────────────── diff --git a/config/dot/quickshell/modules/notifications/Toast.qml b/config/dot/quickshell/modules/notifications/Toast.qml index 5809b24..1a41d61 100644 --- a/config/dot/quickshell/modules/notifications/Toast.qml +++ b/config/dot/quickshell/modules/notifications/Toast.qml @@ -1,7 +1,6 @@ // A single banner: a notification card that slides in and times itself out. import QtQuick -import Quickshell.Services.Notifications import qs.config import qs.services @@ -14,28 +13,25 @@ Item { // into a keyboard focus request on the layer surface. signal replyFocusChanged(bool focused) + // Mirrors the signal above so the dismiss timer below can read it. + property bool replyFocused: false + implicitHeight: card.implicitHeight // Critical notifications stay until dismissed (the setting is 0). An app // asking for 0 means "never expire" per the freedesktop spec; -1 means - // "server decides", which is our default. - readonly property int timeoutMs: { - if (root.notification.urgency === NotificationUrgency.Critical) - 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; - } + // "server decides", which is our default. Shared with Notifs.qml, which + // gives a DND-hidden transient notification the same lifetime. + readonly property int timeoutMs: Notifs.notificationTimeoutMs(root.notification) HoverHandler { id: hover } Timer { - // Hovering holds the banner open; the countdown restarts on leave. - running: root.timeoutMs > 0 && !hover.hovered + // Hovering, or actively typing a reply, holds the banner open; the + // countdown restarts (from the top, same as hover) once both let go. + running: root.timeoutMs > 0 && !hover.hovered && !root.replyFocused interval: root.timeoutMs onTriggered: Notifs.dropPopup(root.notification) } @@ -45,7 +41,10 @@ Item { width: parent.width notification: 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. NumberAnimation on x { diff --git a/config/dot/quickshell/modules/notifications/Toasts.qml b/config/dot/quickshell/modules/notifications/Toasts.qml index fc2e2a5..f45ef57 100644 --- a/config/dot/quickshell/modules/notifications/Toasts.qml +++ b/config/dot/quickshell/modules/notifications/Toasts.qml @@ -45,7 +45,17 @@ PanelWindow { spacing: Theme.itemSpacing 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 { required property var modelData diff --git a/config/dot/quickshell/modules/quicksettings/WifiList.qml b/config/dot/quickshell/modules/quicksettings/WifiList.qml index cf7e907..e585fb8 100644 --- a/config/dot/quickshell/modules/quicksettings/WifiList.qml +++ b/config/dot/quickshell/modules/quicksettings/WifiList.qml @@ -137,7 +137,18 @@ Item { } 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 { id: entry diff --git a/config/dot/quickshell/modules/settings/SliderRow.qml b/config/dot/quickshell/modules/settings/SliderRow.qml index e6d0438..a58b5c2 100644 --- a/config/dot/quickshell/modules/settings/SliderRow.qml +++ b/config/dot/quickshell/modules/settings/SliderRow.qml @@ -51,9 +51,11 @@ Item { return typeof value === "number" ? value : root.minimum; } - // Shown while dragging; -1 means "nothing pending, show what is stored". - property real pending: -1 - readonly property real shown: root.pending >= 0 ? root.pending : root.stored + // Shown while dragging; null means "nothing pending, show what is stored". + // Not -1: several schema entries (e.g. pointerSensitivity) are legitimately + // 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 // of them becoming useless. @@ -176,7 +178,7 @@ Item { id: commitTimer interval: 140 onTriggered: { - if (root.pending < 0) + if (root.pending === null) return; SystemSettings.commitPreference(root.setting, root.pending); // Hand the display back to the stored value. If the write was @@ -188,6 +190,6 @@ Item { Timer { id: releaseTimer interval: 160 - onTriggered: root.pending = -1 + onTriggered: root.pending = null } } diff --git a/config/dot/quickshell/widgets/ValueSlider.qml b/config/dot/quickshell/widgets/ValueSlider.qml index de2cdcf..a0a7e54 100644 --- a/config/dot/quickshell/widgets/ValueSlider.qml +++ b/config/dot/quickshell/widgets/ValueSlider.qml @@ -103,7 +103,12 @@ Item { cursorShape: Qt.PointingHandCursor 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); }