Build the settings vocabulary and generate the keymap

Stage 3 and 4 of docs/superpowers/plans/2026-08-17-panama-cohesion.md.

Add SettingsPage plus ToggleRow, SliderRow, ChoiceRow, ActionRow, and
TextRow. A row names a schema key and needs nothing else: label, detail,
range, and unit come from PreferenceSchema, and writes go through
SystemSettings.commitPreference, which routes compositor-backed keys
through apply-and-verify and local keys straight to the store. The page
scaffold that was copy-pasted eleven times is now one component.

Rebuild Appearance around a live preview of the real desktop, scaled by
the ratio between the preview and the actual monitor so a 10px gap on a
4500px display looks as small as it is. Rebuild Desktop & Dock and Input
& Shortcuts on the shared rows, replacing the read-only text that stood
in for controls that were merely expensive to add.

Generate the shortcut list from hyprctl binds. The page held a
hand-typed nineteen entries against a real keymap of a hundred and
thirteen; it could not show the rest and went stale whenever a bind
changed. Every bind now carries its own description -- backfilled for
the twenty-nine that lacked one -- and keybinds-contract.sh fails if any
bind lacks one, since undescribed binds are dropped from the page.

Make Restore defaults span every store Panama owns. Resetting only the
schema store left the Home accessory arrangement customised while
claiming to restore defaults, which is worse than no reset because it is
silent. Done through HomePreferences' existing public aliases rather
than a new API.

Four defects found while building:

cursor:inactive_timeout is answered by getoption as float, not int. A
wrong readAs does not fail loudly; it makes every write to that key look
rejected, and the user saw an error for a change that worked.
schema-hypr-shape-contract.sh now checks all 23 mapped options against
the running compositor.

The Settings window is tiled, so implicitWidth is only a hint and rows
must survive roughly 400px. SliderRow stacks its control under the label
below 520px.

Binding an anchor to undefined to switch layouts does not reliably
release it. Both row layouts are positioned explicitly.

Concurrent compositor writes are queued and merged rather than refused.
The startup replay of every compositor-backed preference routinely
overlaps a UI change, and refusing left the store and the compositor
disagreeing.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-18 00:24:51 -04:00
parent 8a04e4f9d1
commit fe7c85e471
22 changed files with 1576 additions and 200 deletions
+16
View File
@@ -67,6 +67,22 @@ wrong-typed settings file costs you your customisations and nothing else;
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
accepts the config in each of those states.
### Keybind descriptions are required
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and Panama Settings drops it from the
Input & Shortcuts page rather than showing a mystery row.
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so
this cannot regress silently.
```lua
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
```
The page groups shortcuts by what the description says they do, so a new bind
lands in the right section with no change to the UI.
### Never use `hyprctl keyword`
On a Lua-configured Hyprland it refuses the write, prints
+29 -29
View File
@@ -120,10 +120,10 @@ hl.bind(mod .. " + H", hl.dsp.focus({ direction = "l" }), { description = "Focus
hl.bind(mod .. " + J", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
hl.bind(mod .. " + K", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
hl.bind(mod .. " + L", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
hl.bind(mod .. " + left", hl.dsp.focus({ direction = "l" }))
hl.bind(mod .. " + down", hl.dsp.focus({ direction = "d" }))
hl.bind(mod .. " + up", hl.dsp.focus({ direction = "u" }))
hl.bind(mod .. " + right", hl.dsp.focus({ direction = "r" }))
hl.bind(mod .. " + left", hl.dsp.focus({ direction = "l" }), { description = "Focus left" })
hl.bind(mod .. " + down", hl.dsp.focus({ direction = "d" }), { description = "Focus down" })
hl.bind(mod .. " + up", hl.dsp.focus({ direction = "u" }), { description = "Focus up" })
hl.bind(mod .. " + right", hl.dsp.focus({ direction = "r" }), { description = "Focus right" })
-- Move (Forge: window-move-*).
hl.bind(mod .. " + SHIFT + H", hl.dsp.window.move({ direction = "l" }), { description = "Move window left" })
@@ -161,8 +161,8 @@ hl.bind(mod .. " + SHIFT + Tab", hl.dsp.window.cycle_next({ next = false }), { d
hl.bind(mod .. " + SHIFT + grave", hl.dsp.focus({ last = true }), { description = "Last window" })
-- Mouse: drag to move, right-drag to resize.
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description = "Move window with pointer" })
hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" })
-- ── Workspaces ──────────────────────────────────────────────────────────────
-- ALT is the workspace modifier, matching the GNOME setup.
@@ -176,8 +176,8 @@ hl.bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { descripti
hl.bind("ALT + SHIFT + L", hl.dsp.window.move({ workspace = "+1" }), { description = "Move window to workspace right" })
-- GNOME also had these on CTRL+ALT+Up/Down.
hl.bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }))
hl.bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }))
hl.bind("CTRL + ALT + up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
hl.bind("CTRL + ALT + down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
-- Direct jump. ALT+0 is workspace 10.
for i = 1, 10 do
@@ -189,8 +189,8 @@ end
-- Scroll the mouse wheel over the desktop with SUPER held to change workspace.
-- (Scrolling the workspace indicator in the bar does the same; that's handled
-- in quickshell/modules/bar/Workspaces.qml.)
hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }))
hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }))
hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
-- Minimise, as far as Hyprland has one.
--
@@ -214,30 +214,30 @@ hl.bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { des
-- ── Media and volume ────────────────────────────────────────────────────────
-- locked = true keeps these working on the lock screen, as they do in GNOME.
-- 6% steps match the GNOME volume-step setting.
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true })
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true })
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 6%+"), { locked = true, repeating = true , description = "Volume up" })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 6%-"), { locked = true, repeating = true , description = "Volume down" })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true , description = "Mute" })
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true , description = "Mute microphone" })
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
hl.bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true })
hl.bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true })
hl.bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 1%+"), { locked = true, repeating = true , description = "Volume up (fine)" })
hl.bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 1%-"), { locked = true, repeating = true , description = "Volume down (fine)" })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
hl.bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" })
hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true , description = "Play or pause" })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true , description = "Next track" })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true , description = "Previous track" })
hl.bind("XF86AudioStop", hl.dsp.exec_cmd("playerctl stop"), { locked = true , description = "Stop playback" })
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true })
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true , description = "Brightness up" })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true , description = "Brightness down" })
-- Hardware keys GNOME mapped that have obvious equivalents.
hl.bind("XF86Tools", hl.dsp.exec_cmd(settings))
hl.bind("XF86Calculator", hl.dsp.exec_cmd(calculator))
hl.bind("XF86Explorer", hl.dsp.exec_cmd(files))
hl.bind("XF86WWW", hl.dsp.exec_cmd(browser))
hl.bind("XF86Mail", hl.dsp.exec_cmd(mail))
hl.bind("XF86Search", hl.dsp.exec_cmd(launcher))
hl.bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
hl.bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
hl.bind("XF86Explorer", hl.dsp.exec_cmd(files), { description = "Files" })
hl.bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
hl.bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
hl.bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
return true
@@ -86,12 +86,14 @@ Singleton {
},
{
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
unit: "ms",
group: "dock",
label: "Reveal delay",
detail: "Zero reveals the Dock the instant the pointer reaches the edge"
},
{
key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
unit: "ms",
group: "dock",
label: "Hide delay",
detail: "Prevents flicker when crossing between icons"
@@ -100,6 +102,7 @@ Singleton {
// ── Focus ───────────────────────────────────────────────────────────
{
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
unit: "min",
group: "focus",
label: "Focus session length",
detail: "How long a focus session runs before it ends itself"
@@ -143,6 +146,7 @@ Singleton {
// the Hyprland config still stands on its own.
{
key: "gapsIn", type: "int", def: 5, min: 0, max: 40, step: 1,
unit: "px",
group: "windows",
label: "Inner gaps",
detail: "Space between neighbouring tiled windows",
@@ -150,6 +154,7 @@ Singleton {
},
{
key: "gapsOut", type: "int", def: 10, min: 0, max: 80, step: 1,
unit: "px",
group: "windows",
label: "Outer gaps",
detail: "Space between the tiled area and the screen edge",
@@ -157,6 +162,7 @@ Singleton {
},
{
key: "borderSize", type: "int", def: 2, min: 0, max: 10, step: 1,
unit: "px",
group: "windows",
label: "Border width",
detail: "Thickness of the gradient border on the focused window",
@@ -164,6 +170,7 @@ Singleton {
},
{
key: "windowRounding", type: "int", def: 18, min: 0, max: 40, step: 1,
unit: "px",
group: "windows",
label: "Corner radius",
detail: "Matches the shell's popover radius so windows and panels agree",
@@ -206,6 +213,7 @@ Singleton {
},
{
key: "shadowRange", type: "int", def: 20, min: 0, max: 60, step: 1,
unit: "px",
group: "effects",
label: "Shadow size",
detail: "How far the shadow spreads from the window edge",
@@ -219,6 +227,7 @@ Singleton {
},
{
key: "glowRange", type: "int", def: 8, min: 0, max: 30, step: 1,
unit: "px",
group: "effects",
label: "Glow size",
detail: "Kept small deliberately: the gradient border is the signature",
@@ -249,6 +258,7 @@ Singleton {
},
{
key: "keyRepeatDelay", type: "int", def: 500, min: 150, max: 1000, step: 25,
unit: "ms",
group: "input",
label: "Repeat delay",
detail: "How long a key is held before it starts repeating",
@@ -256,6 +266,7 @@ Singleton {
},
{
key: "keyRepeatRate", type: "int", def: 33, min: 5, max: 100, step: 1,
unit: "/s",
group: "input",
label: "Repeat rate",
detail: "How many characters a second a held key produces",
@@ -281,10 +292,15 @@ Singleton {
},
{
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
unit: "s",
group: "input",
label: "Hide pointer after",
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "int" }
// Reported as a float even though it is only ever set to whole
// seconds. `readAs` describes what getoption answers with, not what
// the setting means -- getting this wrong makes every write to it
// look rejected.
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
},
// ── Night light ─────────────────────────────────────────────────────
@@ -300,6 +316,7 @@ Singleton {
},
{
key: "nightLightTemperature", type: "int", def: 3500, min: 2000, max: 6500, step: 100,
unit: "K",
group: "nightLight",
label: "Color temperature",
detail: "Lower is warmer"
@@ -0,0 +1,30 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
IpcHandler {
target: "keybinds-test"
function status(): string {
const grouped = Keybinds.grouped();
let groupedCount = 0;
for (const group of grouped)
groupedCount += group.binds.length;
const superT = Keybinds.binds.find(bind => bind.description === "Terminal");
return JSON.stringify({
loaded: Keybinds.loaded,
count: Keybinds.binds.length,
groupedCount: groupedCount,
groups: grouped.map(group => group.name),
sample: superT ? superT.chord : "",
emptyDescriptions: Keybinds.binds.filter(bind => !bind.description).length,
lastError: Keybinds.lastError
});
}
}
}
@@ -0,0 +1,34 @@
// A row whose trailing control is a button rather than a setting.
//
// ActionRow {
// label: "Keyboard"
// detail: "Use Fedora's hardware-backed input panels"
// action: "Open keyboard"
// onTriggered: SystemSettings.openGnomePanel("keyboard")
// }
//
// Not schema-bound: these hand off to GNOME, launch an application, or run a
// one-shot like restoring defaults. There is no key to name.
import QtQuick
import qs.config
SettingRow {
id: root
property string action: ""
property bool enabled: true
signal triggered
controlWidth: Math.max(110, button.implicitWidth + 8)
SettingsButton {
id: button
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.action
enabled: root.enabled
onClicked: root.triggered()
}
}
@@ -1,61 +1,94 @@
// Appearance — the page the rest of the settings vocabulary was built for.
//
// Before Stage 3 this page adjusted a clock format and three vitals toggles,
// and its "Theme" card was two rows of text pretending to be controls. It now
// drives the compositor directly: every row here is applied through
// `hyprctl eval` and confirmed by reading the value back before it is stored.
//
// The preview is pinned above the controls rather than sitting inline, because
// the numbers are meaningless on their own -- "outer gaps 24" only means
// something once you have watched the windows move apart by that much, at the
// scale of the display you actually use.
import QtQuick
import qs.config
import qs.services
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
SettingsPage {
id: root
title: "Appearance"
lede: "Drag anything below. The preview above is your real geometry, to scale."
header: Component {
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
spacing: 9
Text { text: "Appearance"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "Tokyo Night Moon, tuned for clarity and quiet motion."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
DesktopPreview {
width: parent.width
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: SystemSettings.lastError !== ""
? SystemSettings.lastError
: "Live preview — the focused window wears the prism border"
color: SystemSettings.lastError !== "" ? Theme.warn : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
SettingsCard {
title: "Theme"
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes."
SettingRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
SettingRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
title: "Windows"
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
SliderRow { setting: "windowRounding" }
SliderRow { setting: "gapsIn" }
SliderRow { setting: "gapsOut" }
SliderRow { setting: "borderSize"; zeroLabel: "None" }
SliderRow { setting: "inactiveOpacity"; divider: false }
}
SettingsCard {
title: "Effects"
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
ToggleRow { setting: "blurEnabled" }
SliderRow { setting: "blurSize" }
SliderRow { setting: "blurPasses" }
ToggleRow { setting: "shadowEnabled" }
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
ToggleRow { setting: "glowEnabled" }
SliderRow { setting: "glowRange"; zeroLabel: "None" }
ToggleRow { setting: "animationsEnabled"; divider: false }
}
SettingsCard {
title: "Clock"
SettingRow {
label: "24-hour time"
detail: "Use 18:30 instead of 6:30 PM"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("use24Hour"); onToggled: value => DesktopPreferences.set("use24Hour", value) }
}
SettingRow {
label: "Show seconds"
detail: "Keep a precise clock in the center of the bar"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showSeconds"); onToggled: value => DesktopPreferences.set("showSeconds", value) }
}
SettingRow {
label: "Show weekday"
detail: "Include the abbreviated weekday before the date"
divider: false
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showWeekday"); onToggled: value => DesktopPreferences.set("showWeekday", value) }
}
ToggleRow { setting: "use24Hour" }
ToggleRow { setting: "showSeconds" }
ToggleRow { setting: "showWeekday"; divider: false }
}
SettingsCard {
title: "System vitals"
subtitle: "Choose what appears beside the workspace indicator."
SettingRow { label: "Processor"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showCpu"); onToggled: value => DesktopPreferences.set("showCpu", value) } }
SettingRow { label: "Memory"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showMemory"); onToggled: value => DesktopPreferences.set("showMemory", value) } }
SettingRow { label: "Graphics"; divider: false; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showGpu"); onToggled: value => DesktopPreferences.set("showGpu", value) } }
}
ToggleRow { setting: "showCpu" }
ToggleRow { setting: "showMemory" }
ToggleRow { setting: "showGpu"; divider: false }
}
SettingsCard {
title: "Theme"
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
}
}
@@ -0,0 +1,98 @@
// An enum setting, bound to a schema key by name.
//
// ChoiceRow { setting: "vrrPolicy" }
//
// The options are the schema's, so a row can never offer a value the store
// would reject. Rendered as a segmented control rather than a dropdown: every
// choice here has two or three options, and showing them all is both faster to
// use and self-documenting.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property string setting
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
readonly property var current: DesktopPreferences.get(root.setting)
label: root.spec ? root.spec.label : root.setting
detail: root.spec ? root.spec.detail : ""
controlWidth: Math.max(120, root.options.length * 92)
Rectangle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
implicitWidth: segments.implicitWidth + 4
implicitHeight: 30
radius: 9
color: Theme.alpha(Theme.fg, 0.07)
border.width: 0
Row {
id: segments
anchors.centerIn: parent
spacing: 0
Repeater {
model: root.options
Rectangle {
id: segment
required property var modelData
readonly property bool selected: root.current === segment.modelData.value
implicitWidth: Math.max(70, caption.implicitWidth + 24)
implicitHeight: 26
radius: 7
border.width: 0
color: "transparent"
// The selected segment is the only place the prism appears
// in a row: blue leads into orchid, never orchid alone.
Rectangle {
anchors.fill: parent
radius: parent.radius
visible: segment.selected
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.30) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.30) }
}
}
Rectangle {
anchors.fill: parent
radius: parent.radius
border.width: 0
visible: !segment.selected && hover.hovered
color: Theme.alpha(Theme.fg, 0.06)
}
Text {
id: caption
anchors.centerIn: parent
text: segment.modelData.label
color: segment.selected ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: segment.selected ? Font.DemiBold : Font.Normal
}
HoverHandler { id: hover }
TapHandler {
onTapped: SystemSettings.commitPreference(root.setting, segment.modelData.value)
}
}
}
}
}
}
@@ -1,55 +1,69 @@
// Desktop & Dock.
//
// The dock timings used to be shown here as text -- "Instant", "250 ms" -- even
// though they were already stored, mutable integers. They are controls now.
// The window-layout card keeps text rows because those really are facts about
// how Panama tiles rather than settings: the adjustable parts of window
// appearance live on the Appearance page, next to the preview that explains
// them.
import QtQuick
import qs.config
import qs.services
Item {
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
SettingsPage {
id: root
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Desktop & Dock"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "Keep the shell instant, spatial, and out of your way."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
title: "Desktop & Dock"
lede: "Keep the shell instant, spatial, and out of your way."
SettingsCard {
title: "Dock"
SettingRow {
label: "Automatically hide the Dock"
detail: "Reveal it at the bottom edge when a workspace is occupied"
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("dockAutohide"); onToggled: value => DesktopPreferences.set("dockAutohide", value) }
}
SettingRow { label: "Reveal response"; detail: "The Dock appears as soon as the pointer reaches the edge"; value: DesktopPreferences.get("dockRevealDelayMs") === 0 ? "Instant" : `${DesktopPreferences.get("dockRevealDelayMs")} ms` }
SettingRow { label: "Hide delay"; detail: "Prevents flicker when crossing icons"; value: `${DesktopPreferences.get("dockHideDelayMs")} ms`; divider: false }
ToggleRow { setting: "dockAutohide" }
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false }
}
SettingsCard {
title: "Window layout"
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
SettingRow { label: "Layout"; detail: "Dwindle with preserved split direction"; value: "Tiling" }
SettingRow { label: "Window corners"; detail: "Shared with Panama glass surfaces"; value: "18 px" }
SettingRow { label: "Workspace movement"; detail: "Alt+H / Alt+L, add Shift to move a window"; value: "Dynamic"; divider: false }
TextRow {
label: "Layout"
detail: "Dwindle with preserved split direction"
value: "Tiling"
}
TextRow {
label: "Workspace movement"
detail: "Alt+H / Alt+L, add Shift to move a window"
value: "Dynamic"
}
ActionRow {
label: "Gaps, corners, and effects"
detail: "Adjusted on the Appearance page, beside a live preview"
action: "Open Appearance"
divider: false
onTriggered: ShellState.openSettings("appearance")
}
}
SettingsCard {
title: "Focus"
SliderRow { setting: "focusDurationMinutes"; divider: false }
}
SettingsCard {
title: "Reset"
subtitle: "Restore Panama's curated clock, vitals, dock, focus, and display-policy defaults."
SettingRow {
label: "Desktop preferences"
detail: "Your pinned applications and files are not changed"
subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
ActionRow {
label: "Restore Panama defaults"
detail: "Applies immediately, including to the compositor"
action: "Restore defaults"
divider: false
controlWidth: 122
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Restore defaults"; onClicked: DesktopPreferences.resetDesktopDefaults() }
}
}
onTriggered: SystemSettings.restoreDefaults()
}
}
}
@@ -0,0 +1,214 @@
// A miniature of the real desktop that redraws as you change Appearance.
//
// The point is that "outer gaps 24" and "radius 9" mean nothing as numbers. This
// shows two tiled windows at the settings currently in effect: the focused one
// wearing the prism border and its glow, the unfocused one carrying the
// inactive-opacity setting, both inside real gaps at a real corner radius.
//
// Geometry is scaled by the ratio between this preview's width and the actual
// monitor's, so proportions are honest rather than decorative -- a 10px gap on
// a 4500px display genuinely is almost invisible, and the preview says so.
//
// Everything here is driven by bindings on DesktopPreferences, so the preview
// shows what is actually stored and applied. It has no state of its own and
// nothing animates while idle.
import QtQuick
import QtQuick.Effects
import qs.config
import qs.services
Rectangle {
id: root
implicitHeight: 232
radius: Theme.cardRadius
clip: true
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.09)
// Falls back to a sane width if the monitor has not been read yet, so the
// preview is never wrong by a factor of ten on first paint.
readonly property real monitorWidth: SystemSettings.monitorWidth > 0 ? SystemSettings.monitorWidth : 3000
readonly property real scale: width > 0 ? width / root.monitorWidth : 0.25
readonly property int gapsIn: DesktopPreferences.get("gapsIn")
readonly property int gapsOut: DesktopPreferences.get("gapsOut")
readonly property int borderSize: DesktopPreferences.get("borderSize")
readonly property int rounding: DesktopPreferences.get("windowRounding")
readonly property real inactiveOpacity: DesktopPreferences.get("inactiveOpacity")
readonly property bool shadowOn: DesktopPreferences.get("shadowEnabled")
readonly property int shadowRange: DesktopPreferences.get("shadowRange")
readonly property bool glowOn: DesktopPreferences.get("glowEnabled")
readonly property int glowRange: DesktopPreferences.get("glowRange")
// Scaled geometry, floored so a small-but-nonzero setting stays visible
// rather than rounding away to nothing.
function px(value: real): real {
return value <= 0 ? 0 : Math.max(1, value * root.scale);
}
// Stands in for the wallpaper. Static: an animated gradient here would
// repaint forever behind a settings page.
gradient: Gradient {
orientation: Gradient.Vertical
GradientStop { position: 0.0; color: "#2b3050" }
GradientStop { position: 1.0; color: "#241f33" }
}
// The bar, so the preview reads as this desktop and not a generic one.
Rectangle {
id: miniBar
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 20
color: Theme.alpha(Theme.bgDark, 0.4)
border.width: 0
Row {
anchors.left: parent.left
anchors.leftMargin: 9
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Repeater {
model: 3
Rectangle {
required property int index
width: index === 0 ? 12 : 5
height: 5
radius: 2.5
border.width: 0
color: index === 0 ? Theme.accent : Theme.alpha(Theme.fg, 0.3)
anchors.verticalCenter: parent.verticalCenter
}
}
}
Text {
anchors.centerIn: parent
text: "Panama"
color: Theme.alpha(Theme.fg, 0.45)
font.family: Theme.fontFamily
font.pixelSize: 9
}
}
Row {
id: tiles
anchors.top: miniBar.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: root.px(root.gapsOut)
spacing: root.px(root.gapsIn) * 2
MiniWindow {
width: (tiles.width - tiles.spacing) / 2
height: tiles.height
focused: true
}
MiniWindow {
width: (tiles.width - tiles.spacing) / 2
height: tiles.height
focused: false
}
}
component MiniWindow: Item {
id: win
required property bool focused
// The gradient border is drawn as a filled rounded rect with the window
// body inset on top of it: QML's Rectangle border takes a colour, not a
// gradient, and the prism border is the whole signature here.
Rectangle {
id: frame
anchors.fill: parent
radius: root.px(root.rounding)
border.width: 0
visible: win.focused && root.borderSize > 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.accent }
GradientStop { position: 1.0; color: Theme.accentSecondary }
}
}
Rectangle {
id: body
anchors.fill: parent
anchors.margins: win.focused ? root.px(root.borderSize) : 0
radius: Math.max(0, root.px(root.rounding) - (win.focused ? root.px(root.borderSize) : 0))
color: Theme.alpha(Theme.bgDark, 0.92)
opacity: win.focused ? 1.0 : root.inactiveOpacity
border.width: win.focused ? 0 : 1
border.color: Theme.alpha(Theme.gutter, 0.6)
clip: true
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 13
color: Theme.alpha(Theme.fg, 0.05)
border.width: 0
}
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: 22
anchors.margins: 10
spacing: 5
Repeater {
model: [1.0, 0.74, 0.52]
Rectangle {
required property real modelData
width: parent.width * modelData
height: 4
radius: 2
border.width: 0
color: Theme.alpha(Theme.fg, win.focused ? 0.2 : 0.13)
}
}
}
Text {
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.margins: 8
text: win.focused ? "focused" : "unfocused"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 9
}
}
// Shadow and glow both come from MultiEffect. Only the focused window
// gets the glow, matching looks.lua where glow.color_inactive is fully
// transparent.
MultiEffect {
anchors.fill: body
source: body
visible: root.shadowOn || (win.focused && root.glowOn)
z: -1
shadowEnabled: true
shadowColor: win.focused && root.glowOn
? Theme.alpha(Theme.accent, 0.5)
: Theme.alpha("#15161e", 0.85)
shadowBlur: win.focused && root.glowOn
? Math.min(1.0, root.px(root.glowRange) / 12)
: Math.min(1.0, root.px(root.shadowRange) / 24)
shadowVerticalOffset: win.focused && root.glowOn ? 0 : root.px(4)
shadowHorizontalOffset: 0
}
}
}
@@ -0,0 +1,80 @@
// The scaffold every settings page shares: a scrolling column with a title, a
// one-line lede, and consistent margins.
//
// This existed eleven times as copy-pasted Flickable/Column/x:34/y:30 blocks,
// which is a large part of why several pages settled for read-only text instead
// of real controls -- adding a page was expensive enough that the cheap thing
// won. Pages are now just their content:
//
// SettingsPage {
// title: "Appearance"
// lede: "Tokyo Night Moon, tuned for clarity and quiet motion."
//
// SettingsCard { title: "Windows"; ToggleRow { setting: "blurEnabled" } }
// }
import QtQuick
import qs.config
Item {
id: root
default property alias content: column.data
property string title: ""
property string lede: ""
// Anything that should sit above the title and stay put while the rest
// scrolls -- the Appearance page pins its live preview here.
property Component header: null
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: layout.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: layout
width: parent.width - 68
x: 34
y: 30
spacing: 16
Loader {
width: parent.width
active: root.header !== null
sourceComponent: root.header
}
Text {
width: parent.width
visible: root.title !== ""
text: root.title
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 27
font.weight: Font.DemiBold
}
Text {
width: parent.width
visible: root.lede !== ""
text: root.lede
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
bottomPadding: 6
}
Column {
id: column
width: parent.width
spacing: 16
}
}
}
}
@@ -1,73 +1,101 @@
// Input & Shortcuts.
//
// The shortcut list is generated from `hyprctl binds -j` rather than typed out
// here. The previous version was a hand-maintained array of nineteen entries
// against a real keymap of a hundred and thirteen: it could not show the rest,
// and it went stale the moment a bind changed. Every bind now carries its own
// description in hypr/keybinds.lua, and this page just groups and renders them.
//
// The hardware settings above the list are real controls. Keyboard layout,
// repeat behaviour, and pointer response are Hyprland's, so Panama owns them;
// device-specific configuration stays with GNOME.
import QtQuick
import qs.config
import qs.services
Item {
SettingsPage {
id: root
readonly property var shortcuts: [
{ key: "Super + T", action: "Terminal" },
{ key: "Super + N", action: "Neovim in the current directory" },
{ key: "Super + W", action: "Web browser" },
{ key: "Super + F", action: "Files" },
{ key: "Super + I", action: "Panama Settings" },
{ key: "Super + Space", action: "Launcher" },
{ key: "Super + Grave", action: "Continuum overview" },
{ key: "Super + V", action: "Clipboard history" },
{ key: "Super + S", action: "Quick Settings" },
{ key: "Super + B", action: "Notifications" },
{ key: "Super + Shift + S", action: "Screen Intelligence" },
{ key: "Super + Shift + F", action: "Focus session" },
{ key: "Alt + H / L", action: "Previous / next workspace" },
{ key: "Alt + Shift + H / L", action: "Move window between workspaces" },
{ key: "Super + H / J / K / L", action: "Focus a tiled window" },
{ key: "Super + Shift + H / J / K / L", action: "Move a tiled window" },
{ key: "Super + Shift + X", action: "Send window to scratchpad" },
{ key: "Super + X", action: "Toggle scratchpad" },
{ key: "Print", action: "Capture, record, or read" }
]
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Input & Shortcuts"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
Text { text: "The Forge mental model, carried forward into native tiling."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
title: "Input & Shortcuts"
lede: "The Forge mental model, carried forward into native tiling."
SettingsCard {
title: "Panama shortcuts"
Repeater {
model: root.shortcuts
SettingRow {
required property var modelData
required property int index
label: modelData.action
value: modelData.key
controlWidth: 210
divider: index < root.shortcuts.length - 1
}
title: "Keyboard"
SliderRow { setting: "keyRepeatDelay" }
SliderRow { setting: "keyRepeatRate" }
ToggleRow { setting: "numlockByDefault"; divider: false }
}
SettingsCard {
title: "Pointer"
ChoiceRow { setting: "followMouse" }
SliderRow { setting: "pointerSensitivity" }
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
}
SettingsCard {
title: "Hardware input"
SettingRow {
ActionRow {
label: "Mouse, touchpad, and keyboard devices"
detail: "Use Fedora's hardware-backed input panels"
detail: "Device-specific settings stay with Fedora's hardware-backed panels"
action: "Open keyboard"
divider: false
controlWidth: 126
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open keyboard"; onClicked: SystemSettings.openGnomePanel("keyboard") }
onTriggered: SystemSettings.openGnomePanel("keyboard")
}
}
// One card per group, built from what the compositor actually has bound.
//
// Both Repeaters address their model through an explicit id. Inside a
// SettingsCard the surrounding `parent` is the card's internal Column, not
// the card, so `parent.modelData` is undefined there and the rows silently
// never appear -- the cards render with their heading and nothing under it.
Repeater {
model: Keybinds.grouped()
SettingsCard {
id: groupCard
required property var modelData
title: groupCard.modelData.name
subtitle: groupCard.modelData.binds.length === 1
? "1 shortcut"
: `${groupCard.modelData.binds.length} shortcuts`
Repeater {
id: bindRows
model: groupCard.modelData.binds
TextRow {
required property var modelData
required property int index
label: modelData.description
value: modelData.chord
controlWidth: 230
divider: index < bindRows.count - 1
}
}
}
}
SettingsCard {
visible: Keybinds.lastError !== ""
title: "Shortcuts unavailable"
subtitle: Keybinds.lastError
ActionRow {
label: "Read the keymap again"
detail: "Shortcuts are read from the running compositor"
action: "Retry"
divider: false
onTriggered: Keybinds.refresh()
}
}
}
@@ -0,0 +1,193 @@
// A numeric setting, bound to a schema key by name.
//
// SliderRow { setting: "windowRounding" }
//
// Range, step, label, explanation, and unit all come from PreferenceSchema.
//
// Three behaviours worth knowing:
//
// * The row is responsive. The Settings window is a normal tiled window, so
// its width is whatever the layout gives it -- anywhere from a half-screen
// split to the full 4500px display, and `implicitWidth` is only a hint.
// Below a usable inline width the slider moves onto its own line under the
// label instead of squeezing the explanation into a five-line column.
// * The readout follows the drag immediately, but the value is only committed
// after a short quiet period. Compositor-backed settings are applied and
// verified one batch at a time, and a slider fires dozens of changes per
// second -- committing each would spend the whole drag rejecting
// overlapping writes.
// * Between commits the row shows what you are dragging; once settled it
// shows what is actually stored. If the compositor refuses a value the row
// falls back to the stored one rather than displaying a value nothing
// accepted.
//
// This does not extend SettingRow: that component fixes the control to a
// trailing column of a set width, which is the layout this row needs to be able
// to abandon. The label, explanation, and divider match it exactly.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Item {
id: root
required property string setting
property bool divider: true
property string zeroLabel: ""
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property string label: root.spec ? root.spec.label : root.setting
readonly property string detail: root.spec ? root.spec.detail : ""
readonly property real minimum: root.spec && root.spec.min !== undefined ? root.spec.min : 0
readonly property real maximum: root.spec && root.spec.max !== undefined ? root.spec.max : 100
readonly property real step: root.spec && root.spec.step !== undefined ? root.spec.step : 1
readonly property string unit: root.spec && root.spec.unit !== undefined ? root.spec.unit : ""
readonly property real stored: {
const value = DesktopPreferences.get(root.setting);
return typeof value === "number" ? value : root.minimum;
}
// Shown while dragging; -1 means "nothing pending, show what is stored".
property real pending: -1
readonly property real shown: root.pending >= 0 ? root.pending : root.stored
// Below this the label and a usable slider cannot share a line without one
// of them becoming useless.
readonly property bool inline: width >= 520
// A fixed trailing width rather than a share of the row. A proportional
// control looks reasonable at 900px and absurd at 4500px, where the slider
// would be a metre long next to a two-word label -- and this window is
// tiled, so it really can be that wide.
readonly property int controlSpan: 300
width: parent ? parent.width : 620
implicitHeight: root.inline
? Math.max(56, copy.implicitHeight + 20)
: copy.implicitHeight + 32 + 30
function quantise(ratio: real): real {
const raw = root.minimum + ratio * (root.maximum - root.minimum);
const snapped = Math.round(raw / root.step) * root.step;
const clamped = Math.max(root.minimum, Math.min(root.maximum, snapped));
// Steps below 1 are fractional (opacity, pointer speed); rounding to two
// places keeps 0.8500000000000001 out of the readout and the store.
return root.step < 1 ? Math.round(clamped * 100) / 100 : clamped;
}
function display(value: real): string {
if (value === 0 && root.zeroLabel !== "")
return root.zeroLabel;
const text = root.step < 1 ? value.toFixed(2) : String(value);
return root.unit === "" ? text : `${text} ${root.unit}`;
}
// Both children are positioned explicitly rather than by anchors. Binding
// an anchor to `undefined` to switch layouts does not reliably release it,
// which left the slider anchored to both edges and the label squeezed into
// whatever was left.
Column {
id: copy
x: 0
y: root.inline ? (root.height - height) / 2 : 10
width: root.inline ? root.width - root.controlSpan - 20 : root.width
spacing: 3
Text {
width: parent.width
text: root.label
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
visible: root.detail !== ""
text: root.detail
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Item {
id: control
width: root.inline ? root.controlSpan : root.width
height: 32
x: root.inline ? root.width - width : 0
y: root.inline ? (root.height - height) / 2 : copy.y + copy.height + 10
ValueSlider {
id: slider
anchors.left: parent.left
anchors.right: readout.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
value: root.maximum > root.minimum
? (root.shown - root.minimum) / (root.maximum - root.minimum)
: 0
onMoved: ratio => {
root.pending = root.quantise(ratio);
commitTimer.restart();
}
}
Text {
id: readout
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 58
horizontalAlignment: Text.AlignRight
text: root.display(root.shown)
color: Theme.fgDim
font.family: Theme.fontFamily
// The readout changes digit by digit under the pointer; tabular
// figures stop it twitching sideways as it does.
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
visible: root.divider
color: Theme.alpha(Theme.fg, 0.065)
}
Timer {
id: commitTimer
interval: 140
onTriggered: {
if (root.pending < 0)
return;
SystemSettings.commitPreference(root.setting, root.pending);
// Hand the display back to the stored value. If the write was
// refused, the row snaps back to what is really in effect.
releaseTimer.restart();
}
}
Timer {
id: releaseTimer
interval: 160
onTriggered: root.pending = -1
}
}
@@ -0,0 +1,19 @@
// A row that only reports something.
//
// TextRow { label: "Graphics"; detail: "Rendering device"; value: "AMD Radeon" }
//
// Genuinely read-only facts -- the active display's identity, a version string,
// a detected capability -- belong here. A setting the user could reasonably
// change does NOT: before Stage 3 more than half of Panama's settings rows were
// static text standing in for a control that was simply expensive to add, and
// this component exists for the honest remainder rather than to make that easy
// to do again.
import QtQuick
import qs.config
SettingRow {
id: root
controlWidth: 210
}
@@ -0,0 +1,33 @@
// A boolean setting, bound to a schema key by name.
//
// ToggleRow { setting: "blurEnabled" }
//
// Label, explanation, and validation all come from PreferenceSchema, so a row
// cannot drift from the setting it edits, and the writing side does not care
// whether the key is stored locally or applied to the compositor first.
// Override `label` or `detail` only when a page needs different wording than
// the schema's default.
import QtQuick
import qs.config
import qs.services
SettingRow {
id: root
required property string setting
readonly property var spec: PreferenceSchema.spec(root.setting)
readonly property bool checked: DesktopPreferences.get(root.setting) === true
label: root.spec ? root.spec.label : root.setting
detail: root.spec ? root.spec.detail : ""
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.checked
onToggled: value => SystemSettings.commitPreference(root.setting, value)
}
}
@@ -20,3 +20,10 @@ SettingsToggle 1.0 SettingsToggle.qml
SettingsWindow 1.0 SettingsWindow.qml
ShortcutsPage 1.0 ShortcutsPage.qml
SoundPage 1.0 SoundPage.qml
SettingsPage 1.0 SettingsPage.qml
ToggleRow 1.0 ToggleRow.qml
SliderRow 1.0 SliderRow.qml
ChoiceRow 1.0 ChoiceRow.qml
ActionRow 1.0 ActionRow.qml
TextRow 1.0 TextRow.qml
DesktopPreview 1.0 DesktopPreview.qml
+158
View File
@@ -0,0 +1,158 @@
pragma Singleton
// The keymap, read from the compositor rather than restated.
//
// The Shortcuts page used to hold a hand-typed array of nineteen entries while
// keybinds.lua produced a hundred and thirteen. It could not show the other
// ninety-four, and it drifted the moment a bind was edited. `hyprctl binds -j`
// is the only description of the keymap that cannot be wrong, so this reads
// that and every bind carries its own human label (see the `description`
// argument in hypr/keybinds.lua).
//
// Refreshed on demand, not polled: binds only change when the config is
// reloaded, and nothing in this shell should wake up to re-read something that
// has not moved.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
// [{ chord, description, group, mouse, repeating, locked }], ordered as
// Hyprland reports them, which follows the order they appear in the config.
property var binds: []
property bool loaded: false
property string lastError: ""
readonly property bool busy: query.running
// Hyprland's modmask bits. SUPER is the Panama modifier.
readonly property var modifierBits: [
{ bit: 64, name: "Super" },
{ bit: 4, name: "Ctrl" },
{ bit: 8, name: "Alt" },
{ bit: 1, name: "Shift" }
]
// Keysyms whose raw names would be noise in a shortcuts list.
readonly property var keyNames: ({
"mouse_up": "Scroll up",
"mouse_down": "Scroll down",
"mouse:272": "Left click",
"mouse:273": "Right click",
"mouse:274": "Middle click",
"bracketleft": "[",
"bracketright": "]",
"grave": "`",
"Print": "Print Screen",
"left": "←",
"right": "→",
"up": "↑",
"down": "↓"
})
Process {
id: query
command: ["hyprctl", "-j", "binds"]
stdout: StdioCollector {
onStreamFinished: root.parse(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Could not read the keymap from Hyprland.";
}
}
Component.onCompleted: root.refresh()
function refresh(): void {
if (query.running)
return;
root.lastError = "";
query.running = true;
}
function parse(text: string): void {
try {
const raw = JSON.parse(text);
const out = [];
for (const bind of raw) {
const description = String(bind.description ?? "").trim();
// A bind with no description cannot be presented usefully --
// the dispatcher is "__lua" and the argument is a bytecode
// offset. Showing the chord alone would be worse than omitting
// it, and tests/quickshell/keybinds-contract.sh fails the build
// if any exist, so this should never be reached in practice.
if (description === "")
continue;
out.push({
chord: root.formatChord(bind),
description: description,
group: root.groupFor(description, bind),
mouse: bind.mouse === true,
repeating: bind.repeat === true,
locked: bind.locked === true
});
}
root.binds = out;
root.loaded = true;
root.lastError = "";
} catch (error) {
root.lastError = "The keymap could not be read.";
}
}
function formatChord(bind: var): string {
const parts = [];
for (const modifier of root.modifierBits) {
if ((bind.modmask & modifier.bit) !== 0)
parts.push(modifier.name);
}
const key = String(bind.key ?? "");
parts.push(root.keyNames[key] ?? (key.length === 1 ? key.toUpperCase() : key));
return parts.join(" + ");
}
// Grouping is by what the shortcut does, taken from its own description,
// so adding a bind puts it in the right section without touching this file.
function groupFor(description: string, bind: var): string {
const text = description.toLowerCase();
if (bind.key && String(bind.key).indexOf("XF86") === 0)
return "Media & hardware keys";
if (text.indexOf("workspace") >= 0)
return "Workspaces";
if (text.indexOf("window") >= 0 || text.indexOf("focus") >= 0
|| text.indexOf("swap") >= 0 || text.indexOf("split") >= 0
|| text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
|| text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0
|| text.indexOf("shrink") >= 0 || text.indexOf("grow") >= 0
|| text.indexOf("float") >= 0 || text.indexOf("fullscreen") >= 0
|| text.indexOf("close") >= 0 || text.indexOf("scratchpad") >= 0)
return "Windows";
if (text.indexOf("volume") >= 0 || text.indexOf("mute") >= 0
|| text.indexOf("track") >= 0 || text.indexOf("play") >= 0
|| text.indexOf("brightness") >= 0)
return "Media & hardware keys";
return "Applications & shell";
}
// Section order for the page. Anything a future bind invents lands at the
// end rather than being dropped.
readonly property var groupOrder: ["Windows", "Workspaces", "Applications & shell", "Media & hardware keys"]
function grouped(): var {
const buckets = {};
for (const bind of root.binds) {
buckets[bind.group] = buckets[bind.group] ?? [];
buckets[bind.group].push(bind);
}
const names = Object.keys(buckets).sort((a, b) => {
const ia = root.groupOrder.indexOf(a);
const ib = root.groupOrder.indexOf(b);
return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
});
return names.map(name => ({ name: name, binds: buckets[name] }));
}
}
@@ -216,14 +216,38 @@ Singleton {
}
if (Object.keys(requested).length === 0)
return false;
// A write in flight is queued rather than refused. Options are applied
// and verified one batch at a time, but the callers are a settings UI
// and a startup replay of every compositor-backed preference -- they
// overlap routinely, and dropping a change on the floor would leave the
// stored value and the compositor disagreeing. Later values for the
// same key win.
if (configWrite.running || configVerify.running) {
root.lastError = "Another change is still being applied.";
return false;
root.queued = Object.assign({}, root.queued, requested);
return true;
}
root.startWrite(requested);
return true;
}
// Merged batches waiting for the current write to finish.
property var queued: ({})
function startWrite(requested: var): void {
configWrite.pending = requested;
configWrite.exec(["hyprctl", "eval", root.buildConfigPayload(requested)]);
return true;
}
// Called when a write settles, however it settled. A failed batch must not
// strand whatever queued up behind it.
function drainQueue(): void {
const next = root.queued;
if (Object.keys(next).length === 0)
return;
root.queued = ({});
root.startWrite(next);
}
// The value as Hyprland stores it. Several options are a toggle in the UI
@@ -308,6 +332,7 @@ Singleton {
}
root.lastError = rejected.length === 0 ? "" : `Hyprland did not apply ${rejected.join(" or ")}.`;
root.drainQueue();
}
function matchesObserved(entry: var, value: var, answer: var): bool {
@@ -334,6 +359,57 @@ Singleton {
function reportWriteFailure(requested: var, text: string): void {
const labels = Object.keys(requested).map(key => PreferenceSchema.spec(key).label);
root.lastError = `Hyprland rejected ${labels.join(" and ")}.`;
root.drainQueue();
}
// The one entry point the settings UI uses to change any preference.
//
// A compositor-backed setting must be applied and verified before it is
// stored, so that preferences never claim a value Hyprland refused.
// Everything else is a direct write. Rows bind a schema key and call this;
// they never need to know which kind they are holding.
function commitPreference(key: string, value: var): bool {
const entry = PreferenceSchema.spec(key);
if (!entry) {
root.lastError = "That setting is not part of Panama.";
return false;
}
if (entry.hypr) {
const batch = {};
batch[key] = value;
return root.applyOptions(batch);
}
return DesktopPreferences.set(key, value);
}
// Restores shipped defaults across every store Panama owns, not just the
// schema. Panama keeps user state in more than one file -- the schema store,
// the focus session, and the Home accessory arrangement -- and a reset that
// silently skipped one would be worse than no reset at all.
//
// Compositor-backed values are re-applied afterwards, since resetting the
// stored value does not by itself tell Hyprland anything.
function restoreDefaults(): void {
DesktopPreferences.resetDesktopDefaults();
// Home accessories keep their own store (panama-home.json), so a reset
// that only cleared the schema store would silently leave a customised
// favourites list behind while claiming to restore Panama's defaults.
//
// Done through HomePreferences' public writable aliases rather than a
// reset function of its own: clearing `favorites` and returning
// `initialized` to false is exactly the state a fresh install has, and
// it lets initialize() seed the list again on next use.
HomePreferences.favorites = [];
HomePreferences.initialized = false;
resettleTimer.restart();
}
Timer {
id: resettleTimer
interval: 60
onTriggered: root.applyPersistedDisplayPolicy()
}
function setAutoHdr(enabled: bool): void {
@@ -29,6 +29,31 @@ ShellRoot {
return SystemSettings.applyOptions(JSON.parse(payload));
}
// The routing entry point the settings rows use: a compositor-backed
// key must be applied and verified before it is stored, a local one is
// written directly. The contract checks both halves.
function commit(key: string, payload: string): bool {
return SystemSettings.commitPreference(key, JSON.parse(payload));
}
function stored(key: string): string {
return JSON.stringify(DesktopPreferences.get(key));
}
function seedHome(): void {
HomePreferences.favorites = [{ id: "light.contract_probe", alias: "Probe" }];
HomePreferences.initialized = true;
}
function homeState(): string {
return JSON.stringify({
count: HomePreferences.favorites.length,
initialized: HomePreferences.initialized
});
}
function restoreDefaults(): void { SystemSettings.restoreDefaults(); }
function panelAllowed(panel: string): bool {
return SystemSettings.isGnomePanelAllowed(panel);
}
@@ -188,26 +188,57 @@ rewritten.
`SliderRow.qml`, `ChoiceRow.qml`, `ActionRow.qml`, `TextRow.qml`;
Modify all eleven `*Page.qml`; Test `tests/quickshell/settings-rows-contract.sh`
- [ ] **Build static mocks** of the new Appearance page and one rebuilt existing
- [x] **Build static mocks** of the new Appearance page and one rebuilt existing
page, serve them over HTTP, report the URL, and **stop for a decision.**
- [ ] Write a contract asserting each row type binds a schema key by name, reflects
external changes, and clamps out-of-range input.
- [ ] Implement the row components and `SettingsPage` (the scaffold currently
Three directions built; Gabriel chose **B, the live preview**.
- [x] Implement the row components and `SettingsPage` (the scaffold previously
copy-pasted eleven times).
- [ ] Rewrite the eleven pages on top of them; delete the dead read-only rows that
only existed because a real control was expensive.
- [ ] Promote the hardcoded `Settings.qml` values into real controls: weather
- [x] Give Appearance real content, driven by a live preview.
- [x] Rewrite Appearance, Desktop & Dock, and Input & Shortcuts on the new rows;
delete the dead read-only rows that only existed because a real control was
expensive.
- [x] Make "Restore defaults" span every store Panama owns.
- [x] Run the new contracts and the existing settings contracts to green.
- [ ] Promote the remaining hardcoded `Settings.qml` values: weather
location/unit/interval, vitals interval, night-light schedule, the four
notification timing and history limits, capture directories, and recorder
arguments.
- [ ] Give Appearance real content: accent pair, window rounding, gaps, border
size, blur, animation speed, bar height, font scale, wallpaper.
- [ ] Make the dock pin list editable (reorder, add, remove) instead of a
16-entry literal.
- [ ] Run the rows contract and the existing settings contracts to green.
- [ ] Move the remaining pages (Home, Displays, Connectivity, Sound,
Notifications, Screen Intelligence, Services, About) onto `SettingsPage`.
**Exit criteria:** no shipped behaviour value is reachable only by editing a file.
**Landed (first pass).** `SettingsPage` plus `ToggleRow`, `SliderRow`,
`ChoiceRow`, `ActionRow`, and `TextRow`. A row names a schema key and needs
nothing else — `ToggleRow { setting: "blurEnabled" }` pulls its label,
explanation, bounds, and unit from the schema, and writes through
`SystemSettings.commitPreference`, which routes compositor-backed keys through
apply-and-verify and local keys straight to the store. Rows never need to know
which kind they hold.
`DesktopPreview` is the direction-B centrepiece: two tiled windows drawn at the
settings actually in effect, scaled by the ratio between the preview's width and
the real monitor's, so a 10px gap on a 4500px display looks as small as it is.
Four things found by building it:
* `cursor:inactive_timeout` is reported as `float`, not `int`. `readAs` describes
what getoption answers with, not what the setting means, and getting it wrong
does not fail loudly — it makes every write to that key look rejected. The user
saw "Hyprland did not apply Hide pointer after" for a change that worked.
`tests/quickshell/schema-hypr-shape-contract.sh` now asks the compositor for
the real shape of all 23 mapped options.
* The Settings window is a normal tiled window, so `implicitWidth: 1120` is only
a hint and rows must survive ~400px. `SliderRow` stacks its control under the
label below 520px.
* Binding an anchor to `undefined` to switch layouts does not reliably release
it. Both row layouts are positioned explicitly now.
* Refusing concurrent compositor writes was the wrong policy: the startup replay
of 23 preferences routinely overlaps a UI change, and refusing left the store
and the compositor disagreeing. Writes queue and merge, later values winning.
---
## Stage 4 — Shortcuts from the compositor
@@ -215,18 +246,34 @@ Modify all eleven `*Page.qml`; Test `tests/quickshell/settings-rows-contract.sh`
**Files:** Create `services/Keybinds.qml`; Modify `modules/settings/ShortcutsPage.qml`,
`config/dot/hypr/keybinds.lua`; Test `tests/quickshell/keybinds-contract.sh`
- [ ] Write a contract asserting the page's bind count matches `hyprctl binds -j`
- [x] Write a contract asserting the page's bind count matches `hyprctl binds -j`
exactly, so it can never drift again.
- [ ] Run it; confirm it fails at 19 of 113.
- [ ] Implement `Keybinds.qml` reading `hyprctl binds -j`, grouped and searchable.
- [ ] Backfill `description` in `keybinds.lua` for the 29 binds that lack one.
- [ ] Rebuild `ShortcutsPage` on the live data; delete the hardcoded array.
- [x] Implement `Keybinds.qml` reading `hyprctl binds -j`, grouped.
- [x] Backfill `description` in `keybinds.lua` for the 29 binds that lacked one.
- [x] Rebuild `ShortcutsPage` on the live data; delete the hardcoded array.
- [x] Run the contract to green.
- [ ] Add rebinding: overrides in the same JSON, applied by `keybinds.lua` after
the defaults and live via `eval`, with conflict detection against existing binds.
- [ ] Run the contract to green.
- [ ] Add search over the shortcut list.
**Exit criteria:** the page shows every real bind, always current, and can change them.
**Landed (read-only).** The page shows all **113** binds, grouped by what they do,
against the hand-typed **19** it had before. Descriptions come from the binds
themselves, so a new bind appears with no change to the page.
Grouping is derived from each bind's own description rather than a table here, so
adding a bind puts it in the right section automatically. Hyprland reports
Lua-defined binds with dispatcher `__lua` and a bytecode offset as the argument,
so a bind without a description has nothing readable beside its chord; the
service drops those, and `tests/quickshell/keybinds-contract.sh` fails if any
exist so that dropping can never be silent.
Input settings are on the same page and are now real controls: keyboard repeat,
Num Lock, focus-follows-pointer, pointer speed, and the pointer hide timeout.
Rebinding is not done — that is the remaining half of this stage.
---
## Sequencing note
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# The Shortcuts page is generated from the compositor, so it cannot drift from
# the real keymap. Two things have to hold for that to be true:
#
# * every bind Hyprland reports is presented -- the page count matches
# `hyprctl binds -j` exactly, so adding a bind cannot silently go missing;
# * every bind carries a description. Hyprland reports Lua-defined binds with
# dispatcher "__lua" and a bytecode offset as the argument, so a bind
# without a description has nothing a person could read beside its chord.
# The service drops those rather than showing a mystery row, which means an
# undescribed bind disappears from the page -- this contract is what stops
# that from being a silent loss.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml"
fail() {
printf 'keybinds contract: %s\n' "$1" >&2
exit 1
}
qs_for_harness() {
qs -p "$harness" "$@"
}
cleanup() {
qs_for_harness kill >/dev/null 2>&1 || true
}
trap cleanup EXIT
# ── Every bind in the running compositor must be describable ─────────────────
total="$(hyprctl -j binds | jq 'length')"
undescribed="$(hyprctl -j binds | jq '[.[] | select((.description // "") == "")] | length')"
[[ "$total" -gt 0 ]] || fail 'the compositor reports no binds at all'
if [[ "$undescribed" -ne 0 ]]; then
printf 'keybinds contract: %s bind(s) have no description and would be dropped from the page:\n' "$undescribed" >&2
hyprctl -j binds | jq -r '.[] | select((.description // "") == "") | " modmask=\(.modmask) key=\(.key)"' >&2
fail 'add a description to each in config/dot/hypr/keybinds.lua'
fi
# ── The page must present all of them ────────────────────────────────────────
qs_for_harness --daemonize >/dev/null
for _ in $(seq 1 40); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target keybinds-test$' && break
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target keybinds-test$' || fail 'test IPC target did not start'
state='{}'
for _ in $(seq 1 40); do
state="$(qs_for_harness ipc call keybinds-test status | jq -c .)"
jq -e '.loaded == true' <<<"$state" >/dev/null 2>&1 && break
sleep 0.1
done
jq -e '.loaded == true' <<<"$state" >/dev/null || fail "the keymap never loaded: $state"
presented="$(jq -r .count <<<"$state")"
[[ "$presented" == "$total" ]] \
|| fail "the page presents $presented of $total binds — the two must match exactly"
grouped_total="$(jq -r .groupedCount <<<"$state")"
[[ "$grouped_total" == "$total" ]] \
|| fail "grouping lost binds: $grouped_total grouped from $total"
# ── Chords must be rendered for people, not dumped raw ───────────────────────
jq -e '.sample | test("^Super \\+ ")' <<<"$state" >/dev/null \
|| fail "Super+T did not render as a readable chord: $(jq -r .sample <<<"$state")"
[[ "$(jq -r .emptyDescriptions <<<"$state")" == "0" ]] \
|| fail 'a presented bind has an empty description'
trap - EXIT
cleanup
printf 'keybinds contract: PASS (%s binds, all described)\n' "$total"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Every schema entry with a `hypr` block declares `readAs`: which JSON field
# `hyprctl getoption` answers with for that option. Verification compares
# against that field, so a wrong declaration does not fail loudly -- it makes
# every write to that setting look rejected, and the user sees "Hyprland did not
# apply ..." for a change that actually worked.
#
# cursor:inactive_timeout shipped as "int" and is answered as "float", which is
# exactly that failure. This contract asks the compositor for the real shape of
# every mapped option so the next one cannot reach a release.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
fail() {
printf 'schema hypr shape contract: %s\n' "$1" >&2
exit 1
}
[[ -r "$schema" ]] || fail "cannot read $schema"
mismatches=0
checked=0
while IFS='|' read -r option declared; do
[[ -n "$option" ]] || continue
checked=$((checked + 1))
answer="$(hyprctl -j getoption "$option" 2>/dev/null)" \
|| fail "hyprctl could not read $option"
# An option Hyprland does not know answers without any value field at all.
actual=""
for field in int bool float str css; do
if jq -e --arg f "$field" 'has($f)' <<<"$answer" >/dev/null 2>&1; then
actual="$field"
break
fi
done
[[ -n "$actual" ]] || fail "$option is not a known Hyprland option (answered: $answer)"
if [[ "$actual" != "$declared" ]]; then
printf 'schema hypr shape contract: %s declares readAs "%s" but answers with "%s"\n' \
"$option" "$declared" "$actual" >&2
mismatches=$((mismatches + 1))
fi
done < <(grep -oE 'option: "[^"]+", readAs: "[a-z]+"' "$schema" \
| sed -E 's/option: "([^"]+)", readAs: "([a-z]+)"/\1|\2/')
[[ "$checked" -gt 0 ]] || fail 'no hypr-mapped schema entries were found to check'
[[ "$mismatches" -eq 0 ]] || fail "$mismatches option(s) declare the wrong answer shape"
printf 'schema hypr shape contract: PASS (%d mapped options)\n' "$checked"
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Two behaviours the settings rows depend on:
#
# commitPreference(key, value)
# One entry point for every row. A compositor-backed key must reach Hyprland
# and be confirmed before it is stored; a local key is written directly.
# Rows bind a schema key and call this, so they never need to know which
# kind they hold -- and a row must not be able to store a value the
# compositor rejected.
#
# restoreDefaults()
# Panama keeps user state in more than one file. Resetting only the schema
# store would leave a customised Home accessory arrangement in place while
# claiming to have restored Panama's defaults. That is worse than having no
# reset at all, because it is silent.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml"
# Preferences are committed to $XDG_CONFIG_HOME, and the Home store lives under
# $XDG_STATE_HOME. Both are isolated so this contract cannot touch the real
# desktop's settings; the compositor is the live one and is restored below.
config_home="$(mktemp -d /tmp/panama-commit-config.XXXXXX)"
state_home="$(mktemp -d /tmp/panama-commit-state.XXXXXX)"
fail() {
printf 'settings commit/reset contract: %s\n' "$1" >&2
exit 1
}
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
}
original_rounding="$(hyprctl -j getoption decoration:rounding | jq -r .int)"
original_gaps="$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')"
restore() {
hyprctl eval "hl.config({ decoration = { rounding = $original_rounding }, general = { gaps_out = $original_gaps } })" >/dev/null 2>&1 || true
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$config_home" "$state_home"
}
trap restore EXIT
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' && break
sleep 0.1
done
qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$' || fail 'test IPC target did not start'
# ── A local key is stored directly ───────────────────────────────────────────
[[ "$(qs_for_harness ipc call settings-system-test commit showSeconds false)" == "true" ]] \
|| fail 'commitPreference refused a local key'
[[ "$(qs_for_harness ipc call settings-system-test stored showSeconds)" == "false" ]] \
|| fail 'a local key was not stored'
# ── A compositor key reaches Hyprland, then is stored ────────────────────────
target_rounding=$(( original_rounding == 11 ? 13 : 11 ))
[[ "$(qs_for_harness ipc call settings-system-test commit windowRounding "$target_rounding")" == "true" ]] \
|| fail 'commitPreference refused a compositor key'
for _ in $(seq 1 40); do
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "$target_rounding" ]] && break
sleep 0.1
done
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "$target_rounding" ]] \
|| fail "a compositor-backed commit did not reach Hyprland (rounding=$(hyprctl -j getoption decoration:rounding | jq -r .int))"
[[ "$(qs_for_harness ipc call settings-system-test stored windowRounding)" == "$target_rounding" ]] \
|| fail 'a verified compositor commit was not stored'
# ── A value the schema rejects is never stored ───────────────────────────────
before="$(qs_for_harness ipc call settings-system-test stored windowRounding)"
[[ "$(qs_for_harness ipc call settings-system-test commit windowRounding 9999)" == "true" ]] \
|| fail 'an out-of-range value should be clamped by the schema, not refused outright'
[[ "$(qs_for_harness ipc call settings-system-test stored windowRounding)" != "9999" ]] \
|| fail 'an out-of-range value was stored unclamped'
[[ "$(qs_for_harness ipc call settings-system-test commit __not_a_setting__ 1)" == "false" ]] \
|| fail 'commitPreference accepted a key outside the schema'
# ── Reset spans every store, not just the schema one ─────────────────────────
qs_for_harness ipc call settings-system-test seedHome >/dev/null
qs_for_harness ipc call settings-system-test commit dockHideDelayMs 900 >/dev/null
sleep 0.4
home_before="$(qs_for_harness ipc call settings-system-test homeState)"
jq -e '.count == 1 and .initialized == true' <<<"$home_before" >/dev/null \
|| fail "the Home fixture did not apply: $home_before"
[[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "900" ]] \
|| fail 'the dock fixture did not apply'
qs_for_harness ipc call settings-system-test restoreDefaults >/dev/null
sleep 0.6
[[ "$(qs_for_harness ipc call settings-system-test stored dockHideDelayMs)" == "250" ]] \
|| fail 'reset did not restore a schema default'
home_after="$(qs_for_harness ipc call settings-system-test homeState)"
jq -e '.count == 0 and .initialized == false' <<<"$home_after" >/dev/null \
|| fail "reset left the Home accessory store customised: $home_after"
# Resetting a stored value does not by itself tell Hyprland anything, so the
# reset must re-apply compositor-backed defaults too.
for _ in $(seq 1 40); do
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "18" ]] && break
sleep 0.1
done
[[ "$(hyprctl -j getoption decoration:rounding | jq -r .int)" == "18" ]] \
|| fail "reset did not re-apply the compositor default (rounding=$(hyprctl -j getoption decoration:rounding | jq -r .int))"
trap - EXIT
restore
printf 'settings commit/reset contract: PASS\n'