From f9e5d3f470ccb13fc3260f2e73356caa625e0dbb Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 25 Aug 2026 00:41:39 -0400 Subject: [PATCH] No forgetting, deleting or clearing on a single press, anywhere Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8 --- .../modules/quicksettings/BluetoothList.qml | 76 +++++++++++++-- .../modules/settings/BluetoothPanel.qml | 22 ++++- .../modules/settings/FocusModeRow.qml | 25 +++-- .../modules/settings/HomeFavoriteCard.qml | 31 +++--- .../modules/settings/NotificationsPage.qml | 32 +++++-- .../modules/settings/ShortcutsPage.qml | 36 +++++-- .../quickshell/modules/settings/ThemeCard.qml | 95 +++++++++++++++---- .../modules/settings/ThemeSaveRow.qml | 34 +++---- 8 files changed, 267 insertions(+), 84 deletions(-) diff --git a/config/dot/quickshell/modules/quicksettings/BluetoothList.qml b/config/dot/quickshell/modules/quicksettings/BluetoothList.qml index 6f7d919..03b07ec 100644 --- a/config/dot/quickshell/modules/quicksettings/BluetoothList.qml +++ b/config/dot/quickshell/modules/quicksettings/BluetoothList.qml @@ -41,11 +41,36 @@ Item { return list; } + // Forgetting is armed in place rather than through ConfirmAction: a 44px + // row inside a 300px popup has no width for a Keep/Forget it pair beside + // the device name, so the trash glyph arms itself and the row's own + // sublabel becomes the question. The token is ShellState's, the same one + // ConfirmAction uses, so arming here disarms whatever was armed elsewhere + // and only one confirm is ever live app-wide. + // + // The token is keyed by device address rather than held on the delegate, + // because discovery rebuilds this list -- and every delegate in it -- every + // time BlueZ reports something new. An arm parked on the row object would + // be dropped by the next scan result; keyed by address it survives the + // rebuild and is only ever cleared by a press or by the section closing. + readonly property string forgetPrefix: "bluetooth-forget-quick:" + + function forgetId(device): string { + return root.forgetPrefix + (device.address || device.name || ""); + } + + function disarmForget(): void { + if (ShellState.armedConfirm.indexOf(root.forgetPrefix) === 0) + ShellState.armedConfirm = ""; + } + onActiveChanged: { - if (root.active) + if (root.active) { Connectivity.acquireDiscovery(root.scanHold); - else + } else { Connectivity.releaseDiscovery(root.scanHold); + root.disarmForget(); + } } Component.onDestruction: Connectivity.releaseDiscovery(root.scanHold) @@ -106,18 +131,36 @@ Item { required property var modelData + readonly property bool forgetArmed: + ShellState.armedConfirm === root.forgetId(deviceRow.modelData) + width: parent.width // BlueZ reports a plain freedesktop name ("audio-headphones"); // the symbolic variant is the one that can be recolored. icon: deviceRow.modelData.icon !== "" ? deviceRow.modelData.icon + "-symbolic" : "bluetooth-symbolic" iconFallback: "bluetooth-symbolic" label: deviceRow.modelData.name || deviceRow.modelData.address - sublabel: root.stateText(deviceRow.modelData) + // Armed, the row states the question and what it costs, since + // the glyph alone is too small to carry either. + sublabel: deviceRow.forgetArmed + ? "Press again to drop the pairing" + : root.stateText(deviceRow.modelData) selected: deviceRow.modelData.connected - onClicked: root.activate(deviceRow.modelData) + // While armed the row body is the way out: a press anywhere + // else on it takes the arming back instead of connecting, so + // the escape is the largest target on screen. + onClicked: { + if (deviceRow.forgetArmed) { + root.disarmForget(); + return; + } + root.activate(deviceRow.modelData); + } // Forgetting is destructive enough that it gets its own - // control rather than sharing the row click. + // control rather than sharing the row click -- and its own + // two presses. See root.forgetPrefix for why the arming + // happens in place here rather than through ConfirmAction. IconButton { anchors.verticalCenter: parent.verticalCenter visible: deviceRow.modelData.paired @@ -125,7 +168,28 @@ Item { iconSize: 13 icon: "user-trash-symbolic" iconFallback: "window-close-symbolic" - onClicked: deviceRow.modelData.forget() + tint: deviceRow.forgetArmed ? Theme.danger : Theme.fg + onClicked: { + if (!deviceRow.forgetArmed) { + ShellState.armedConfirm = root.forgetId(deviceRow.modelData); + return; + } + root.disarmForget(); + deviceRow.modelData.forget(); + } + + // Behind the parent's own fill, so the hover tint still + // reads on top of it. Danger appears only once armed -- + // the first press must not look like the last one. + Rectangle { + anchors.fill: parent + z: -1 + radius: parent.radius + visible: deviceRow.forgetArmed + color: Theme.alpha(Theme.danger, 0.3) + border.width: 1 + border.color: Theme.alpha(Theme.danger, 0.6) + } } } } diff --git a/config/dot/quickshell/modules/settings/BluetoothPanel.qml b/config/dot/quickshell/modules/settings/BluetoothPanel.qml index b589d5a..6ffd7c9 100644 --- a/config/dot/quickshell/modules/settings/BluetoothPanel.qml +++ b/config/dot/quickshell/modules/settings/BluetoothPanel.qml @@ -66,10 +66,16 @@ Column { width: parent.width label: entry.modelData.name || entry.modelData.address || "Unknown device" - detail: root.stateLabel(entry.modelData) + // Armed, the detail names what Forget costs. "Forget" reads like + // "hide from this list"; it is the pairing itself that goes, and + // the device has to be put back into pairing mode to return. + detail: forgetConfirm.armed + ? "Drops the pairing — the device must be put back into pairing mode to return" + : root.stateLabel(entry.modelData) divider: entry.index < root.shown.length - 1 || root.hiddenCount > 0 || root.showAll - controlWidth: 200 + // Room for Keep and the confirming press while the row is armed. + controlWidth: forgetConfirm.armed ? 290 : 200 activatable: !entry.modelData.pairing onActivated: root.primaryAction(entry.modelData) @@ -87,11 +93,17 @@ Column { onClicked: root.primaryAction(entry.modelData) } - SettingsButton { + ConfirmAction { + id: forgetConfirm + anchors.verticalCenter: parent.verticalCenter visible: entry.modelData.paired - text: "Forget" - onClicked: entry.modelData.forget() + actionId: "bluetooth-forget:" + + (entry.modelData.address || entry.modelData.name || "") + armText: "Forget…" + confirmText: "Forget it" + enabled: !entry.modelData.pairing + onConfirmed: entry.modelData.forget() } } } diff --git a/config/dot/quickshell/modules/settings/FocusModeRow.qml b/config/dot/quickshell/modules/settings/FocusModeRow.qml index 5234d35..4f0e7eb 100644 --- a/config/dot/quickshell/modules/settings/FocusModeRow.qml +++ b/config/dot/quickshell/modules/settings/FocusModeRow.qml @@ -529,16 +529,29 @@ Column { SettingRow { width: parent.width label: "Delete this mode" - detail: "Removes it and its exceptions for good" - controlWidth: 90 + // The permanence is said on the armed press, where it is the + // question being answered, rather than under a button that had + // already done it by the time the sentence was read. + detail: deleteConfirm.armed + ? "Removes " + (root.modeName === "" ? "this mode" : root.modeName) + + (root.allow.length === 0 + ? "" + : " and its " + root.allow.length + + (root.allow.length === 1 ? " exception" : " exceptions")) + + " for good" + : "Takes it out of the list" + controlWidth: deleteConfirm.armed ? 180 : 90 divider: false - SettingsButton { + ConfirmAction { + id: deleteConfirm + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - text: "Delete" - tone: "danger" - onClicked: FocusModes.removeMode(root.modeId) + actionId: "focus-mode-delete:" + root.modeId + armText: "Delete…" + confirmText: "Delete it" + onConfirmed: FocusModes.removeMode(root.modeId) } } } diff --git a/config/dot/quickshell/modules/settings/HomeFavoriteCard.qml b/config/dot/quickshell/modules/settings/HomeFavoriteCard.qml index 03cc967..844d93e 100644 --- a/config/dot/quickshell/modules/settings/HomeFavoriteCard.qml +++ b/config/dot/quickshell/modules/settings/HomeFavoriteCard.qml @@ -65,7 +65,7 @@ Rectangle { id: aliasFrame anchors.left: reorderControls.right anchors.leftMargin: 10 - anchors.right: removeButton.left + anchors.right: removeConfirm.left anchors.rightMargin: 12 anchors.top: parent.top anchors.topMargin: 12 @@ -107,17 +107,23 @@ Rectangle { } } + // The line under the field says which light this is; armed, it says what + // pressing again costs instead, because the alias only lives in this + // favourite and there is nowhere else to read it back from. Text { anchors.left: aliasFrame.left - anchors.right: removeButton.left + anchors.right: removeConfirm.left anchors.rightMargin: 12 anchors.top: aliasFrame.bottom anchors.topMargin: 7 - text: root.sourceName - color: Theme.fgDim + text: removeConfirm.armed + ? "Drops it from My Home — the name you gave it goes too" + : root.sourceName + color: removeConfirm.armed ? Theme.danger : Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall - elide: Text.ElideRight + wrapMode: removeConfirm.armed ? Text.WordWrap : Text.NoWrap + elide: removeConfirm.armed ? Text.ElideNone : Text.ElideRight } Rectangle { @@ -143,17 +149,14 @@ Rectangle { } } - SettingsButton { - id: removeButton + ConfirmAction { + id: removeConfirm anchors.right: parent.right anchors.rightMargin: 11 anchors.verticalCenter: parent.verticalCenter - text: "Remove" - activeFocusOnTab: true - border.width: activeFocus ? 2 : 1 - border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) - onClicked: root.removeRequested(root.favorite.id) - Keys.onReturnPressed: root.removeRequested(root.favorite.id) - Keys.onSpacePressed: root.removeRequested(root.favorite.id) + actionId: "home-favorite-remove:" + root.favorite.id + armText: "Remove…" + confirmText: "Remove it" + onConfirmed: root.removeRequested(root.favorite.id) } } diff --git a/config/dot/quickshell/modules/settings/NotificationsPage.qml b/config/dot/quickshell/modules/settings/NotificationsPage.qml index 07ef924..abee835 100644 --- a/config/dot/quickshell/modules/settings/NotificationsPage.qml +++ b/config/dot/quickshell/modules/settings/NotificationsPage.qml @@ -246,15 +246,33 @@ SettingsPage { SliderRow { setting: "maxVisibleToasts" } SliderRow { setting: "notificationHistoryLimit" } - ActionRow { + // A SettingRow with its own ConfirmAction rather than an ActionRow: + // clearing history is not undoable and it takes the inline replies and + // actions with it, so it takes two presses and the armed detail names + // both the count and what stops being answerable. + SettingRow { label: "Notification history" - detail: Notifs.history.length === 1 - ? "1 kept now · the oldest are dropped past the limit" - : `${Notifs.history.length} kept now · the oldest are dropped past the limit` - action: "Clear" - enabled: Notifs.history.length > 0 + detail: clearConfirm.armed + ? (Notifs.history.length === 1 + ? "Drops the 1 kept notification — its inline reply and actions go with it" + : `Drops all ${Notifs.history.length} kept notifications — their inline replies and actions go with them`) + : (Notifs.history.length === 1 + ? "1 kept now · the oldest are dropped past the limit" + : `${Notifs.history.length} kept now · the oldest are dropped past the limit`) + controlWidth: clearConfirm.armed ? 200 : 110 divider: false - onTriggered: Notifs.dismissAll() + + ConfirmAction { + id: clearConfirm + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + actionId: "notification-history-clear" + armText: "Clear…" + confirmText: "Clear them" + enabled: Notifs.history.length > 0 + onConfirmed: Notifs.dismissAll() + } } } diff --git a/config/dot/quickshell/modules/settings/ShortcutsPage.qml b/config/dot/quickshell/modules/settings/ShortcutsPage.qml index d14c357..475ccdd 100644 --- a/config/dot/quickshell/modules/settings/ShortcutsPage.qml +++ b/config/dot/quickshell/modules/settings/ShortcutsPage.qml @@ -408,17 +408,35 @@ SettingsPage { // Rebinding stores only the new chord; what a shortcut does always // comes from the desktop's configuration. - ActionRow { + // + // A SettingRow with its own ConfirmAction rather than an ActionRow: + // this one press drops every override at once and reloads the live + // compositor, so it takes two, and the armed detail says both. + SettingRow { label: "Restore every shipped shortcut" - detail: root.overrideCount === 0 - ? "Every shortcut is where it shipped" - : root.overrideCount + (root.overrideCount === 1 - ? " shortcut differs from the shipped keymap" - : " shortcuts differ from the shipped keymap") - action: "Restore all" + detail: restoreConfirm.armed + ? "Puts " + root.overrideCount + (root.overrideCount === 1 + ? " changed shortcut" : " changed shortcuts") + + " back where they shipped and reloads the compositor now" + : (root.overrideCount === 0 + ? "Every shortcut is where it shipped" + : root.overrideCount + (root.overrideCount === 1 + ? " shortcut differs from the shipped keymap" + : " shortcuts differ from the shipped keymap")) + controlWidth: restoreConfirm.armed ? 220 : 130 divider: false - enabled: root.overrideCount > 0 && !Keybinds.reloading - onTriggered: Keybinds.resetAll() + + ConfirmAction { + id: restoreConfirm + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + actionId: "keybinds-restore-all" + armText: "Restore all…" + confirmText: "Restore them" + enabled: root.overrideCount > 0 && !Keybinds.reloading + onConfirmed: Keybinds.resetAll() + } } } diff --git a/config/dot/quickshell/modules/settings/ThemeCard.qml b/config/dot/quickshell/modules/settings/ThemeCard.qml index b751e84..52f044c 100644 --- a/config/dot/quickshell/modules/settings/ThemeCard.qml +++ b/config/dot/quickshell/modules/settings/ThemeCard.qml @@ -30,13 +30,54 @@ Rectangle { readonly property bool isDefault: root.theme.id === (root.theme.scheme === "light" ? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark) + // Delete arms in place rather than through ConfirmAction. The ✕ badge is a + // 22px corner of a card whose whole body is the apply target roughly ten + // pixels away -- there is nowhere in that caption to put a Keep beside a + // Delete it without the pair becoming the card. So the badge itself is the + // two presses: the first turns it into a labelled danger pill and reddens + // the card's border, the second deletes. The token is ShellState's, the + // same one ConfirmAction uses, so one confirm is armed app-wide. + readonly property string removeActionId: "theme-card-delete:" + root.theme.id + readonly property bool removeArmed: ShellState.armedConfirm === root.removeActionId + + function pressRemove(): void { + if (!root.removeArmed) { + ShellState.armedConfirm = root.removeActionId; + return; + } + root.disarmRemove(); + root.removed(); + } + + function disarmRemove(): void { + if (root.removeArmed) + ShellState.armedConfirm = ""; + } + + // Armed, the card body is the way out: a press anywhere on it takes the + // arming back instead of applying the theme, because the miss this guards + // against is a press landing next to the badge rather than on it. + function press(): void { + if (root.removeArmed) { + root.disarmRemove(); + return; + } + root.chosen(); + } + + // A card that scrolls away, or a gallery that rebuilds, must not leave the + // token claiming a theme nothing is showing. + Component.onDestruction: root.disarmRemove() + implicitHeight: preview.height + caption.height + 4 radius: Theme.cardRadius + 2 color: Theme.alpha(Theme.fg, 0.04) border.width: 2 - border.color: root.activeFocus - ? Theme.accentSecondary - : (root.active ? Theme.accent : Theme.alpha(Theme.fg, 0.12)) + border.color: root.removeArmed + ? Theme.danger + : (root.activeFocus + ? Theme.accentSecondary + : (root.active ? Theme.accent : Theme.alpha(Theme.fg, 0.12))) clip: true activeFocusOnTab: true @@ -44,11 +85,13 @@ Rectangle { Accessible.name: root.theme.name Accessible.description: (root.theme.scheme === "light" ? "Light theme" : "Dark theme") + (root.active ? ", selected" : "") + + (root.removeArmed ? ", delete is armed — press this card to keep it" : "") Accessible.focusable: true Accessible.focused: root.activeFocus - Keys.onReturnPressed: root.chosen() - Keys.onSpacePressed: root.chosen() + Keys.onReturnPressed: root.press() + Keys.onSpacePressed: root.press() + Keys.onEscapePressed: root.disarmRemove() Rectangle { id: preview @@ -148,36 +191,52 @@ Rectangle { // Only saved themes can be removed, and the affordance is a // separate focus stop so Tab never lands on "delete" when the // person meant "select". + // + // At rest the badge is neutral: danger marks the press that + // confirms, never the one that initiates, which is SettingsButton's + // contract and holds here too. Armed, it stops being a glyph and + // becomes a labelled pill wide enough to read. Rectangle { id: remove anchors.verticalCenter: parent.verticalCenter visible: !root.shipped - width: 22 + width: root.removeArmed ? removeLabel.implicitWidth + 16 : 22 height: 22 radius: 7 - color: removeHover.hovered - ? Theme.alpha(Theme.danger, 0.22) - : Theme.alpha(Theme.fg, 0.07) - border.width: remove.activeFocus ? 2 : 1 - border.color: remove.activeFocus + color: root.removeArmed + ? Theme.alpha(Theme.danger, 0.3) + : (removeHover.hovered + ? Theme.alpha(Theme.fg, 0.14) + : Theme.alpha(Theme.fg, 0.07)) + border.width: root.removeArmed || remove.activeFocus ? 2 : 1 + border.color: root.removeArmed || remove.activeFocus ? Theme.danger : Theme.alpha(Theme.fg, 0.08) activeFocusOnTab: visible Accessible.role: Accessible.Button - Accessible.name: "Delete " + root.theme.name + Accessible.name: root.removeArmed + ? "Delete " + root.theme.name + ", press again to confirm" + : "Delete " + root.theme.name + Accessible.description: root.removeArmed + ? "This theme's palette, terminal colours and effects go with it" + : "" Accessible.focusable: true Accessible.focused: remove.activeFocus - Keys.onReturnPressed: root.removed() - Keys.onSpacePressed: root.removed() + Keys.onReturnPressed: root.pressRemove() + Keys.onSpacePressed: root.pressRemove() + Keys.onEscapePressed: root.disarmRemove() Text { + id: removeLabel + anchors.centerIn: parent - text: "✕" - color: Theme.danger + text: root.removeArmed ? "Delete it" : "✕" + color: root.removeArmed ? Theme.danger : Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall + font.weight: root.removeArmed ? Font.DemiBold : Font.Normal } HoverHandler { @@ -190,7 +249,7 @@ Rectangle { // on its way out. TapHandler { gesturePolicy: TapHandler.ReleaseWithinBounds - onTapped: root.removed() + onTapped: root.pressRemove() } } } @@ -202,6 +261,6 @@ Rectangle { } TapHandler { - onTapped: root.chosen() + onTapped: root.press() } } diff --git a/config/dot/quickshell/modules/settings/ThemeSaveRow.qml b/config/dot/quickshell/modules/settings/ThemeSaveRow.qml index aac120d..8534c4e 100644 --- a/config/dot/quickshell/modules/settings/ThemeSaveRow.qml +++ b/config/dot/quickshell/modules/settings/ThemeSaveRow.qml @@ -15,11 +15,16 @@ SettingRow { readonly property bool custom: ThemeProfiles.activeProfile.shipped !== true label: "Save as" - detail: root.custom - ? "Saving again under a new name keeps both" - : "Editing a shipped theme forks it; give the fork a name to keep it" + // Armed, the row says what the delete actually costs: a saved theme is the + // only copy of its palette, terminal colours and effect values. + detail: deleteConfirm.armed + ? "Deletes “" + String(ThemeProfiles.activeProfile.name ?? "this theme") + + "” — its palette, terminal colours and effects go with it" + : (root.custom + ? "Saving again under a new name keeps both" + : "Editing a shipped theme forks it; give the fork a name to keep it") divider: false - controlWidth: root.custom ? 380 : 292 + controlWidth: root.custom ? (deleteConfirm.armed ? 470 : 380) : 292 Row { anchors.right: parent.right @@ -83,23 +88,14 @@ SettingRow { Keys.onSpacePressed: saveButton.save() } - SettingsButton { - id: deleteButton + ConfirmAction { + id: deleteConfirm visible: root.custom - text: "Delete" - tone: "danger" - activeFocusOnTab: visible - border.width: activeFocus ? 2 : 1 - border.color: activeFocus ? Theme.danger : Theme.alpha(Theme.danger, 0.45) - - function remove(): void { - ThemeProfiles.deleteProfile(ThemeProfiles.activeId); - } - - onClicked: deleteButton.remove() - Keys.onReturnPressed: deleteButton.remove() - Keys.onSpacePressed: deleteButton.remove() + actionId: "theme-profile-delete:" + ThemeProfiles.activeId + armText: "Delete…" + confirmText: "Delete it" + onConfirmed: ThemeProfiles.deleteProfile(ThemeProfiles.activeId) } } }