The safety layer: two presses for anything you cannot take back

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-25 00:39:07 -04:00
parent 8b1205e4b8
commit 88371d19f0
17 changed files with 447 additions and 36 deletions
+1 -1
View File
@@ -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: ""
} }
+93
View File
@@ -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