diff --git a/README.md b/README.md index bc07acc..7007edf 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -156 of them, under `tests/`. Run the lot, or a subset by pattern: +158 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/config/dot/hypr/input.lua b/config/dot/hypr/input.lua index cea64aa..ec40d51 100644 --- a/config/dot/hypr/input.lua +++ b/config/dot/hypr/input.lua @@ -35,12 +35,39 @@ hl.config({ -- Still focus-follows-pointer, just less twitchy. mouse_refocus = false, - -- Flat pointer response, no acceleration. Matters for gaming. + -- Flat pointer response by default, no acceleration. Matters for + -- gaming; Settings offers adaptive for people who want it back. sensitivity = prefs.get("pointerSensitivity", 0), - accel_profile = "flat", + accel_profile = prefs.get("accelProfile", "flat"), + + natural_scroll = prefs.get("naturalScroll", false), + scroll_factor = prefs.get("scrollFactor", 1.0), + left_handed = prefs.get("leftHanded", false), -- Clicking a floating window raises and focuses it. float_switch_override_focus = 2, + + -- Every touchpad preference Settings offers is read here as well as + -- applied live: the live half (hyprctl eval) reaches a running + -- compositor, and THIS half is what survives `hyprctl reload` and the + -- gap before the shell starts. A schema `hypr:` entry without a + -- prefs.get() here is a control that quietly reverts -- the whole + -- Mouse & Touchpad page once had exactly that bug. Defaults must + -- match PreferenceSchema's; the hypr-prefs-contract checks both. + touchpad = { + tap_to_click = prefs.get("touchpadTapToClick", true), + natural_scroll = prefs.get("touchpadNaturalScroll", true), + disable_while_typing = prefs.get("touchpadDisableWhileTyping", true), + scroll_factor = prefs.get("touchpadScrollFactor", 1.0), + drag_lock = prefs.getInt("touchpadDragLock", 0), + middle_button_emulation = prefs.get("touchpadMiddleButtonEmulation", false), + }, + }, + + -- Tuning for the three-finger gestures registered below. + gestures = { + workspace_swipe_distance = prefs.getInt("swipeDistance", 300), + workspace_swipe_invert = prefs.get("swipeInvert", true), }, }) diff --git a/config/dot/hypr/looks.lua b/config/dot/hypr/looks.lua index c03306a..edafe55 100644 --- a/config/dot/hypr/looks.lua +++ b/config/dot/hypr/looks.lua @@ -68,10 +68,10 @@ hl.config({ -- Harmless on its own; tearing only happens where a rule opts in. allow_tearing = true, - layout = "dwindle", + layout = prefs.get("windowLayout", "dwindle"), snap = { - enabled = true, + enabled = prefs.get("windowSnapping", true), window_gap = prefs.getInt("snapWindowGap", 10), monitor_gap = prefs.getInt("snapMonitorGap", 10), respect_gaps = prefs.get("snapRespectGaps", false), @@ -88,6 +88,10 @@ hl.config({ fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0), inactive_opacity = prefs.get("inactiveOpacity", 1.0), + -- Accessibility: dim every window but the focused one. + dim_inactive = prefs.get("dimInactive", false), + dim_strength = prefs.get("dimStrength", 0.5), + blur = { enabled = prefs.get("blurEnabled", true), size = prefs.get("blurSize", 8), @@ -150,6 +154,7 @@ hl.config({ -- to how the Forge extension behaved on GNOME. preserve_split = prefs.get("preserveSplit", true), smart_resizing = true, + force_split = prefs.getInt("forceSplit", 0), }, -- Only in effect when the tiling layout is "master". Panama ships dwindle, @@ -193,7 +198,11 @@ hl.config({ allow_session_lock_restore = true, -- Don't let apps steal focus by shouting; matches GNOME's behavior. - focus_on_activate = false, + focus_on_activate = prefs.get("focusOnActivate", false), + + -- Whether pointing at another monitor is enough to move focus there, + -- or it takes a click. GNOME moves on pointer; both are offered. + mouse_move_focuses_monitor = prefs.get("mouseMoveFocusesMonitor", true), -- Window swallowing: a terminal hides itself while a graphical -- application launched from it is open, and comes back when that @@ -237,6 +246,10 @@ hl.config({ -- Fade the cursor out after 4s of no movement, like GNOME does. inactive_timeout = prefs.get("cursorInactiveTimeout", 4), + + -- The accessibility magnifier. 1.0 = off. + zoom_factor = prefs.get("magnifierFactor", 1.0), + zoom_rigid = prefs.get("magnifierRigid", false), }, ecosystem = { diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index d25275b..63108de 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -1446,7 +1446,8 @@ Singleton { }, // ── Per-application notification rules ────────────────────────────── - // { "": { enabled, showOnLockScreen, showContentOnLockScreen } } + // { "": { enabled } } -- lock-screen fields from older rules are + // dropped at normalization; hyprlock cannot render notifications. // // Absent means "no rule", which is not the same as a rule that allows // everything: a new application must be able to notify without needing diff --git a/config/dot/quickshell/modules/settings/AccessibilityPage.qml b/config/dot/quickshell/modules/settings/AccessibilityPage.qml index 6fe201a..c115f5d 100644 --- a/config/dot/quickshell/modules/settings/AccessibilityPage.qml +++ b/config/dot/quickshell/modules/settings/AccessibilityPage.qml @@ -62,32 +62,26 @@ SettingsPage { SliderRow { setting: "dimStrength"; divider: false } } - // What this session genuinely cannot do, said plainly. - // - // Sticky keys, slow keys, bounce keys and mouse keys are AccessX, which is - // an X11 SERVER feature. XKB under Wayland has no accessx option group at - // all, and Hyprland does not implement one. The compositor will accept - // "accessx:enable" as a keyboard option and store it, and nothing will ever - // act on it -- so there is no switch here, and pointing at GNOME's panel - // would be no better, since the daemon that applies those keys is not - // running either. + // What this session genuinely cannot do, said plainly -- and for the + // right reason. On Wayland there is no protocol for sticky, slow or + // bounce keys: each compositor implements its own (mutter does, which is + // how GNOME has them on Wayland), and Hyprland does not yet. An earlier + // version blamed "X11 feature with no Wayland equivalent", which sent + // anyone who needs sticky keys to the wrong conclusion about the whole + // platform. There is also deliberately no handoff to GNOME's + // universal-access panel: its toggles are applied by GNOME Shell, and + // the few that work through plain gsettings (cursor size, text scale, + // high contrast) are owned by the controls above on this very page. SettingsCard { title: "Keyboard accessibility" - subtitle: "Sticky, slow and bounce keys are an X11 feature with no Wayland equivalent, so they are unavailable in this session. Offering them here would store a preference that nothing acts on." + subtitle: "Sticky, slow and bounce keys are implemented by each Wayland compositor for itself; Hyprland does not implement them yet, so they are unavailable in this session. Offering switches here would store preferences nothing acts on." ActionRow { label: "Screen reader" detail: "Orca reads the screen aloud and works over the accessibility bus, which does run here" action: "Start Orca" - onTriggered: SystemSettings.openApplication("orca") - } - - ActionRow { - label: "GNOME accessibility settings" - detail: "For the parts GNOME's own stack still owns" - action: "Open" divider: false - onTriggered: SystemSettings.openGnomePanel("universal-access") + onTriggered: SystemSettings.openApplication("orca") } } diff --git a/config/dot/quickshell/modules/settings/ConnectivityPage.qml b/config/dot/quickshell/modules/settings/ConnectivityPage.qml index 475224a..43ec75c 100644 --- a/config/dot/quickshell/modules/settings/ConnectivityPage.qml +++ b/config/dot/quickshell/modules/settings/ConnectivityPage.qml @@ -185,11 +185,24 @@ SettingsPage { SettingsCard { title: "Owned by Fedora" - subtitle: "VPNs and per-connection routing are still configured by GNOME's panel, which is installed and searchable. Printers and online accounts have their own pages here." + // These two panels drive NetworkManager over D-Bus, which is why they + // work in this session when most GNOME panels do not. They are split + // because GNOME splits them: "network" is wired, VPN and proxies, and + // does not contain Wi-Fi -- the one panel used to point everyone + // there, so the road to a hidden SSID or eduroam ended on a page + // without Wi-Fi on it. + subtitle: "Wired, VPN and Wi-Fi connection editing stay with GNOME's panels, which drive the same NetworkManager this page reads. Printers and online accounts have their own pages here." + + ActionRow { + label: "Wi-Fi networks" + detail: "Hidden networks, enterprise (802.1X) logins, and per-network settings" + action: "Open" + onTriggered: SystemSettings.openGnomePanel("wifi") + } ActionRow { label: "Network connections" - detail: "VPN, proxies, and per-connection settings" + detail: "VPN, proxies, and wired connection settings" action: "Open" divider: false onTriggered: SystemSettings.openGnomePanel("network") diff --git a/config/dot/quickshell/modules/settings/NotificationsPage.qml b/config/dot/quickshell/modules/settings/NotificationsPage.qml index 00b9068..87665e2 100644 --- a/config/dot/quickshell/modules/settings/NotificationsPage.qml +++ b/config/dot/quickshell/modules/settings/NotificationsPage.qml @@ -7,7 +7,6 @@ SettingsPage { // Only one application is expanded at a time; the point is a card you // can read, not twenty open at once. - property string expandedApp: "" // Which focus mode is open for editing. One at a time, like the app rules. property string expandedMode: "" @@ -117,87 +116,21 @@ SettingsPage { readonly property var app: appEntry.modelData readonly property var rule: Notifs.appRule(appEntry.app.id) - readonly property bool open: root.expandedApp === String(appEntry.app.id) - - // What the two lock-screen switches add up to, so the common - // case -- reading rather than changing -- needs no interaction. - // Every app repeated both switch labels verbatim before this, - // which made twenty apps sixty rows of identical sentences. - readonly property string summary: { - if (!appEntry.rule.enabled) - return "Notifications off"; - if (!appEntry.rule.showOnLockScreen) - return "On · hidden on the lock screen"; - return appEntry.rule.showContentOnLockScreen - ? "On · lock screen shows content" - : "On · lock screen shows the sender only"; - } width: parent.width SettingRow { width: parent.width label: appEntry.app.name - // The identifier is only worth the space while the app is - // open, which is the only time it disambiguates anything. - detail: appEntry.open ? String(appEntry.app.id) : appEntry.summary - activatable: true - divider: !appEntry.open - controlWidth: 92 - onActivated: root.expandedApp = appEntry.open ? "" : String(appEntry.app.id) - - Row { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 11 - - SettingsToggle { - anchors.verticalCenter: parent.verticalCenter - checked: appEntry.rule.enabled - onToggled: value => Notifs.setAppRule(appEntry.app.id, { enabled: value }) - } - - Text { - anchors.verticalCenter: parent.verticalCenter - text: appEntry.open ? "\u25B4" : "\u25BE" - color: Theme.fgMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSmall - } - } - } - - SettingRow { - width: parent.width - visible: appEntry.open - label: "Show on lock screen" - detail: "Allow this app's notifications on the lock screen" + detail: appEntry.rule.enabled ? String(appEntry.app.id) : "Notifications off" + divider: true controlWidth: 48 SettingsToggle { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - checked: appEntry.rule.showOnLockScreen - onToggled: value => Notifs.setAppRule(appEntry.app.id, { showOnLockScreen: value }) - } - } - - SettingRow { - width: parent.width - visible: appEntry.open - label: "Show content on lock screen" - detail: "Show message details when this app is visible there" - divider: false - controlWidth: 48 - // Meaningless unless the app reaches the lock screen at all. - opacity: appEntry.rule.showOnLockScreen ? 1 : 0.45 - - SettingsToggle { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - enabled: appEntry.rule.showOnLockScreen - checked: appEntry.rule.showContentOnLockScreen - onToggled: value => Notifs.setAppRule(appEntry.app.id, { showContentOnLockScreen: value }) + checked: appEntry.rule.enabled + onToggled: value => Notifs.setAppRule(appEntry.app.id, { enabled: value }) } } } diff --git a/config/dot/quickshell/modules/settings/PowerPage.qml b/config/dot/quickshell/modules/settings/PowerPage.qml index eaa935d..fb9e6e4 100644 --- a/config/dot/quickshell/modules/settings/PowerPage.qml +++ b/config/dot/quickshell/modules/settings/PowerPage.qml @@ -46,6 +46,15 @@ SettingsPage { return "Plugged in, not charging"; return "On battery"; } + } + + // The two points at which the desktop starts telling you. These were + // in the schema and reachable from settings search long before any + // page rendered them -- search delivered people to this card and the + // controls were not here. + SliderRow { setting: "batteryLowPercent" } + SliderRow { + setting: "batteryCriticalPercent" divider: Battery.chargeLimitSupported } diff --git a/config/dot/quickshell/modules/settings/ShortcutsPage.qml b/config/dot/quickshell/modules/settings/ShortcutsPage.qml index 5d6321a..c49b0be 100644 --- a/config/dot/quickshell/modules/settings/ShortcutsPage.qml +++ b/config/dot/quickshell/modules/settings/ShortcutsPage.qml @@ -111,17 +111,12 @@ SettingsPage { ToggleRow { setting: "numlockByDefault"; 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") - } - } + // Deliberately no handoff to GNOME's keyboard panel here. That panel + // writes org.gnome.desktop input-source and shortcut gsettings, which + // nothing in a Hyprland session reads -- so the button looked like the + // escape hatch for layouts and did nothing, while the controls that DO + // work sat further up this same page. A handoff to an inert panel is a + // dead end wearing a button. // One card per group, built from what the compositor actually has bound. // diff --git a/config/dot/quickshell/scripts/panama-idle b/config/dot/quickshell/scripts/panama-idle index 38d1c54..fd8177d 100755 --- a/config/dot/quickshell/scripts/panama-idle +++ b/config/dot/quickshell/scripts/panama-idle @@ -71,16 +71,24 @@ load() { suffix="Battery" fi - # The battery variants fall back to their AC counterparts rather than to a - # constant, so a machine whose battery keys were never written still gets - # coherent behavior instead of the schema's shipped shorter timings - # overriding a deliberately long AC setting. - blank_min="$(clamp_int "$(read_setting "screenBlankMinutes$suffix" \ - "$(read_setting screenBlankMinutes 5)")" 0 120 5)" - lock_min="$(clamp_int "$(read_setting "lockMinutes$suffix" \ - "$(read_setting lockMinutes 10)")" 0 240 10)" - suspend_min="$(clamp_int "$(read_setting "suspendMinutes$suffix" \ - "$(read_setting suspendMinutes 0)")" 0 480 0)" + # An unwritten key falls back to ITS OWN schema default, never to the + # other power source's value. The battery keys once fell back to their AC + # counterparts, which sounded protective and produced a lie instead: the + # Power page shows the schema default (suspend at 20) for an unwritten + # battery key, while this generator quietly used the AC value (never), so + # a fresh laptop displayed one behavior and shipped another -- and + # discharged to zero in a bag. Whatever the sliders show is what must be + # generated; these fallbacks are pinned to the schema by + # tests/hypr/idle-defaults-contract. + if [[ "$suffix" == "Battery" ]]; then + blank_min="$(clamp_int "$(read_setting screenBlankMinutesBattery 2)" 0 120 2)" + lock_min="$(clamp_int "$(read_setting lockMinutesBattery 5)" 0 240 5)" + suspend_min="$(clamp_int "$(read_setting suspendMinutesBattery 20)" 0 480 20)" + else + blank_min="$(clamp_int "$(read_setting screenBlankMinutes 5)" 0 120 5)" + lock_min="$(clamp_int "$(read_setting lockMinutes 10)" 0 240 10)" + suspend_min="$(clamp_int "$(read_setting suspendMinutes 0)" 0 480 0)" + fi lock_on_sleep="$(read_setting lockOnSleep true)" [[ "$lock_on_sleep" == "true" || "$lock_on_sleep" == "false" ]] || lock_on_sleep=true } @@ -162,7 +170,15 @@ case "${1:-apply}" in install) generate install_dropin - systemctl --user restart hypridle.service + # Restart only a daemon that is already running. `restart` on an + # inactive unit STARTS it, and this verb now also runs from + # change-settings during ./install -- possibly inside a GNOME + # session, where starting hypridle would fight GNOME's own idle + # handling. A session that has not started hypridle yet picks the + # drop-in up at its next launch. + if systemctl --user is-active -q hypridle.service; then + systemctl --user restart hypridle.service + fi ;; remove) rm -f "$dropin" diff --git a/config/dot/quickshell/services/DeviceEvents.qml b/config/dot/quickshell/services/DeviceEvents.qml index 3db9948..932b65f 100644 --- a/config/dot/quickshell/services/DeviceEvents.qml +++ b/config/dot/quickshell/services/DeviceEvents.qml @@ -120,6 +120,29 @@ Singleton { } } + // The "Warn at" threshold from the Power page. A quiet mention, not an + // interruption: Do Not Disturb may still silence it, and the bar glyph + // turns with it. For a long time this threshold changed only the glyph's + // color, which nobody watching their work ever saw -- the first actual + // message arrived at the critical level. + Connections { + target: Battery + function onLowChanged(): void { + if (!root.powerInitialized || !Battery.low || Battery.critical) + return; + StatusEvents.publish({ + key: "battery-low", + glyph: "\u{F007A}", + title: "Battery low", + detail: Math.round(Battery.percent) + "% remaining", + tone: "warning", + priority: StatusEvents.importantPriority, + actionId: "open-settings", + actionData: "power" + }); + } + } + // Running out is not ambient. Published at a priority Do Not Disturb does // not silence, because the one notification you must not miss is the one // saying the machine is about to stop. diff --git a/config/dot/quickshell/services/Notifs.qml b/config/dot/quickshell/services/Notifs.qml index 69e098a..0d73275 100644 --- a/config/dot/quickshell/services/Notifs.qml +++ b/config/dot/quickshell/services/Notifs.qml @@ -113,11 +113,9 @@ Singleton { function normalizedAppRule(rule: var): var { const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {}; - return { - enabled: source.enabled !== false, - showOnLockScreen: source.showOnLockScreen !== false, - showContentOnLockScreen: source.showContentOnLockScreen !== false - }; + // Only `enabled` -- see the lock-screen note below for why the rule + // model carries nothing else, and drops old fields on the way through. + return { enabled: source.enabled !== false }; } function appRule(appId: string): var { @@ -133,9 +131,7 @@ Singleton { for (const knownAppId of Object.keys(root.appRules)) next[knownAppId] = root.appRule(knownAppId); next[appId] = { - enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true, - showOnLockScreen: patch.showOnLockScreen === undefined ? current.showOnLockScreen : patch.showOnLockScreen === true, - showContentOnLockScreen: patch.showContentOnLockScreen === undefined ? current.showContentOnLockScreen : patch.showContentOnLockScreen === true + enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true }; if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next)) @@ -158,17 +154,14 @@ Singleton { return appId; } - // These policy getters deliberately accept Notification objects, so a lock - // screen can use the same source of truth without duplicating app matching. - function shouldShowOnLockScreen(notification: var): bool { - const rule = root.appRule(root.notificationAppId(notification)); - return rule.enabled && rule.showOnLockScreen; - } - - function shouldShowContentOnLockScreen(notification: var): bool { - const rule = root.appRule(root.notificationAppId(notification)); - return rule.enabled && rule.showOnLockScreen && rule.showContentOnLockScreen; - } + // There are deliberately no lock-screen policy getters here, and no + // lock-screen switches on the Notifications page. The lock screen is + // hyprlock, which cannot render notifications, so two per-app privacy + // toggles shipped for a while that controlled nothing -- on the page a + // person checks precisely when they care. Stored rules may still carry + // showOnLockScreen fields from that era; nothing reads them. If a lock + // screen that can render notifications ever exists, the rule store and + // appRule() are the right place to hang its policy back onto. // history grouped by app, in most-recent-app-first order — the shape // NotificationList.qml renders directly. diff --git a/setup/scripts/change-settings b/setup/scripts/change-settings index 3cda425..529b88b 100755 --- a/setup/scripts/change-settings +++ b/setup/scripts/change-settings @@ -95,3 +95,16 @@ if exists vicinae; then log "Setting vicinae theme" vicinae theme set tokyonight-moon >/dev/null 2>&1 || true fi + +# A machine with a battery gets managed idle timings from the start. The +# shipped hypridle.conf deliberately never suspends -- correct for a desktop, +# and exactly wrong for a laptop in a bag. panama-idle generates the +# power-source-aware config and points hypridle at it with a drop-in; it does +# not start hypridle here (the Hyprland session does that), so running it +# under GNOME during install is safe. A desktop skips this entirely and keeps +# the shipped file, as before. +if "${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-hw" battery 2>/dev/null; then + log "Battery detected: enabling managed idle timings (suspend on battery works out of the box)" + "${PANAMA_PATH:-$HOME/.local/share/Panama}/config/dot/quickshell/scripts/panama-idle" install \ + || log "Could not enable managed idle timings; the Power page can turn them on later" +fi diff --git a/tests/hypr/hypr-prefs-contract b/tests/hypr/hypr-prefs-contract new file mode 100755 index 0000000..b585370 --- /dev/null +++ b/tests/hypr/hypr-prefs-contract @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +# Every compositor-owned preference must be read at config time, not only +# applied live. +# +# A schema entry with `hypr:` metadata gets two delivery paths by design +# (stated in services/SystemSettings.qml): hyprctl eval reaches the running +# compositor, and a prefs.get() in the Lua is what survives `hyprctl reload` +# and the moment before the shell starts. Twenty-one keys once shipped with +# only the live half -- the entire Mouse & Touchpad page quietly reverted on +# every reload, and the schema's touchpad natural-scroll default disagreed +# with Hyprland's, so the page misreported the hardware until the shell's +# startup replay ran. +# +# Two properties, then: every hypr-backed key has a prefs.get() somewhere in +# config/dot/hypr/, and the Lua fallback equals the schema default -- a +# different fallback is the same lie on a different day. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +python3 - "$repo_dir" <<'PY' +import re, sys, pathlib + +repo = pathlib.Path(sys.argv[1]) +schema = (repo / "config/dot/quickshell/config/PreferenceSchema.qml").read_text() + +# Pair each key with its entry body (up to the next key) and keep the ones +# carrying hypr: metadata, along with their declared default. +keys = [(m.group(1), m.start()) for m in re.finditer(r'key: "([A-Za-z]+)"', schema)] +failures = [] +backed = {} +for i, (key, pos) in enumerate(keys): + end = keys[i + 1][1] if i + 1 < len(keys) else len(schema) + body = schema[pos:end] + if "hypr: {" not in body: + continue + m = re.search(r'def: (\([^)]*\)|"[^"]*"|[-\d.]+|true|false)', body) + if not m: + failures.append(f"{key}: hypr-backed but its default could not be read") + continue + backed[key] = m.group(1).strip('"') + +lua = "" +for path in (repo / "config/dot/hypr").glob("*.lua"): + lua += path.read_text() + +def normalize(value): + # A bool schema key can back an int compositor option (autoHdr -> render: + # cm_auto_hdr), read through prefs.getInt; true/1 and false/0 are the + # same declared default on the two sides. + v = {"true": "1", "false": "0"}.get(str(value), str(value)) + try: + return repr(float(v)) + except ValueError: + return v + +for key, default in sorted(backed.items()): + reads = re.findall( + r'prefs\.get(?:Int)?\(\s*"' + key + r'"\s*,\s*("[^"]*"|[-\d.]+|true|false)\s*\)', lua) + if not reads: + failures.append(f"{key}: no prefs.get() in config/dot/hypr -- reverts on hyprctl reload") + continue + for fallback in reads: + if normalize(fallback.strip('"')) != normalize(default): + failures.append( + f"{key}: Lua fallback {fallback} disagrees with schema default {default!r}") + +if failures: + print(f"hypr prefs contract: {len(failures)} finding(s)") + for failure in failures: + print(f" - {failure}") + sys.exit(1) +print(f"hypr prefs contract: ok ({len(backed)} compositor-owned keys read at config time)") +PY diff --git a/tests/hypr/idle-config-contract b/tests/hypr/idle-config-contract index 17b4699..0385180 100755 --- a/tests/hypr/idle-config-contract +++ b/tests/hypr/idle-config-contract @@ -134,13 +134,23 @@ grep -q 'timeout = 1800' "$generated" || fail 'on wall power, the AC blank timin grep -q 'timeout = 2700' "$generated" || fail 'on wall power, the AC lock timing was not used' grep -q 'systemctl suspend' "$generated" && fail 'on wall power, a battery-only suspend listener was written' -# A battery key that was never written falls back to its AC counterpart. +# A battery key that was never written falls back to ITS OWN schema default, +# not to the AC value. This section once pinned the opposite -- protecting a +# deliberate AC setting from being "overridden" by unplugging -- but the +# Power page's battery card shows the schema defaults for unwritten battery +# keys, so the AC fallback made the generator disagree with what the screen +# said: a fresh laptop displayed "suspend at 20 minutes on battery" and +# generated no suspend listener at all. Whatever the sliders show is what +# must be generated; a person who wants battery to match AC sets it so, on +# the card that has said the real values all along. stub_hw 0 1 run_powered '{"screenBlankMinutes":30,"lockMinutes":45}' -grep -q 'timeout = 1800' "$generated" \ - || fail 'an unset battery blank did not fall back to the AC value, so unplugging would override a deliberate setting' -grep -q 'timeout = 2700' "$generated" \ - || fail 'an unset battery lock did not fall back to the AC value' +grep -q 'timeout = 120' "$generated" \ + || fail 'an unset battery blank did not use the schema default the Power page displays' +grep -q 'timeout = 300' "$generated" \ + || fail 'an unset battery lock did not use the schema default the Power page displays' +grep -q 'systemctl suspend' "$generated" \ + || fail 'an unset battery suspend generated no suspend listener -- the discharged-in-a-bag bug, back again' # No battery at all: the battery keys are never consulted, even when present. stub_hw 1 0 diff --git a/tests/hypr/idle-defaults-contract b/tests/hypr/idle-defaults-contract new file mode 100755 index 0000000..8012972 --- /dev/null +++ b/tests/hypr/idle-defaults-contract @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +# The idle generator's fallbacks must equal the schema's defaults. +# +# The Power page shows the schema default for an unwritten key; panama-idle +# generates from its own fallback for the same key. When the two disagreed +# (schema said suspend on battery at 20 minutes, the generator fell back to +# the AC value, never), a fresh laptop displayed one behavior and shipped +# another. The UI and the generator read the same file; this pins them to the +# same defaults too. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +python3 - "$repo_dir" <<'PY' +import re, sys, pathlib + +repo = pathlib.Path(sys.argv[1]) +schema = (repo / "config/dot/quickshell/config/PreferenceSchema.qml").read_text() +idle = (repo / "config/dot/quickshell/scripts/panama-idle").read_text() + +def schema_default(key): + m = re.search(r'key: "' + key + r'".*?def: ([-\d.]+|true|false)', schema, re.S) + return m.group(1) if m else None + +failures = [] +# Every read_setting with a literal fallback, including the nested clamp_int +# default that guards a malformed value. +for key, fallback in re.findall(r'read_setting\s+"?([A-Za-z]+)"?\s+(?:\\\n\s*)?"?([\w.]+)"?', idle): + expected = schema_default(key) + if expected is None: + failures.append(f"{key}: generator reads a key the schema does not declare") + elif fallback != expected: + failures.append(f"{key}: generator falls back to {fallback}, schema default is {expected}") + +if failures: + print(f"idle defaults contract: {len(failures)} finding(s)") + for failure in failures: + print(f" - {failure}") + sys.exit(1) +print("idle defaults contract: ok (generator fallbacks match the schema)") +PY diff --git a/tests/quickshell/NotificationAppRulesHarness.qml b/tests/quickshell/NotificationAppRulesHarness.qml index 35c87f2..5eb692b 100644 --- a/tests/quickshell/NotificationAppRulesHarness.qml +++ b/tests/quickshell/NotificationAppRulesHarness.qml @@ -76,12 +76,6 @@ ShellRoot { const dnd = root.notification(3, "org.signal.Signal.desktop", "Signal"); Notifs.handleNotification(dnd); - Notifs.setAppRule("org.privacy.App.desktop", { - showOnLockScreen: false, - showContentOnLockScreen: false - }); - const privateNotification = root.notification(4, "org.privacy.App.desktop", "Private"); - return JSON.stringify({ appId: appId, initialRules: initialRules, @@ -95,10 +89,6 @@ ShellRoot { history: Notifs.history.length, popups: Notifs.popups.length, unread: Notifs.unreadCount - }, - privacy: { - visible: Notifs.shouldShowOnLockScreen(privateNotification), - content: Notifs.shouldShowContentOnLockScreen(privateNotification) } }); } @@ -107,11 +97,7 @@ ShellRoot { root.reset(); const notification = root.notification(5, "org.persist.App.desktop", "Persist"); Notifs.handleNotification(notification); - Notifs.setAppRule("org.persist.App.desktop", { - enabled: false, - showOnLockScreen: true, - showContentOnLockScreen: false - }); + Notifs.setAppRule("org.persist.App.desktop", { enabled: false }); return JSON.stringify(DesktopPreferences.get("notificationAppRules")); } diff --git a/tests/quickshell/notification-app-rules-contract b/tests/quickshell/notification-app-rules-contract index cf5560e..0e6f5df 100755 --- a/tests/quickshell/notification-app-rules-contract +++ b/tests/quickshell/notification-app-rules-contract @@ -56,14 +56,17 @@ for (const fixture of identityFixtures) { } const defaultRule = normalizedAppRule({}); -if (JSON.stringify(defaultRule) !== JSON.stringify({ enabled: true, showOnLockScreen: true, showContentOnLockScreen: true })) +if (JSON.stringify(defaultRule) !== JSON.stringify({ enabled: true })) fail(`missing rule fields did not default safely: ${JSON.stringify(defaultRule)}`); +// Old stored rules may still carry lock-screen fields from the era when the +// page offered switches for them; normalization must drop dead fields, not +// carry them forward as if something read them. const explicitRule = normalizedAppRule({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false }); -if (JSON.stringify(explicitRule) !== JSON.stringify({ enabled: false, showOnLockScreen: false, showContentOnLockScreen: false })) - fail(`explicit rule was not preserved: ${JSON.stringify(explicitRule)}`); +if (JSON.stringify(explicitRule) !== JSON.stringify({ enabled: false })) + fail(`stale lock-screen fields were not dropped: ${JSON.stringify(explicitRule)}`); -for (const required of ["rememberApplication", "appRule", "setAppRule", "handleNotification", "shouldShowOnLockScreen", "shouldShowContentOnLockScreen"]) { +for (const required of ["rememberApplication", "appRule", "setAppRule", "handleNotification"]) { functionBody(required); } @@ -109,14 +112,20 @@ for (const required of [ // that the call is still in a binding, not that it is spelled one way. "Notifs.appRule(", ".enabled", - "showOnLockScreen", - "showContentOnLockScreen", "Notifs.setAppRule" ]) { if (!page.includes(required)) fail(`settings page is missing ${required}`); } +// The lock screen is hyprlock, which cannot render notifications. Two per-app +// lock-screen switches once shipped anyway, controlling nothing -- the page +// must not grow controls the session cannot honor. +for (const forbidden of ["showOnLockScreen", "showContentOnLockScreen"]) { + if (page.includes(forbidden)) + fail(`settings page offers ${forbidden}, which nothing in a hyprlock session reads`); +} + console.log("notification application rules contract: PASS"); ' @@ -184,15 +193,10 @@ exercise="$(qs_for_test ipc call notification-app-rules-test exercise)" jq -e ' .appId == "org.signal.Signal.desktop" and .initialRules == { - "org.signal.Signal.desktop": { - enabled: true, - showOnLockScreen: true, - showContentOnLockScreen: true - } + "org.signal.Signal.desktop": { enabled: true } } and .muted == { tracked: false, history: 0, popups: 0, unread: 0 } and .dnd == { tracked: true, history: 1, popups: 0, unread: 1 } and - .privacy == { visible: false, content: false } and .fallback == { id: "Fallback Terminal", application: { id: "Fallback Terminal", name: "Fallback Terminal" } @@ -201,11 +205,7 @@ jq -e ' persisted="$(qs_for_test ipc call notification-app-rules-test persist)" jq -e '. == { - "org.persist.App.desktop": { - enabled: false, - showOnLockScreen: true, - showContentOnLockScreen: false - } + "org.persist.App.desktop": { enabled: false } }' <<<"$persisted" >/dev/null || fail "runtime persistence fixture wrote the wrong shape: $persisted" settings_file="$config_home/panama/settings.json"