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; 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: { onActiveChanged: {
if (root.active) if (root.active) {
Connectivity.acquireDiscovery(root.scanHold); Connectivity.acquireDiscovery(root.scanHold);
else } else {
Connectivity.releaseDiscovery(root.scanHold); Connectivity.releaseDiscovery(root.scanHold);
root.disarmForget();
}
} }
Component.onDestruction: Connectivity.releaseDiscovery(root.scanHold) Component.onDestruction: Connectivity.releaseDiscovery(root.scanHold)
@@ -106,18 +131,36 @@ Item {
required property var modelData required property var modelData
readonly property bool forgetArmed:
ShellState.armedConfirm === root.forgetId(deviceRow.modelData)
width: parent.width width: parent.width
// BlueZ reports a plain freedesktop name ("audio-headphones"); // BlueZ reports a plain freedesktop name ("audio-headphones");
// the symbolic variant is the one that can be recolored. // the symbolic variant is the one that can be recolored.
icon: deviceRow.modelData.icon !== "" ? deviceRow.modelData.icon + "-symbolic" : "bluetooth-symbolic" icon: deviceRow.modelData.icon !== "" ? deviceRow.modelData.icon + "-symbolic" : "bluetooth-symbolic"
iconFallback: "bluetooth-symbolic" iconFallback: "bluetooth-symbolic"
label: deviceRow.modelData.name || deviceRow.modelData.address 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 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 // 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 { IconButton {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: deviceRow.modelData.paired visible: deviceRow.modelData.paired
@@ -125,7 +168,28 @@ Item {
iconSize: 13 iconSize: 13
icon: "user-trash-symbolic" icon: "user-trash-symbolic"
iconFallback: "window-close-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 width: parent.width
label: entry.modelData.name || entry.modelData.address || "Unknown device" 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 divider: entry.index < root.shown.length - 1
|| root.hiddenCount > 0 || root.showAll || 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 activatable: !entry.modelData.pairing
onActivated: root.primaryAction(entry.modelData) onActivated: root.primaryAction(entry.modelData)
@@ -87,11 +93,17 @@ Column {
onClicked: root.primaryAction(entry.modelData) onClicked: root.primaryAction(entry.modelData)
} }
SettingsButton { ConfirmAction {
id: forgetConfirm
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: entry.modelData.paired visible: entry.modelData.paired
text: "Forget" actionId: "bluetooth-forget:"
onClicked: entry.modelData.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 { SettingRow {
width: parent.width width: parent.width
label: "Delete this mode" label: "Delete this mode"
detail: "Removes it and its exceptions for good" // The permanence is said on the armed press, where it is the
controlWidth: 90 // 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 divider: false
SettingsButton { ConfirmAction {
id: deleteConfirm
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "Delete" actionId: "focus-mode-delete:" + root.modeId
tone: "danger" armText: "Delete…"
onClicked: FocusModes.removeMode(root.modeId) confirmText: "Delete it"
onConfirmed: FocusModes.removeMode(root.modeId)
} }
} }
} }
@@ -65,7 +65,7 @@ Rectangle {
id: aliasFrame id: aliasFrame
anchors.left: reorderControls.right anchors.left: reorderControls.right
anchors.leftMargin: 10 anchors.leftMargin: 10
anchors.right: removeButton.left anchors.right: removeConfirm.left
anchors.rightMargin: 12 anchors.rightMargin: 12
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: 12 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 { Text {
anchors.left: aliasFrame.left anchors.left: aliasFrame.left
anchors.right: removeButton.left anchors.right: removeConfirm.left
anchors.rightMargin: 12 anchors.rightMargin: 12
anchors.top: aliasFrame.bottom anchors.top: aliasFrame.bottom
anchors.topMargin: 7 anchors.topMargin: 7
text: root.sourceName text: removeConfirm.armed
color: Theme.fgDim ? "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.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight wrapMode: removeConfirm.armed ? Text.WordWrap : Text.NoWrap
elide: removeConfirm.armed ? Text.ElideNone : Text.ElideRight
} }
Rectangle { Rectangle {
@@ -143,17 +149,14 @@ Rectangle {
} }
} }
SettingsButton { ConfirmAction {
id: removeButton id: removeConfirm
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 11 anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "Remove" actionId: "home-favorite-remove:" + root.favorite.id
activeFocusOnTab: true armText: "Remove…"
border.width: activeFocus ? 2 : 1 confirmText: "Remove it"
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08) onConfirmed: root.removeRequested(root.favorite.id)
onClicked: root.removeRequested(root.favorite.id)
Keys.onReturnPressed: root.removeRequested(root.favorite.id)
Keys.onSpacePressed: root.removeRequested(root.favorite.id)
} }
} }
@@ -246,15 +246,33 @@ SettingsPage {
SliderRow { setting: "maxVisibleToasts" } SliderRow { setting: "maxVisibleToasts" }
SliderRow { setting: "notificationHistoryLimit" } 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" label: "Notification history"
detail: Notifs.history.length === 1 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" ? "1 kept now · the oldest are dropped past the limit"
: `${Notifs.history.length} kept now · the oldest are dropped past the limit` : `${Notifs.history.length} kept now · the oldest are dropped past the limit`)
action: "Clear" controlWidth: clearConfirm.armed ? 200 : 110
enabled: Notifs.history.length > 0
divider: false 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 // Rebinding stores only the new chord; what a shortcut does always
// comes from the desktop's configuration. // 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" label: "Restore every shipped shortcut"
detail: root.overrideCount === 0 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" ? "Every shortcut is where it shipped"
: root.overrideCount + (root.overrideCount === 1 : root.overrideCount + (root.overrideCount === 1
? " shortcut differs from the shipped keymap" ? " shortcut differs from the shipped keymap"
: " shortcuts differ from the shipped keymap") : " shortcuts differ from the shipped keymap"))
action: "Restore all" controlWidth: restoreConfirm.armed ? 220 : 130
divider: false divider: false
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 enabled: root.overrideCount > 0 && !Keybinds.reloading
onTriggered: Keybinds.resetAll() onConfirmed: Keybinds.resetAll()
}
} }
} }
@@ -30,13 +30,54 @@ Rectangle {
readonly property bool isDefault: root.theme.id === (root.theme.scheme === "light" readonly property bool isDefault: root.theme.id === (root.theme.scheme === "light"
? ThemeCatalog.defaultLight : ThemeCatalog.defaultDark) ? 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 implicitHeight: preview.height + caption.height + 4
radius: Theme.cardRadius + 2 radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.fg, 0.04) color: Theme.alpha(Theme.fg, 0.04)
border.width: 2 border.width: 2
border.color: root.activeFocus border.color: root.removeArmed
? Theme.danger
: (root.activeFocus
? Theme.accentSecondary ? Theme.accentSecondary
: (root.active ? Theme.accent : Theme.alpha(Theme.fg, 0.12)) : (root.active ? Theme.accent : Theme.alpha(Theme.fg, 0.12)))
clip: true clip: true
activeFocusOnTab: true activeFocusOnTab: true
@@ -44,11 +85,13 @@ Rectangle {
Accessible.name: root.theme.name Accessible.name: root.theme.name
Accessible.description: (root.theme.scheme === "light" ? "Light theme" : "Dark theme") Accessible.description: (root.theme.scheme === "light" ? "Light theme" : "Dark theme")
+ (root.active ? ", selected" : "") + (root.active ? ", selected" : "")
+ (root.removeArmed ? ", delete is armed — press this card to keep it" : "")
Accessible.focusable: true Accessible.focusable: true
Accessible.focused: root.activeFocus Accessible.focused: root.activeFocus
Keys.onReturnPressed: root.chosen() Keys.onReturnPressed: root.press()
Keys.onSpacePressed: root.chosen() Keys.onSpacePressed: root.press()
Keys.onEscapePressed: root.disarmRemove()
Rectangle { Rectangle {
id: preview id: preview
@@ -148,36 +191,52 @@ Rectangle {
// Only saved themes can be removed, and the affordance is a // Only saved themes can be removed, and the affordance is a
// separate focus stop so Tab never lands on "delete" when the // separate focus stop so Tab never lands on "delete" when the
// person meant "select". // 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 { Rectangle {
id: remove id: remove
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: !root.shipped visible: !root.shipped
width: 22 width: root.removeArmed ? removeLabel.implicitWidth + 16 : 22
height: 22 height: 22
radius: 7 radius: 7
color: removeHover.hovered color: root.removeArmed
? Theme.alpha(Theme.danger, 0.22) ? Theme.alpha(Theme.danger, 0.3)
: Theme.alpha(Theme.fg, 0.07) : (removeHover.hovered
border.width: remove.activeFocus ? 2 : 1 ? Theme.alpha(Theme.fg, 0.14)
border.color: remove.activeFocus : 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) ? Theme.danger : Theme.alpha(Theme.fg, 0.08)
activeFocusOnTab: visible activeFocusOnTab: visible
Accessible.role: Accessible.Button 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.focusable: true
Accessible.focused: remove.activeFocus Accessible.focused: remove.activeFocus
Keys.onReturnPressed: root.removed() Keys.onReturnPressed: root.pressRemove()
Keys.onSpacePressed: root.removed() Keys.onSpacePressed: root.pressRemove()
Keys.onEscapePressed: root.disarmRemove()
Text { Text {
id: removeLabel
anchors.centerIn: parent anchors.centerIn: parent
text: "✕" text: root.removeArmed ? "Delete it" : "✕"
color: Theme.danger color: root.removeArmed ? Theme.danger : Theme.fgDim
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
font.weight: root.removeArmed ? Font.DemiBold : Font.Normal
} }
HoverHandler { HoverHandler {
@@ -190,7 +249,7 @@ Rectangle {
// on its way out. // on its way out.
TapHandler { TapHandler {
gesturePolicy: TapHandler.ReleaseWithinBounds gesturePolicy: TapHandler.ReleaseWithinBounds
onTapped: root.removed() onTapped: root.pressRemove()
} }
} }
} }
@@ -202,6 +261,6 @@ Rectangle {
} }
TapHandler { TapHandler {
onTapped: root.chosen() onTapped: root.press()
} }
} }
@@ -15,11 +15,16 @@ SettingRow {
readonly property bool custom: ThemeProfiles.activeProfile.shipped !== true readonly property bool custom: ThemeProfiles.activeProfile.shipped !== true
label: "Save as" label: "Save as"
detail: root.custom // 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" ? "Saving again under a new name keeps both"
: "Editing a shipped theme forks it; give the fork a name to keep it" : "Editing a shipped theme forks it; give the fork a name to keep it")
divider: false divider: false
controlWidth: root.custom ? 380 : 292 controlWidth: root.custom ? (deleteConfirm.armed ? 470 : 380) : 292
Row { Row {
anchors.right: parent.right anchors.right: parent.right
@@ -83,23 +88,14 @@ SettingRow {
Keys.onSpacePressed: saveButton.save() Keys.onSpacePressed: saveButton.save()
} }
SettingsButton { ConfirmAction {
id: deleteButton id: deleteConfirm
visible: root.custom visible: root.custom
text: "Delete" actionId: "theme-profile-delete:" + ThemeProfiles.activeId
tone: "danger" armText: "Delete…"
activeFocusOnTab: visible confirmText: "Delete it"
border.width: activeFocus ? 2 : 1 onConfirmed: ThemeProfiles.deleteProfile(ThemeProfiles.activeId)
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()
} }
} }
} }