diff --git a/config/dot/hypr/README.md b/config/dot/hypr/README.md index 9873b6d..cd5e179 100644 --- a/config/dot/hypr/README.md +++ b/config/dot/hypr/README.md @@ -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 diff --git a/config/dot/hypr/keybinds.lua b/config/dot/hypr/keybinds.lua index b08c8c5..247caa0 100644 --- a/config/dot/hypr/keybinds.lua +++ b/config/dot/hypr/keybinds.lua @@ -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 diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index c98dd51..1b4ae14 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -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" diff --git a/config/dot/quickshell/keybinds-harness.qml b/config/dot/quickshell/keybinds-harness.qml new file mode 100644 index 0000000..1cd8614 --- /dev/null +++ b/config/dot/quickshell/keybinds-harness.qml @@ -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 + }); + } + } +} diff --git a/config/dot/quickshell/modules/settings/ActionRow.qml b/config/dot/quickshell/modules/settings/ActionRow.qml new file mode 100644 index 0000000..98cf866 --- /dev/null +++ b/config/dot/quickshell/modules/settings/ActionRow.qml @@ -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() + } +} diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index 0380ada..475f1c1 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -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 } - - 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 } + DesktopPreview { + width: parent.width } - 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) } - } - } - - 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) } } + 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: "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" + + ToggleRow { setting: "use24Hour" } + ToggleRow { setting: "showSeconds" } + ToggleRow { setting: "showWeekday"; divider: false } + } + + SettingsCard { + title: "System vitals" + subtitle: "Choose what appears beside the workspace indicator." + + 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 } + } } diff --git a/config/dot/quickshell/modules/settings/ChoiceRow.qml b/config/dot/quickshell/modules/settings/ChoiceRow.qml new file mode 100644 index 0000000..f54786c --- /dev/null +++ b/config/dot/quickshell/modules/settings/ChoiceRow.qml @@ -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) + } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/DesktopPage.qml b/config/dot/quickshell/modules/settings/DesktopPage.qml index f949333..01f3e94 100644 --- a/config/dot/quickshell/modules/settings/DesktopPage.qml +++ b/config/dot/quickshell/modules/settings/DesktopPage.qml @@ -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 + title: "Desktop & Dock" + lede: "Keep the shell instant, spatial, and out of your way." - 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 } + SettingsCard { + title: "Dock" - 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 } - } + SettingsCard { + title: "Window layout" + subtitle: "Panama follows the Forge mental model with native Hyprland tiling." - 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" - divider: false - controlWidth: 122 - SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Restore defaults"; onClicked: DesktopPreferences.resetDesktopDefaults() } - } - } + 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: "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 + onTriggered: SystemSettings.restoreDefaults() } } } diff --git a/config/dot/quickshell/modules/settings/DesktopPreview.qml b/config/dot/quickshell/modules/settings/DesktopPreview.qml new file mode 100644 index 0000000..0f7a30c --- /dev/null +++ b/config/dot/quickshell/modules/settings/DesktopPreview.qml @@ -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 + } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsPage.qml b/config/dot/quickshell/modules/settings/SettingsPage.qml new file mode 100644 index 0000000..1336928 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SettingsPage.qml @@ -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 + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/ShortcutsPage.qml b/config/dot/quickshell/modules/settings/ShortcutsPage.qml index 2978cd0..3a00e70 100644 --- a/config/dot/quickshell/modules/settings/ShortcutsPage.qml +++ b/config/dot/quickshell/modules/settings/ShortcutsPage.qml @@ -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 + title: "Input & Shortcuts" + lede: "The Forge mental model, carried forward into native tiling." - Column { - id: content - width: parent.width - 68 - x: 34 - y: 30 - spacing: 16 + SettingsCard { + title: "Keyboard" - 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 } + SliderRow { setting: "keyRepeatDelay" } + SliderRow { setting: "keyRepeatRate" } + ToggleRow { setting: "numlockByDefault"; divider: false } + } - 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 - } - } - } + SettingsCard { + title: "Pointer" - SettingsCard { - title: "Hardware input" - SettingRow { - label: "Mouse, touchpad, and keyboard devices" - detail: "Use Fedora's hardware-backed input panels" - divider: false - controlWidth: 126 - SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open keyboard"; onClicked: SystemSettings.openGnomePanel("keyboard") } + ChoiceRow { setting: "followMouse" } + SliderRow { setting: "pointerSensitivity" } + SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false } + } + + SettingsCard { + title: "Hardware input" + + ActionRow { + label: "Mouse, touchpad, and keyboard devices" + detail: "Device-specific settings stay with Fedora's hardware-backed panels" + action: "Open keyboard" + divider: false + 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() + } + } } diff --git a/config/dot/quickshell/modules/settings/SliderRow.qml b/config/dot/quickshell/modules/settings/SliderRow.qml new file mode 100644 index 0000000..e6d0438 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SliderRow.qml @@ -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 + } +} diff --git a/config/dot/quickshell/modules/settings/TextRow.qml b/config/dot/quickshell/modules/settings/TextRow.qml new file mode 100644 index 0000000..de2baae --- /dev/null +++ b/config/dot/quickshell/modules/settings/TextRow.qml @@ -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 +} diff --git a/config/dot/quickshell/modules/settings/ToggleRow.qml b/config/dot/quickshell/modules/settings/ToggleRow.qml new file mode 100644 index 0000000..bb5814d --- /dev/null +++ b/config/dot/quickshell/modules/settings/ToggleRow.qml @@ -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) + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 56d86bb..f9a1b42 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -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 diff --git a/config/dot/quickshell/services/Keybinds.qml b/config/dot/quickshell/services/Keybinds.qml new file mode 100644 index 0000000..15ce4ab --- /dev/null +++ b/config/dot/quickshell/services/Keybinds.qml @@ -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] })); + } +} diff --git a/config/dot/quickshell/services/SystemSettings.qml b/config/dot/quickshell/services/SystemSettings.qml index b62b0d8..79a4338 100644 --- a/config/dot/quickshell/services/SystemSettings.qml +++ b/config/dot/quickshell/services/SystemSettings.qml @@ -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 { diff --git a/config/dot/quickshell/settings-system-harness.qml b/config/dot/quickshell/settings-system-harness.qml index f6f44c3..b1d7065 100644 --- a/config/dot/quickshell/settings-system-harness.qml +++ b/config/dot/quickshell/settings-system-harness.qml @@ -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); } diff --git a/docs/superpowers/plans/2026-08-17-panama-cohesion.md b/docs/superpowers/plans/2026-08-17-panama-cohesion.md index f48820d..41d0ec8 100644 --- a/docs/superpowers/plans/2026-08-17-panama-cohesion.md +++ b/docs/superpowers/plans/2026-08-17-panama-cohesion.md @@ -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 diff --git a/tests/quickshell/keybinds-contract.sh b/tests/quickshell/keybinds-contract.sh new file mode 100755 index 0000000..041863b --- /dev/null +++ b/tests/quickshell/keybinds-contract.sh @@ -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" diff --git a/tests/quickshell/schema-hypr-shape-contract.sh b/tests/quickshell/schema-hypr-shape-contract.sh new file mode 100755 index 0000000..edc0830 --- /dev/null +++ b/tests/quickshell/schema-hypr-shape-contract.sh @@ -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" diff --git a/tests/quickshell/settings-commit-reset-contract.sh b/tests/quickshell/settings-commit-reset-contract.sh new file mode 100755 index 0000000..1832772 --- /dev/null +++ b/tests/quickshell/settings-commit-reset-contract.sh @@ -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'