No forgetting, deleting or clearing on a single press, anywhere

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 00:41:39 -04:00
parent 88371d19f0
commit f9e5d3f470
8 changed files with 267 additions and 84 deletions
@@ -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)
}
}
}
}
@@ -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()
}
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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()
}
}
}
@@ -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()
}
}
}
@@ -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()
}
}
@@ -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)
}
}
}