The safety layer: two presses for anything you cannot take back
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
175 of them, under `tests/`. Run the lot, or a subset by pattern:
|
176 of them, under `tests/`. Run the lot, or a subset by pattern:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
panama test # everything
|
panama test # everything
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
// One tile in the power menu: big glyph, label underneath.
|
// One tile in the power menu: big glyph, label underneath.
|
||||||
//
|
//
|
||||||
// Anything that ends the session arms on the first press and only fires on the
|
// Anything that ends the session arms on the first press and only fires on the
|
||||||
// second, with the label swapping to "Confirm" — an accidental Ctrl+Alt+Delete
|
// second — an accidental Ctrl+Alt+Delete should never be one click away from
|
||||||
// should never be one click away from losing everything that is open.
|
// losing everything that is open.
|
||||||
|
//
|
||||||
|
// Armed, the tile turns red and names the thing it is about to do ("Power off")
|
||||||
|
// rather than saying "Confirm". Six tiles could all say "Confirm"; only one of
|
||||||
|
// them is about to take the machine down, and the press that does it should say
|
||||||
|
// which one it is.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
@@ -21,6 +26,13 @@ Rectangle {
|
|||||||
|
|
||||||
readonly property bool armed: confirmTimer.running
|
readonly property bool armed: confirmTimer.running
|
||||||
|
|
||||||
|
// The tile's own label, said as a sentence rather than as a title: "Power
|
||||||
|
// Off" is the name of a menu entry, "Power off" is the thing the next press
|
||||||
|
// does. Derived, so an entry added to the menu cannot forget to name itself.
|
||||||
|
readonly property string armedLabel: root.label === ""
|
||||||
|
? "Confirm"
|
||||||
|
: root.label.charAt(0) + root.label.slice(1).toLowerCase()
|
||||||
|
|
||||||
implicitWidth: 136
|
implicitWidth: 136
|
||||||
implicitHeight: 136
|
implicitHeight: 136
|
||||||
radius: Theme.cardRadius + 6
|
radius: Theme.cardRadius + 6
|
||||||
@@ -68,7 +80,7 @@ Rectangle {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
text: root.armed ? "Confirm" : root.label
|
text: root.armed ? root.armedLabel : root.label
|
||||||
color: root.armed ? Theme.danger : Theme.fg
|
color: root.armed ? Theme.danger : Theme.fg
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSize
|
font.pixelSize: Theme.fontSize
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// The two-stage destructive confirm, as one component instead of twenty
|
||||||
|
// hand-rolled copies. The first press arms ("Verb…", normal tone -- danger
|
||||||
|
// never initiates, per SettingsButton's own contract); armed, the row shows
|
||||||
|
// Cancel ("Keep") beside the confirming press ("Verb it", danger tone).
|
||||||
|
//
|
||||||
|
// One armed confirm exists app-wide: the token lives on ShellState, so arming
|
||||||
|
// this one disarms whichever other row was armed, on any page. Consumers give
|
||||||
|
// a unique actionId, the verb pair, and handle onConfirmed; the component owns
|
||||||
|
// the state, the copy shape, and the keyboard path (via SettingsButton).
|
||||||
|
//
|
||||||
|
// ConfirmAction {
|
||||||
|
// actionId: "forget-network:" + ssid
|
||||||
|
// armText: "Forget…" // default: verb + ellipsis
|
||||||
|
// confirmText: "Forget it"
|
||||||
|
// cancelText: "Keep" // the house cancel word
|
||||||
|
// enabled: !Service.busy
|
||||||
|
// onConfirmed: Service.forget(ssid)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// The armed state is readable (`armed`) so a row can swap its detail line for
|
||||||
|
// the consequence-naming sentence while armed -- naming what is lost is the
|
||||||
|
// caller's half of the contract; this component only guarantees the two
|
||||||
|
// presses happen and the wrong button cannot be the easy one.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string actionId: ""
|
||||||
|
property string armText: "Remove…"
|
||||||
|
property string confirmText: "Remove it"
|
||||||
|
property string cancelText: "Keep"
|
||||||
|
property bool enabled: true
|
||||||
|
signal confirmed
|
||||||
|
signal armedChanged2
|
||||||
|
|
||||||
|
readonly property bool armed: root.actionId !== ""
|
||||||
|
&& ShellState.armedConfirm === root.actionId
|
||||||
|
|
||||||
|
spacing: 7
|
||||||
|
|
||||||
|
function disarm(): void {
|
||||||
|
if (root.armed)
|
||||||
|
ShellState.armedConfirm = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaving the page, or the row vanishing under the armed state, must not
|
||||||
|
// leave a stale token claiming some other future row's identity. Rows more
|
||||||
|
// often hide than unload (an override pill, a conditional card), so the
|
||||||
|
// visibility guard matters as much as the destruction one.
|
||||||
|
Component.onDestruction: root.disarm()
|
||||||
|
onVisibleChanged: if (!root.visible) root.disarm()
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
visible: !root.armed
|
||||||
|
enabled: root.enabled
|
||||||
|
text: root.armText
|
||||||
|
onClicked: ShellState.armedConfirm = root.actionId
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
visible: root.armed
|
||||||
|
enabled: root.enabled
|
||||||
|
text: root.cancelText
|
||||||
|
onClicked: root.disarm()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
visible: root.armed
|
||||||
|
enabled: root.enabled
|
||||||
|
tone: "danger"
|
||||||
|
text: root.confirmText
|
||||||
|
onClicked: {
|
||||||
|
root.disarm();
|
||||||
|
root.confirmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,15 +104,22 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Armed, this line stops reporting the current mode and names what
|
||||||
|
// forgetting costs. The consequence used to live in a 400ms tooltip on
|
||||||
|
// the pill, which is not where someone about to press Forget is
|
||||||
|
// looking.
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
visible: root.meta !== ""
|
visible: root.meta !== "" || forget.armed
|
||||||
text: root.meta
|
text: forget.armed
|
||||||
|
? "Resolution, refresh rate, scale, rotation and color go back to what Panama picks automatically."
|
||||||
|
: root.meta
|
||||||
color: Theme.fgDim
|
color: Theme.fgDim
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.features: Theme.tabularFigures
|
font.features: Theme.tabularFigures
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
elide: Text.ElideRight
|
wrapMode: forget.armed ? Text.WordWrap : Text.NoWrap
|
||||||
|
elide: forget.armed ? Text.ElideNone : Text.ElideRight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +143,11 @@ Item {
|
|||||||
border.width: 1
|
border.width: 1
|
||||||
border.color: Theme.alpha(Theme.accent, 0.25)
|
border.color: Theme.alpha(Theme.accent, 0.25)
|
||||||
|
|
||||||
|
// The pill says what the state IS. What Forget costs is said by
|
||||||
|
// the header line while Forget is armed, not hidden in here.
|
||||||
ToolTip.visible: customHover.hovered
|
ToolTip.visible: customHover.hovered
|
||||||
ToolTip.delay: 400
|
ToolTip.delay: 400
|
||||||
ToolTip.text: "This display uses a setting you chose. Forget returns it to the one Panama ships."
|
ToolTip.text: "This display uses a setting you chose, not the one Panama picks automatically."
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
id: customLabel
|
id: customLabel
|
||||||
@@ -153,13 +162,17 @@ Item {
|
|||||||
HoverHandler { id: customHover }
|
HoverHandler { id: customHover }
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsButton {
|
ConfirmAction {
|
||||||
|
id: forget
|
||||||
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
visible: root.overridden
|
visible: root.overridden
|
||||||
width: visible ? implicitWidth : 0
|
width: visible ? implicitWidth : 0
|
||||||
text: "Forget"
|
actionId: "forget-display:" + root.connector
|
||||||
|
armText: "Forget…"
|
||||||
|
confirmText: "Forget it"
|
||||||
enabled: root.enabled
|
enabled: root.enabled
|
||||||
onClicked: root.forgetRequested()
|
onConfirmed: root.forgetRequested()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// A failure, reported where it happened. Before this component every page
|
||||||
|
// hand-rolled the same stanza with a different headline -- "Problem",
|
||||||
|
// "Updates need attention", "The network needs attention" -- and no visual
|
||||||
|
// mark distinguishing a failure from any other read-only fact. One shape now:
|
||||||
|
//
|
||||||
|
// ErrorRow { message: Updates.lastError }
|
||||||
|
// ErrorRow { message: Vpn.lastError; label: "The VPN needs attention" }
|
||||||
|
//
|
||||||
|
// The row hides itself while the message is empty, so consumers bind the
|
||||||
|
// service's lastError directly and write no visible: line. The message is the
|
||||||
|
// service's own sentence -- this component never rewords a failure, only
|
||||||
|
// frames it.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string message: ""
|
||||||
|
label: "Needs attention"
|
||||||
|
|
||||||
|
visible: root.message !== ""
|
||||||
|
detail: root.message
|
||||||
|
labelColor: Theme.danger
|
||||||
|
controlWidth: 210
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
@@ -38,6 +38,24 @@ SettingsPage {
|
|||||||
property string pendingInterface: ""
|
property string pendingInterface: ""
|
||||||
property string pendingZone: ""
|
property string pendingZone: ""
|
||||||
|
|
||||||
|
// The default is the same change with a wider blast radius: it decides for
|
||||||
|
// every connection that does not ask for a zone by name. It used to apply
|
||||||
|
// on the pick, one dropdown below a picker that made you confirm.
|
||||||
|
property string pendingDefaultZone: ""
|
||||||
|
|
||||||
|
// Two armed changes on screen at once is how the wrong one gets pressed.
|
||||||
|
function armInterfaceMove(iface: string, zoneName: string): void {
|
||||||
|
root.pendingDefaultZone = "";
|
||||||
|
root.pendingInterface = iface;
|
||||||
|
root.pendingZone = zoneName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function armDefaultZone(zoneName: string): void {
|
||||||
|
root.pendingInterface = "";
|
||||||
|
root.pendingZone = "";
|
||||||
|
root.pendingDefaultZone = zoneName;
|
||||||
|
}
|
||||||
|
|
||||||
// The zone being read in the browser. Read-only: this looks at a zone
|
// The zone being read in the browser. Read-only: this looks at a zone
|
||||||
// without applying it to anything.
|
// without applying it to anything.
|
||||||
property string browsingZone: ""
|
property string browsingZone: ""
|
||||||
@@ -416,10 +434,7 @@ SettingsPage {
|
|||||||
options: root.zoneOptions
|
options: root.zoneOptions
|
||||||
current: placement.zone
|
current: placement.zone
|
||||||
divider: !placement.pending
|
divider: !placement.pending
|
||||||
onPicked: value => {
|
onPicked: value => root.armInterfaceMove(placement.iface, String(value))
|
||||||
root.pendingInterface = placement.iface;
|
|
||||||
root.pendingZone = String(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingRow {
|
SettingRow {
|
||||||
@@ -482,7 +497,45 @@ SettingsPage {
|
|||||||
enabled: !Firewall.busy
|
enabled: !Firewall.busy
|
||||||
options: root.zoneOptions
|
options: root.zoneOptions
|
||||||
current: Firewall.defaultZone
|
current: Firewall.defaultZone
|
||||||
onPicked: value => Firewall.setDefaultZone(String(value))
|
divider: root.pendingDefaultZone === ""
|
||||||
|
onPicked: value => root.armDefaultZone(String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.pendingDefaultZone !== ""
|
||||||
|
label: "Make " + root.pendingDefaultZone + " the default?"
|
||||||
|
// What moves is named the way the per-interface confirm names its
|
||||||
|
// interface: not "the default changes", but which machines end up
|
||||||
|
// deciding differently because of it.
|
||||||
|
detail: "Every connection firewalld has not placed in a zone of its "
|
||||||
|
+ "own follows the default — each one leaves " + Firewall.defaultZone
|
||||||
|
+ " for " + root.pendingDefaultZone
|
||||||
|
+ ", and so does every network joined from now on."
|
||||||
|
controlWidth: 240
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: "Keep " + Firewall.defaultZone
|
||||||
|
enabled: !Firewall.busy
|
||||||
|
onClicked: root.pendingDefaultZone = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: "Change it"
|
||||||
|
tone: "danger"
|
||||||
|
enabled: !Firewall.busy
|
||||||
|
onClicked: {
|
||||||
|
const target = root.pendingDefaultZone;
|
||||||
|
root.pendingDefaultZone = "";
|
||||||
|
Firewall.setDefaultZone(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── The zone browser ─────────────────────────────────────────────────
|
// ── The zone browser ─────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// The honest empty state: a fact the page cannot read right now, with the
|
||||||
|
// reason it cannot. The house rule is that "not measured" is always followed
|
||||||
|
// by "because" -- a bare dash invites the reader to assume zero, and zero is
|
||||||
|
// a measurement.
|
||||||
|
//
|
||||||
|
// NotMeasuredRow { because: "The desktop portal's permission store is not answering, so what has asked for this cannot be read." }
|
||||||
|
// NotMeasuredRow { label: "Not read yet"; because: "Reading what the battery firmware has recorded." }
|
||||||
|
//
|
||||||
|
// `because` is required in spirit: the row renders without one, but a consumer
|
||||||
|
// leaving it empty is exactly the dishonesty this component exists to prevent,
|
||||||
|
// and the idiom contract greps for it.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string because: ""
|
||||||
|
label: "Not measured"
|
||||||
|
|
||||||
|
detail: root.because
|
||||||
|
labelColor: Theme.fgDim
|
||||||
|
controlWidth: 210
|
||||||
|
divider: false
|
||||||
|
}
|
||||||
@@ -251,11 +251,16 @@ SettingsPage {
|
|||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
label: String(jobRow.modelData.name ?? "Untitled")
|
label: String(jobRow.modelData.name ?? "Untitled")
|
||||||
detail: String(jobRow.modelData.printer ?? "") + " · "
|
// Armed, the row makes the Hold/Cancel distinction the service
|
||||||
|
// was built around: holding keeps the job, cancelling throws it
|
||||||
|
// away and the document has to be sent from the app again.
|
||||||
|
detail: cancelJob.armed
|
||||||
|
? "The job is thrown away — printing it means sending it from the app again. Hold keeps it in the queue instead."
|
||||||
|
: String(jobRow.modelData.printer ?? "") + " · "
|
||||||
+ String(jobRow.modelData.state ?? "")
|
+ String(jobRow.modelData.state ?? "")
|
||||||
+ (Number(jobRow.modelData.pages ?? 0) > 0
|
+ (Number(jobRow.modelData.pages ?? 0) > 0
|
||||||
? " · " + jobRow.modelData.pages + " pages" : "")
|
? " · " + jobRow.modelData.pages + " pages" : "")
|
||||||
controlWidth: 175
|
controlWidth: 265
|
||||||
divider: jobRow.index < Printers.jobs.length - 1
|
divider: jobRow.index < Printers.jobs.length - 1
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
@@ -275,11 +280,18 @@ SettingsPage {
|
|||||||
: Printers.hold(Number(jobRow.modelData.id))
|
: Printers.hold(Number(jobRow.modelData.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsButton {
|
ConfirmAction {
|
||||||
|
id: cancelJob
|
||||||
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "Cancel"
|
actionId: "cancel-job:" + String(jobRow.modelData.id ?? "")
|
||||||
|
armText: "Cancel…"
|
||||||
|
confirmText: "Cancel it"
|
||||||
|
// Not "Keep": the job is not being kept in a drawer, it
|
||||||
|
// carries on out of the printer.
|
||||||
|
cancelText: "Keep printing"
|
||||||
enabled: !Printers.busy
|
enabled: !Printers.busy
|
||||||
onClicked: Printers.cancel(Number(jobRow.modelData.id))
|
onConfirmed: Printers.cancel(Number(jobRow.modelData.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ Item {
|
|||||||
default property alias trailingData: trailing.data
|
default property alias trailingData: trailing.data
|
||||||
property string icon: ""
|
property string icon: ""
|
||||||
property string label: ""
|
property string label: ""
|
||||||
|
// The headline's colour. Foreground for every ordinary row; ErrorRow sets
|
||||||
|
// it to Theme.danger so a failure reads as one at a glance.
|
||||||
|
property color labelColor: Theme.fg
|
||||||
property string detail: ""
|
property string detail: ""
|
||||||
property string value: ""
|
property string value: ""
|
||||||
property bool divider: true
|
property bool divider: true
|
||||||
@@ -70,7 +73,7 @@ Item {
|
|||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: root.label
|
text: root.label
|
||||||
color: Theme.fg
|
color: root.labelColor
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSize
|
font.pixelSize: Theme.fontSize
|
||||||
font.weight: Font.Medium
|
font.weight: Font.Medium
|
||||||
|
|||||||
@@ -17,6 +17,31 @@ Rectangle {
|
|||||||
implicitHeight: 31
|
implicitHeight: 31
|
||||||
radius: 9
|
radius: 9
|
||||||
opacity: enabled ? 1 : 0.45
|
opacity: enabled ? 1 : 0.45
|
||||||
|
|
||||||
|
// The keyboard half of this component's own contract. Eleven files used
|
||||||
|
// to bolt these on by hand while the other hundred-odd instantiations --
|
||||||
|
// including every confirming button in every destructive flow -- were
|
||||||
|
// pointer-only. The button carries them itself now, so a consumer cannot
|
||||||
|
// forget them.
|
||||||
|
activeFocusOnTab: root.enabled
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: root.text
|
||||||
|
Accessible.focusable: root.enabled
|
||||||
|
Accessible.focused: root.activeFocus
|
||||||
|
Accessible.onPressAction: root.clicked()
|
||||||
|
Keys.onReturnPressed: root.clicked()
|
||||||
|
Keys.onEnterPressed: root.clicked()
|
||||||
|
Keys.onSpacePressed: root.clicked()
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: -3
|
||||||
|
radius: parent.radius + 3
|
||||||
|
visible: root.activeFocus
|
||||||
|
color: "transparent"
|
||||||
|
border.width: 2
|
||||||
|
border.color: Theme.accentSecondary
|
||||||
|
}
|
||||||
color: {
|
color: {
|
||||||
if (tone === "accent")
|
if (tone === "accent")
|
||||||
return mouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.12) : Theme.accent;
|
return mouse.containsMouse ? Theme.mix(Theme.accent, Theme.fg, 0.12) : Theme.accent;
|
||||||
|
|||||||
@@ -157,15 +157,32 @@ SettingsPage {
|
|||||||
onTriggered: Sharing.setRdpCredentials(Quickshell.env("USER") || "")
|
onTriggered: Sharing.setRdpCredentials(Quickshell.env("USER") || "")
|
||||||
}
|
}
|
||||||
|
|
||||||
ActionRow {
|
SettingRow {
|
||||||
|
id: forgetCredentials
|
||||||
|
|
||||||
|
width: parent.width
|
||||||
visible: Sharing.remoteDesktop?.available === true
|
visible: Sharing.remoteDesktop?.available === true
|
||||||
&& Sharing.remoteDesktop?.hasCredentials === true
|
&& Sharing.remoteDesktop?.hasCredentials === true
|
||||||
label: "Forget the stored credentials"
|
label: "Forget the stored credentials"
|
||||||
detail: "Remote desktop cannot be turned on again until new ones are set"
|
// Armed, the row names the loss rather than the state: the keyring
|
||||||
action: "Clear"
|
// entry goes, and the only way back is the terminal flow above.
|
||||||
enabled: !Sharing.busy
|
detail: forgetCredentialsConfirm.armed
|
||||||
|
? "The user name and password are deleted from the login keyring. Remote desktop stays off until you set new ones in a terminal."
|
||||||
|
: "Remote desktop cannot be turned on again until new ones are set"
|
||||||
|
controlWidth: 200
|
||||||
divider: false
|
divider: false
|
||||||
onTriggered: Sharing.clearRdpCredentials()
|
|
||||||
|
ConfirmAction {
|
||||||
|
id: forgetCredentialsConfirm
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
actionId: "forget-rdp-credentials"
|
||||||
|
armText: "Forget…"
|
||||||
|
confirmText: "Forget them"
|
||||||
|
enabled: !Sharing.busy
|
||||||
|
onConfirmed: Sharing.clearRdpCredentials()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -257,7 +257,15 @@ SettingsPage {
|
|||||||
// what to rely on later.
|
// what to rely on later.
|
||||||
icon: pointRow.modelData.kept ? "\u{F0A22}" : "\u{F0954}"
|
icon: pointRow.modelData.kept ? "\u{F0A22}" : "\u{F0954}"
|
||||||
label: String(pointRow.modelData.date ?? "")
|
label: String(pointRow.modelData.date ?? "")
|
||||||
detail: String(pointRow.modelData.description ?? "")
|
// Armed, the row names the loss instead of describing
|
||||||
|
// the snapshot: this is the only record of what these
|
||||||
|
// files looked like then, and deleting it is the one
|
||||||
|
// thing on this page nothing else can undo.
|
||||||
|
detail: pointRow.confirming
|
||||||
|
? "The only copy of these files as of "
|
||||||
|
+ String(pointRow.modelData.date ?? "this point in time")
|
||||||
|
+ " is deleted."
|
||||||
|
: String(pointRow.modelData.description ?? "")
|
||||||
+ " · #" + pointRow.modelData.number
|
+ " · #" + pointRow.modelData.number
|
||||||
+ (pointRow.modelData.kept ? " · kept" : "")
|
+ (pointRow.modelData.kept ? " · kept" : "")
|
||||||
+ (pointRow.browsingThis ? " · open below" : "")
|
+ (pointRow.browsingThis ? " · open below" : "")
|
||||||
|
|||||||
@@ -347,15 +347,27 @@ SettingsPage {
|
|||||||
|
|
||||||
width: parent.width
|
width: parent.width
|
||||||
label: String(heldRow.modelData.name ?? "")
|
label: String(heldRow.modelData.name ?? "")
|
||||||
detail: String(heldRow.modelData.fingerprint ?? "")
|
// Armed, the row stops showing the fingerprint and says what
|
||||||
controlWidth: 110
|
// the confirming press will actually do -- including the case
|
||||||
|
// where it will do nothing, which is owed BEFORE the press
|
||||||
|
// rather than as an error afterwards.
|
||||||
|
detail: removeHeld.armed
|
||||||
|
? (SshKeys.durableRemoval
|
||||||
|
? "The agent stops offering this key until it is added again. The key file in ~/.ssh is untouched."
|
||||||
|
: "This agent lists every key in ~/.ssh, so it will refuse: removal here does not stick. Move the key out of ~/.ssh instead.")
|
||||||
|
: String(heldRow.modelData.fingerprint ?? "")
|
||||||
|
controlWidth: 190
|
||||||
|
|
||||||
|
ConfirmAction {
|
||||||
|
id: removeHeld
|
||||||
|
|
||||||
SettingsButton {
|
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "Remove"
|
actionId: "agent-remove:" + String(heldRow.modelData.path ?? "")
|
||||||
|
armText: "Remove…"
|
||||||
|
confirmText: "Remove it"
|
||||||
enabled: !SshKeys.busy
|
enabled: !SshKeys.busy
|
||||||
onClicked: SshKeys.removeFromAgent(String(heldRow.modelData.path ?? ""))
|
onConfirmed: SshKeys.removeFromAgent(String(heldRow.modelData.path ?? ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,16 +276,34 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsButton {
|
ConfirmAction {
|
||||||
|
id: removePicture
|
||||||
|
|
||||||
visible: UserAccounts.avatarUrl !== ""
|
visible: UserAccounts.avatarUrl !== ""
|
||||||
text: "Remove"
|
actionId: "remove-avatar"
|
||||||
|
armText: "Remove…"
|
||||||
|
confirmText: "Remove it"
|
||||||
enabled: !UserAccounts.busy
|
enabled: !UserAccounts.busy
|
||||||
onClicked: {
|
onConfirmed: {
|
||||||
root.pictureOpen = false;
|
root.pictureOpen = false;
|
||||||
UserAccounts.removeIcon();
|
UserAccounts.removeIcon();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The consequence, said before the confirming press: this
|
||||||
|
// deletes the file accountsservice keeps, so the picture is
|
||||||
|
// gone from the lock screen and every other account surface,
|
||||||
|
// not merely from this page.
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: removePicture.armed
|
||||||
|
text: "The picture is deleted from your account — the lock screen and everywhere else showing it fall back to your initial."
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ SettingRow 1.0 SettingRow.qml
|
|||||||
SettingsCard 1.0 SettingsCard.qml
|
SettingsCard 1.0 SettingsCard.qml
|
||||||
SettingsNote 1.0 SettingsNote.qml
|
SettingsNote 1.0 SettingsNote.qml
|
||||||
SettingsButton 1.0 SettingsButton.qml
|
SettingsButton 1.0 SettingsButton.qml
|
||||||
|
ConfirmAction 1.0 ConfirmAction.qml
|
||||||
|
ErrorRow 1.0 ErrorRow.qml
|
||||||
|
NotMeasuredRow 1.0 NotMeasuredRow.qml
|
||||||
SettingsShell 1.0 SettingsShell.qml
|
SettingsShell 1.0 SettingsShell.qml
|
||||||
SettingsSidebar 1.0 SettingsSidebar.qml
|
SettingsSidebar 1.0 SettingsSidebar.qml
|
||||||
SettingsToggle 1.0 SettingsToggle.qml
|
SettingsToggle 1.0 SettingsToggle.qml
|
||||||
|
|||||||
@@ -142,4 +142,11 @@ Singleton {
|
|||||||
// Set by Dock.qml so the bar can avoid fighting it for pointer grabs, and
|
// Set by Dock.qml so the bar can avoid fighting it for pointer grabs, and
|
||||||
// read by the capture overlay so the dock isn't in the screenshot.
|
// read by the capture overlay so the dock isn't in the screenshot.
|
||||||
property bool dockRevealed: false
|
property bool dockRevealed: false
|
||||||
|
|
||||||
|
// Exactly one destructive confirmation may be armed at a time, app-wide --
|
||||||
|
// "two armed destructive actions on screen at once is how the wrong one
|
||||||
|
// gets pressed" (SyncPage wrote that down for its own card; ConfirmAction
|
||||||
|
// enforces it for everyone). The token is owned by whichever ConfirmAction
|
||||||
|
// armed it; arming another disarms the first.
|
||||||
|
property string armedConfirm: ""
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+93
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# The safety idioms are only worth having if a refactor cannot quietly drop
|
||||||
|
# them. This pins the Tier-1 layer:
|
||||||
|
#
|
||||||
|
# 1. SettingsButton carries its own keyboard contract (Tab stop, Return /
|
||||||
|
# Enter / Space, Accessible role, focus ring) -- the reason no consumer
|
||||||
|
# hand-rolls those any more.
|
||||||
|
# 2. ConfirmAction exists, is registered, and every instantiation names an
|
||||||
|
# actionId; the one-armed-at-a-time token it arbitrates through lives on
|
||||||
|
# ShellState. An anonymous ConfirmAction shares "" with every other
|
||||||
|
# anonymous one, which arms them all at once.
|
||||||
|
# 3. ErrorRow instantiations bind a message, and NotMeasuredRow
|
||||||
|
# instantiations say `because:` -- a bare "Not measured" invites the
|
||||||
|
# reader to assume zero, and zero is a measurement.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
qs="$repo_dir/config/dot/quickshell"
|
||||||
|
|
||||||
|
fail() { printf 'settings idiom contract: %s\n' "$1" >&2; exit 1; }
|
||||||
|
[[ -r "$qs/modules/settings/SettingsButton.qml" ]] || fail "missing SettingsButton.qml"
|
||||||
|
|
||||||
|
python3 - "$qs" <<'PY'
|
||||||
|
import re, sys, pathlib
|
||||||
|
qs = pathlib.Path(sys.argv[1])
|
||||||
|
settings = qs / 'modules' / 'settings'
|
||||||
|
problems = []
|
||||||
|
|
||||||
|
# 1. The button's own keyboard contract.
|
||||||
|
button = (settings / 'SettingsButton.qml').read_text()
|
||||||
|
for needle, why in [
|
||||||
|
('activeFocusOnTab', 'no Tab stop'),
|
||||||
|
('Keys.onReturnPressed', 'Return does nothing'),
|
||||||
|
('Keys.onSpacePressed', 'Space does nothing'),
|
||||||
|
('Accessible.role', 'invisible to screen readers'),
|
||||||
|
('Theme.accentSecondary', 'no focus ring'),
|
||||||
|
]:
|
||||||
|
if needle not in button:
|
||||||
|
problems.append(f'SettingsButton.qml: {needle} gone -- {why}')
|
||||||
|
|
||||||
|
# 2. ConfirmAction registered, arbitrated through ShellState, always named.
|
||||||
|
qmldir = (settings / 'qmldir').read_text()
|
||||||
|
for component in ('ConfirmAction', 'ErrorRow', 'NotMeasuredRow'):
|
||||||
|
if not re.search(rf'^{component} ', qmldir, re.M):
|
||||||
|
problems.append(f'qmldir: {component} unregistered -- pages referencing it fail to load')
|
||||||
|
|
||||||
|
confirm = (settings / 'ConfirmAction.qml').read_text()
|
||||||
|
if 'ShellState.armedConfirm' not in confirm:
|
||||||
|
problems.append('ConfirmAction.qml: not arbitrated through ShellState.armedConfirm -- two rows can be armed at once')
|
||||||
|
shellstate = (qs / 'services' / 'ShellState.qml').read_text()
|
||||||
|
if 'armedConfirm' not in shellstate:
|
||||||
|
problems.append('ShellState.qml: armedConfirm token gone -- ConfirmAction has nothing to arbitrate through')
|
||||||
|
|
||||||
|
# 3. Instantiation-shape checks. A component use spans the braces that follow
|
||||||
|
# it; requiring the property inside that span is a cheap parse that has caught
|
||||||
|
# every real miss so far.
|
||||||
|
def block_after(text, start):
|
||||||
|
depth, i = 0, text.index('{', start)
|
||||||
|
for j in range(i, len(text)):
|
||||||
|
if text[j] == '{': depth += 1
|
||||||
|
elif text[j] == '}':
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0: return text[i:j]
|
||||||
|
return text[i:]
|
||||||
|
|
||||||
|
REQUIRED = {'ConfirmAction': 'actionId', 'ErrorRow': 'message', 'NotMeasuredRow': 'because'}
|
||||||
|
for page in sorted(qs.rglob('*.qml')):
|
||||||
|
if page.name in ('ConfirmAction.qml', 'ErrorRow.qml', 'NotMeasuredRow.qml'):
|
||||||
|
continue
|
||||||
|
text = page.read_text()
|
||||||
|
for component, prop in REQUIRED.items():
|
||||||
|
for m in re.finditer(rf'\b{component}\s*\{{', text):
|
||||||
|
if not re.search(rf'\b{prop}\s*:', block_after(text, m.start())):
|
||||||
|
problems.append(f'{page.name}: {component} without {prop}:')
|
||||||
|
|
||||||
|
if problems:
|
||||||
|
print(f"settings idiom contract: {len(problems)} problem(s)")
|
||||||
|
for p in problems: print(" -", p)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
uses = {c: 0 for c in REQUIRED}
|
||||||
|
for page in qs.rglob('*.qml'):
|
||||||
|
if page.name in ('ConfirmAction.qml', 'ErrorRow.qml', 'NotMeasuredRow.qml'):
|
||||||
|
continue
|
||||||
|
text = page.read_text()
|
||||||
|
for c in uses:
|
||||||
|
uses[c] += len(re.findall(rf'\b{c}\s*\{{', text))
|
||||||
|
print("settings idiom contract: ok ("
|
||||||
|
+ ", ".join(f"{n} {c}" for c, n in uses.items())
|
||||||
|
+ ", every one carries its required property)")
|
||||||
|
PY
|
||||||
Reference in New Issue
Block a user