22 Commits
Author SHA1 Message Date
Gabriel Brown 8a04e4f9d1 Record live Home accessory verification 2026-08-17 23:33:11 -04:00
Gabriel Brown 5347954a88 Merge branch 'feat/home-accessories-customization'
# Conflicts:
#	config/dot/quickshell/config/qmldir
#	config/dot/quickshell/services/SystemSettings.qml
#	tests/quickshell/settings-pages-contract.sh
2026-08-17 23:31:33 -04:00
Gabriel Brown 00a81edadd Make Panama settings one shared source of truth
Panama had grown into three configuration surfaces that only agreed because
they had been typed to agree: looks.lua hardcoded values, DesktopPreferences
independently defaulted the same values, and SystemSettings replayed them at
startup. Nothing kept them in sync, and the Lua side read no shared state at
all. This lands the first three stages of docs/superpowers/plans/2026-08-17-panama-cohesion.md.

Fix silently failing Hyprland writes. On a Lua-configured Hyprland, hyprctl
keyword refuses the write, prints the refusal to stdout, and still exits 0, so
the HDR, VRR, and direct-scanout toggles persisted their value and reported
success while the compositor never changed. Writes now go through hyprctl eval,
which has the same hazard on syntax and runtime errors, so success is defined
as reading the value back and finding it equal. The existing contract passed
throughout the outage because it re-applied the values already in place; the
new one flips each value to something it does not hold.

Derive preferences from a schema. Every setting used to be restated four times
-- a property alias, a JSON adapter property, a change handler, and a line in
reset -- where omitting any one failed silently. PreferenceSchema.qml is now
the single source, and persistence, validation, reset, and the Hyprland mapping
all derive from it. Unknown keys on disk survive a write so a rollback does not
discard a newer build's settings, and a corrupt file falls back to shipped
defaults. The store moved to ~/.config/panama/settings.json, migrating from the
old state directory without deleting it.

Share that file with Hyprland. prefs.lua reads it at config time with every
shipped literal kept as the fallback, so the config still stands alone. The Lua
is the default, the JSON is the truth, and Settings is the editor. The
compositor-adjustable surface goes from 3 keys to 23.

Also fixes two test-hygiene bugs found by running the suite end to end for the
first time: settings-pages-contract could see the window settings-window-contract
leaves behind, and the new write contract was persisting its deliberately-wrong
values into the user's real store.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-17 23:26:56 -04:00
Gabriel Brown 4bd3638012 Cover partial Phone capabilities 2026-08-17 19:50:27 -04:00
Gabriel Brown 7f540a58e5 Avoid visual Phone harness teardown 2026-08-17 19:42:15 -04:00
Gabriel Brown 7ac5355a77 Record focused verification completion 2026-08-17 19:26:57 -04:00
Gabriel Brown a9eb90f457 Fix Home Assistant composition contract 2026-08-17 19:21:17 -04:00
Gabriel Brown b5c9156e64 Document Home accessory customization 2026-08-17 19:18:45 -04:00
Gabriel Brown 3213a0989a Fix Phone controls runtime accessibility 2026-08-17 19:01:42 -04:00
Gabriel Brown fbf729b25c Add BlueBubbles to Phone controls 2026-08-17 18:48:28 -04:00
Gabriel Brown e37df9d838 Fix Home shelf slider interactions 2026-08-17 18:34:48 -04:00
Gabriel Brown 673aac6aba Build the Home accessory shelf 2026-08-17 18:22:27 -04:00
Gabriel Brown c8eb344a0b Fix Home and Phone settings states 2026-08-17 18:02:09 -04:00
Gabriel Brown 65966200fe Add Home and Phone settings 2026-08-17 17:48:05 -04:00
Gabriel Brown 2130d6a1a5 Block Home actions during fixture transitions 2026-08-17 17:27:43 -04:00
Gabriel Brown 9a99c6064e Fix nested fixture transition draining 2026-08-17 17:16:36 -04:00
Gabriel Brown 4f93522f7d Fix fixture process transitions 2026-08-17 16:59:15 -04:00
Gabriel Brown a325ec807e Fix Home action completion and test isolation 2026-08-17 16:49:50 -04:00
Gabriel Brown c2b19f7545 Compose Home catalog with accessory preferences 2026-08-17 16:35:41 -04:00
Gabriel Brown 925fc0276f Persist Home accessory preferences 2026-08-17 16:24:09 -04:00
Gabriel Brown 65a8197419 Fix Home Assistant catalog QML compatibility 2026-08-17 16:06:05 -04:00
Gabriel Brown ff67387c42 Add Home Assistant light catalog and dimming 2026-08-17 16:01:50 -04:00
56 changed files with 5634 additions and 661 deletions
+57 -19
View File
@@ -27,6 +27,7 @@ Don't "fix" them.
| File | Contents |
|---|---|
| `hyprland.lua` | Entry point. Each `require()` is its own error scope |
| `prefs.lua` | Reads the settings file Panama Settings writes. See below |
| `env.lua` | Environment. Note the uwsm caveat below |
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision |
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout |
@@ -43,6 +44,46 @@ Validate any change without leaving your session:
Hyprland --verify-config
```
## Settings: one file, both sides
`~/.config/panama/settings.json` is shared with the Quickshell side. The
relationship is:
- **This config is the default.** Every adjustable value is written
`prefs.get("key", <shipped value>)`, so the config still works standalone with
no settings file at all.
- **The JSON is the truth.** Hyprland and Quickshell both read it.
- **Panama Settings is the editor.** It writes the file *and* applies the change
live, so nothing needs a reload and the two sides cannot drift apart.
To add an adjustable setting: add an entry to
`quickshell/config/PreferenceSchema.qml` with a `hypr` block naming the
`hl.config` path, then read it here with `prefs.get`. Nothing else is needed —
persistence, validation, reset, and the live write are all derived from that
entry.
`prefs.lua` never raises. A missing, empty, truncated, malformed, or
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.
### Never use `hyprctl keyword`
On a Lua-configured Hyprland it refuses the write, prints
`keyword can't work with non-legacy parsers` to **stdout**, and still **exits 0**:
```sh
$ hyprctl getoption decoration:rounding -j # → "int": 18
$ hyprctl keyword decoration:rounding 4 # → the refusal above
$ echo $? # → 0
$ hyprctl getoption decoration:rounding -j # → "int": 18, unchanged
```
Use `hyprctl eval 'hl.config({ ... })'` instead. Note that `eval` *also* exits 0
on syntax and runtime errors, reporting them as an `error:` line on stdout — so
for either command, the only trustworthy signal that a write landed is reading
the value back with `hyprctl getoption`.
## The look
Tokyo Night Moon, with two accents that come from the tmux theme:
@@ -221,29 +262,26 @@ Caffeine, Night Light, Focus, audio input/output, user, settings, and power in
one place. Home and Phone continue the same surface rather than opening extra
dashboard windows.
Home shows the first four configured Home Assistant favourites as direct
controls and expands to the complete configured list. Panama reads the current
GNOME Home Assistant extension configuration and its Secret Service token as a
compatibility fallback, so the existing setup works without copying a secret.
The preferred private configuration lives in the gitignored
`config/bash/env` file:
Home shows the first four selected favourites at rest and every selected light
when expanded. Use **Panama Settings → Home & Phone** to choose favourites,
set Panama-only aliases, and arrange their order. Dragging a brightness control
only previews the value; releasing it sends one brightness request. A normal
power toggle leaves Home Assistant responsible for restoring its previous
level.
```sh
export PANAMA_HOME_ASSISTANT_URL=https://home.example.test
export PANAMA_HOME_ASSISTANT_TOKEN=replace-with-a-long-lived-token
export PANAMA_HOME_ASSISTANT_ENTITIES=light.kitchen,light.living_room
```
The helper reads that file directly. No shell restart is required after editing
it; close and reopen Control Center to refresh. If Home Assistant is offline,
the last known values stay visible with a stale-state label and Retry action.
Credentials stay private in the gitignored `config/bash/env` file, with the
existing GNOME extension and Secret Service setup retained as a compatibility
fallback. Favourites, aliases, and order live in Quickshell state. No shell
restart is required after changing credentials; close and reopen Control Center
to refresh. If Home Assistant is offline, the last known values stay visible
with a stale-state label and Retry action.
Phone uses KDE Connect for the capabilities the paired iPhone actually
advertises: Send File, Send Clipboard, and Ring. The device remains visible
while iOS suspends KDE Connect, but actions stay disabled until it reconnects.
No battery percentage is invented when iOS reports none, and BlueBubbles
remains the messaging experience. Active file sends appear in Daybook's
Ongoing page; successful sends become a quiet recent exchange.
while iOS suspends KDE Connect, but those actions stay disabled until it
reconnects. Messages opens BlueBubbles independently of KDE Connect. No battery
percentage is invented when iOS reports none. Active file sends appear in
Daybook's Ongoing page; successful sends become a quiet recent exchange.
## Screen Intelligence
+5
View File
@@ -14,6 +14,11 @@
-- Check: Hyprland --verify-config
-- ─────────────────────────────────────────────────────────────────────────────
-- Shared preferences first: looks/input/monitors read their defaults through it.
-- It never raises, so a missing or malformed settings file costs customisations
-- and nothing else.
require("prefs")
require("env")
require("monitors")
require("looks")
+8 -6
View File
@@ -5,28 +5,30 @@
-- acceleration changes. The goal is that muscle memory transfers untouched.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
hl.config({
input = {
kb_layout = "us",
kb_layout = prefs.get("keyboardLayout", "us"),
kb_variant = "",
kb_model = "",
kb_options = "",
kb_rules = "",
numlock_by_default = true,
numlock_by_default = prefs.get("numlockByDefault", true),
-- GNOME's defaults are 500ms delay / 33Hz repeat.
repeat_delay = 500,
repeat_rate = 33,
repeat_delay = prefs.get("keyRepeatDelay", 500),
repeat_rate = prefs.get("keyRepeatRate", 33),
-- 1 = click to focus. GNOME's behaviour; NOT sloppy focus.
follow_mouse = 1,
follow_mouse = prefs.getInt("followMouse", 1),
-- Don't refocus on mouse move alone -- only on click.
mouse_refocus = false,
-- Flat pointer response, no acceleration. Matters for gaming.
sensitivity = 0,
sensitivity = prefs.get("pointerSensitivity", 0),
accel_profile = "flat",
-- Clicking a floating window raises and focuses it.
+19 -17
View File
@@ -12,12 +12,14 @@
-- display.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
hl.config({
general = {
gaps_in = 5,
gaps_out = 10,
gaps_in = prefs.get("gapsIn", 5),
gaps_out = prefs.get("gapsOut", 10),
border_size = 2,
border_size = prefs.get("borderSize", 2),
col = {
-- The prism: blue leads, orchid follows, on a diagonal so the pair
@@ -44,16 +46,16 @@ hl.config({
decoration = {
-- 18 to match the shell's popover radius, so a window and a panel sitting
-- next to each other read as the same object family.
rounding = 18,
rounding = prefs.get("windowRounding", 18),
rounding_power = 2,
active_opacity = 1.0,
inactive_opacity = 1.0,
inactive_opacity = prefs.get("inactiveOpacity", 1.0),
blur = {
enabled = true,
size = 8,
passes = 3,
enabled = prefs.get("blurEnabled", true),
size = prefs.get("blurSize", 8),
passes = prefs.get("blurPasses", 3),
-- Required for blur to be affordable. Never turn this off.
new_optimizations = true,
@@ -75,8 +77,8 @@ hl.config({
},
shadow = {
enabled = true,
range = 20,
enabled = prefs.get("shadowEnabled", true),
range = prefs.get("shadowRange", 20),
render_power = 3,
sharp = false,
color = "rgba(15161eee)",
@@ -88,8 +90,8 @@ hl.config({
-- border is the signature, and a strong halo would compete with it.
-- This is just enough to lift the focused window off the wallpaper.
glow = {
enabled = true,
range = 8,
enabled = prefs.get("glowEnabled", true),
range = prefs.get("glowRange", 8),
render_power = 2,
color = "rgba(82aaff33)",
color_inactive = "rgba(00000000)",
@@ -99,7 +101,7 @@ hl.config({
motion_blur = { enabled = false },
},
animations = { enabled = true },
animations = { enabled = prefs.get("animationsEnabled", true) },
dwindle = {
-- Keep the split orientation a window was created with. Closest match
@@ -118,7 +120,7 @@ hl.config({
-- Variable refresh rate. 3 = enable only for fullscreen windows whose
-- content type is "video" or "game" -- the tag rules.lua applies to
-- games. Keeps VRR off the desktop, where it causes visible flicker.
vrr = 3,
vrr = prefs.getInt("vrrPolicy", 3),
-- Blur behind the lock screen.
session_lock_blur = true,
@@ -137,12 +139,12 @@ hl.config({
-- 1 = automatically flip the monitor into HDR for fullscreen content
-- that asks for it, and back out afterwards. This is how games get HDR
-- without the desktop paying the screencopy cost. See monitors.lua.
cm_auto_hdr = 1,
cm_auto_hdr = prefs.getInt("autoHdr", 1),
-- 2 = direct scanout only for windows tagged content = "game"
-- (set by the rules in rules.lua). Bypasses compositing for real
-- fullscreen games.
direct_scanout = 2,
direct_scanout = prefs.getInt("directScanoutPolicy", 2),
},
cursor = {
@@ -158,7 +160,7 @@ hl.config({
sync_gsettings_theme = true,
-- Fade the cursor out after 4s of no movement, like GNOME does.
inactive_timeout = 4,
inactive_timeout = prefs.get("cursorInactiveTimeout", 4),
},
ecosystem = {
+252
View File
@@ -0,0 +1,252 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Shared preferences
--
-- Reads the same file Panama Settings writes:
-- $XDG_CONFIG_HOME/panama/settings.json (default ~/.config/panama/settings.json)
--
-- This is what makes the desktop one product rather than two that happen to
-- agree. The relationship is:
--
-- * the Lua config is the DEFAULT -- every prefs.get() call passes the shipped
-- value as its fallback, so this config still works standalone with no JSON
-- file at all;
-- * the JSON file is the TRUTH -- both Hyprland and Quickshell read it;
-- * Panama Settings is the EDITOR -- it writes the file and applies the change
-- live through `hyprctl eval`, so nothing needs a reload and the two sides
-- cannot drift apart.
--
-- Nothing here may raise. A missing, empty, truncated, or actively malformed
-- file must cost the user nothing worse than their customisations; it must
-- never cost them a working compositor. Every failure path returns the caller's
-- fallback.
-- ─────────────────────────────────────────────────────────────────────────────
local prefs = {}
-- ── A small JSON reader ─────────────────────────────────────────────────────
-- Hyprland's Lua has no JSON support and pulling in a rock for a flat object of
-- scalars is not worth the dependency. Handles the whole format apart from
-- non-ASCII \u escapes, which are replaced rather than decoded -- no setting is
-- a non-ASCII string, and mangling one is preferable to failing the parse.
local function decode(text)
local pos = 1
local function skipSpace()
pos = text:find("[^ \t\r\n]", pos) or #text + 1
end
local parseValue
local function parseString()
pos = pos + 1 -- opening quote
local parts = {}
while true do
local char = text:sub(pos, pos)
if char == "" then
error("unterminated string")
elseif char == '"' then
pos = pos + 1
break
elseif char == "\\" then
local escape = text:sub(pos + 1, pos + 1)
local simple = {
n = "\n", t = "\t", r = "\r", b = "\b", f = "\f",
['"'] = '"', ["\\"] = "\\", ["/"] = "/",
}
if simple[escape] then
parts[#parts + 1] = simple[escape]
pos = pos + 2
elseif escape == "u" then
local code = tonumber(text:sub(pos + 2, pos + 5), 16)
parts[#parts + 1] = (code and code < 128) and string.char(code) or "?"
pos = pos + 6
else
error("invalid escape")
end
else
parts[#parts + 1] = char
pos = pos + 1
end
end
return table.concat(parts)
end
local function parseNumber()
local literal = text:match("^-?%d+%.?%d*[eE]?[-+]?%d*", pos)
if not literal or literal == "" then
error("invalid number")
end
pos = pos + #literal
local value = tonumber(literal)
if not value then
error("invalid number")
end
return value
end
local function parseObject()
pos = pos + 1 -- opening brace
local out = {}
skipSpace()
if text:sub(pos, pos) == "}" then
pos = pos + 1
return out
end
while true do
skipSpace()
if text:sub(pos, pos) ~= '"' then
error("expected key")
end
local key = parseString()
skipSpace()
if text:sub(pos, pos) ~= ":" then
error("expected colon")
end
pos = pos + 1
out[key] = parseValue()
skipSpace()
local char = text:sub(pos, pos)
pos = pos + 1
if char == "}" then
return out
elseif char ~= "," then
error("expected comma or closing brace")
end
end
end
local function parseArray()
pos = pos + 1 -- opening bracket
local out = {}
skipSpace()
if text:sub(pos, pos) == "]" then
pos = pos + 1
return out
end
while true do
out[#out + 1] = parseValue()
skipSpace()
local char = text:sub(pos, pos)
pos = pos + 1
if char == "]" then
return out
elseif char ~= "," then
error("expected comma or closing bracket")
end
end
end
parseValue = function()
skipSpace()
local char = text:sub(pos, pos)
if char == "{" then
return parseObject()
elseif char == "[" then
return parseArray()
elseif char == '"' then
return parseString()
elseif text:sub(pos, pos + 3) == "true" then
pos = pos + 4
return true
elseif text:sub(pos, pos + 4) == "false" then
pos = pos + 5
return false
elseif text:sub(pos, pos + 3) == "null" then
pos = pos + 4
return nil
elseif char == "" then
error("unexpected end of input")
else
return parseNumber()
end
end
local value = parseValue()
if type(value) ~= "table" then
error("top level value is not an object")
end
return value
end
-- ── Loading ─────────────────────────────────────────────────────────────────
local function settingsPath()
local configHome = os.getenv("XDG_CONFIG_HOME")
if configHome == nil or configHome == "" then
local home = os.getenv("HOME")
if home == nil or home == "" then
return nil
end
configHome = home .. "/.config"
end
return configHome .. "/panama/settings.json"
end
local function read()
local path = settingsPath()
if not path then
return {}
end
local file = io.open(path, "r")
if not file then
return {} -- no file yet is the normal first-run case, not an error
end
local text = file:read("*a")
file:close()
if not text or text:match("^%s*$") then
return {}
end
local ok, parsed = pcall(decode, text)
if ok and type(parsed) == "table" then
return parsed
end
return {}
end
-- Loaded once at config time. A pcall around the whole thing so that even an
-- unanticipated failure in the reader degrades to shipped defaults.
local values = {}
do
local ok, parsed = pcall(read)
if ok and type(parsed) == "table" then
values = parsed
end
end
-- ── Public interface ────────────────────────────────────────────────────────
-- Returns the stored value for `key`, or `fallback` when it is absent or is not
-- the same type as the fallback. The type guard matters: a stale or hand-edited
-- file that puts a string where Hyprland needs a number would otherwise abort
-- the config, taking down far more than the one setting that was wrong.
function prefs.get(key, fallback)
local value = values[key]
if value == nil then
return fallback
end
if type(value) ~= type(fallback) then
return fallback
end
return value
end
-- Hyprland has no boolean-to-integer coercion for options that take 0/1, and
-- several of them read more naturally as a toggle in the settings UI.
function prefs.getInt(key, fallback)
local value = values[key]
if type(value) == "boolean" then
return value and 1 or 0
end
if type(value) ~= "number" then
return fallback
end
return math.floor(value + 0.5)
end
-- True when a settings file was actually read. Useful from overrides.lua.
function prefs.loaded()
return next(values) ~= nil
end
return prefs
@@ -1,8 +1,23 @@
pragma Singleton
// User choices that Panama Settings may change at runtime. This is deliberately
// separate from Settings.qml: the latter remains the stable public surface used
// by the shell, while this object owns durable mutation and shipped defaults.
// ─────────────────────────────────────────────────────────────────────────────
// Durable user choices, derived entirely from config/PreferenceSchema.qml.
//
// This object owns reading, validating, and writing. It knows nothing about
// which settings exist -- that is the schema's job -- so adding a setting never
// requires touching this file. That is the point: the previous implementation
// restated every key four times (a property alias, a JSON adapter property, a
// change handler, and a line in reset), and each omission failed silently.
//
// Reads go through get(), writes through set(). Typed accessors for the values
// the shell reads on every frame live in Settings.qml, which remains the stable
// public surface.
//
// The store lives at ~/.config/panama/settings.json rather than inside
// Quickshell's per-shell state directory, because the Hyprland Lua config reads
// the same file (see config/dot/hypr/prefs.lua) and because a user should be
// able to back it up, diff it, or keep it in a dotfiles repo.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
@@ -11,110 +26,125 @@ import QtQuick
Singleton {
id: root
property alias use24Hour: values.use24Hour
property alias showSeconds: values.showSeconds
property alias showWeekday: values.showWeekday
property alias showCpu: values.showCpu
property alias showMemory: values.showMemory
property alias showGpu: values.showGpu
property alias dockAutohide: values.dockAutohide
property alias dockRevealDelayMs: values.dockRevealDelayMs
property alias dockHideDelayMs: values.dockHideDelayMs
property alias focusDurationMinutes: values.focusDurationMinutes
property alias autoHdr: values.autoHdr
property alias vrrPolicy: values.vrrPolicy
property alias directScanoutPolicy: values.directScanoutPolicy
property alias nightLightEnabled: values.nightLightEnabled
property alias nightLightAutomatic: values.nightLightAutomatic
property alias nightLightTemperature: values.nightLightTemperature
property alias lastPage: values.lastPage
readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || `${Quickshell.env("HOME")}/.config`) + "/panama/settings.json"
// Bumped on every accepted change. get() reads it so bindings built on get()
// have something to invalidate; a bare function call would otherwise capture
// no dependency and every reader would silently go stale.
property int revision: 0
// Everything currently on disk, including keys this build does not know
// about. Unknown keys are carried through untouched so that rolling back to
// an older Panama does not discard a newer version's settings.
property var values: ({})
property bool loaded: false
function get(key: string): var {
root.revision;
const stored = root.values[key];
if (stored === undefined)
return PreferenceSchema.defaultFor(key);
const coerced = PreferenceSchema.coerce(key, stored);
return coerced === undefined ? PreferenceSchema.defaultFor(key) : coerced;
}
// Returns false when the key is unknown or the value cannot be represented,
// so a caller can surface the rejection instead of assuming it took.
function set(key: string, value: var): bool {
if (!PreferenceSchema.has(key))
return false;
const coerced = PreferenceSchema.coerce(key, value);
if (coerced === undefined)
return false;
if (root.values[key] === coerced)
return true;
// Reassign rather than mutate: QML does not notify on in-place changes
// to a var property's contents.
const next = Object.assign({}, root.values);
next[key] = coerced;
root.values = next;
root.revision++;
persistTimer.restart();
return true;
}
// Restores every schema default in one write. Complete by construction --
// there is no hand-maintained list to fall out of sync with the schema.
function resetDesktopDefaults(): void {
const next = Object.assign({}, root.values, PreferenceSchema.defaults());
root.values = next;
root.revision++;
persistTimer.restart();
}
function load(): void {
let parsed = {};
try {
const text = preferencesFile.text();
if (text && text.trim().length > 0)
parsed = JSON.parse(text);
} catch (error) {
// A corrupt file must not cost the user a working desktop. Fall
// back to shipped defaults and let the next write replace it.
parsed = {};
}
root.values = (parsed && typeof parsed === "object") ? parsed : {};
root.revision++;
root.loaded = true;
}
FileView {
id: preferencesFile
path: Quickshell.stateDir + "/panama-settings.json"
path: root.path
blockLoading: true
printErrors: false
atomicWrites: true
JsonAdapter {
id: values
property bool use24Hour: false
property bool showSeconds: true
property bool showWeekday: true
property bool showCpu: true
property bool showMemory: true
property bool showGpu: true
property bool dockAutohide: true
property int dockRevealDelayMs: 0
property int dockHideDelayMs: 250
property int focusDurationMinutes: 45
property bool autoHdr: true
property int vrrPolicy: 3
property int directScanoutPolicy: 2
property bool nightLightEnabled: false
property bool nightLightAutomatic: false
property int nightLightTemperature: 3500
property string lastPage: "home"
}
onLoaded: root.load()
// No file yet is the normal first-run case, not an error.
onLoadFailed: root.load()
}
// Listen after the adapter has been constructed instead of writing from
// FileView.onAdapterUpdated. The latter also fires for default-property
// initialization, which can overwrite a valid file before it is loaded.
Connections {
target: values
function onUse24HourChanged(): void { persistTimer.restart(); }
function onShowSecondsChanged(): void { persistTimer.restart(); }
function onShowWeekdayChanged(): void { persistTimer.restart(); }
function onShowCpuChanged(): void { persistTimer.restart(); }
function onShowMemoryChanged(): void { persistTimer.restart(); }
function onShowGpuChanged(): void { persistTimer.restart(); }
function onDockAutohideChanged(): void { persistTimer.restart(); }
function onDockRevealDelayMsChanged(): void { persistTimer.restart(); }
function onDockHideDelayMsChanged(): void { persistTimer.restart(); }
function onFocusDurationMinutesChanged(): void { persistTimer.restart(); }
function onAutoHdrChanged(): void { persistTimer.restart(); }
function onVrrPolicyChanged(): void { persistTimer.restart(); }
function onDirectScanoutPolicyChanged(): void { persistTimer.restart(); }
function onNightLightEnabledChanged(): void { persistTimer.restart(); }
function onNightLightAutomaticChanged(): void { persistTimer.restart(); }
function onNightLightTemperatureChanged(): void { persistTimer.restart(); }
function onLastPageChanged(): void { persistTimer.restart(); }
Component.onCompleted: {
migration.adopt();
root.load();
}
// Singleton construction can happen after FileView's preload phase when a
// test or a lazy page first references it. An explicit reload makes the
// durable state authoritative in that case as well as in the main shell.
Component.onCompleted: preferencesFile.reload()
// Coalesce a group of UI changes into one atomic write. Writing from the
// adapter's change signal itself can race a second property assignment and
// reload the older value before the batch has finished.
// Coalesce a burst of changes into one atomic write. Writing on every
// assignment races a second assignment and can reload the older value.
Timer {
id: persistTimer
interval: 0
onTriggered: preferencesFile.writeAdapter()
onTriggered: preferencesFile.setText(JSON.stringify(root.values, null, 2) + "\n")
}
function resetDesktopDefaults(): void {
values.use24Hour = false;
values.showSeconds = true;
values.showWeekday = true;
values.showCpu = true;
values.showMemory = true;
values.showGpu = true;
values.dockAutohide = true;
values.dockRevealDelayMs = 0;
values.dockHideDelayMs = 250;
values.focusDurationMinutes = 45;
values.autoHdr = true;
values.vrrPolicy = 3;
values.directScanoutPolicy = 2;
values.nightLightEnabled = false;
values.nightLightAutomatic = false;
values.nightLightTemperature = 3500;
values.lastPage = "home";
// One-time move from the pre-Stage-1 location inside Quickshell's state
// directory. Reads the old file only when the new one does not exist yet, so
// it can never overwrite newer settings, and never deletes the original.
QtObject {
id: migration
function adopt(): void {
if (preferencesFile.text())
return;
try {
const legacy = legacyFile.text();
if (legacy && legacy.trim().length > 0)
preferencesFile.setText(legacy);
} catch (error) {
// Nothing to migrate.
}
}
}
FileView {
id: legacyFile
path: Quickshell.stateDir + "/panama-settings.json"
blockLoading: true
printErrors: false
}
}
@@ -0,0 +1,168 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property alias initialized: values.initialized
property alias favorites: values.favorites
property string saveError: ""
FileView {
id: preferencesFile
path: Quickshell.stateDir + "/panama-home.json"
blockLoading: true
printErrors: false
atomicWrites: true
onSaved: root.saveError = ""
onSaveFailed: error => root.saveError = "Could not save Home favourites."
JsonAdapter {
id: values
property bool initialized: false
property var favorites: []
}
}
Connections {
target: values
function onInitializedChanged(): void { persistTimer.restart(); }
function onFavoritesChanged(): void { persistTimer.restart(); }
}
Component.onCompleted: preferencesFile.reload()
Timer {
id: persistTimer
interval: 180
repeat: false
onTriggered: preferencesFile.writeAdapter()
}
function cloneFavorites(): var {
var clone = [];
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
clone.push({
id: favorite.id,
alias: favorite.alias
});
}
return clone;
}
function isValidEntityId(entityId: var): bool {
return typeof entityId === "string" && /^light\.[a-z0-9_]+$/.test(entityId);
}
function initialize(legacyIds: var): void {
if (values.initialized) {
return;
}
var seededFavorites = [];
var seen = {};
if (legacyIds && typeof legacyIds.length === "number") {
for (var index = 0; index < legacyIds.length; index++) {
var entityId = legacyIds[index];
if (!isValidEntityId(entityId) || seen[entityId]) {
continue;
}
seen[entityId] = true;
seededFavorites.push({ id: entityId, alias: "" });
}
}
values.favorites = seededFavorites;
values.initialized = true;
}
function isSelected(entityId: string): bool {
for (var index = 0; index < values.favorites.length; index++) {
if (values.favorites[index].id === entityId) {
return true;
}
}
return false;
}
function aliasFor(entityId: string, sourceName: string): string {
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
if (favorite.id === entityId && favorite.alias !== "") {
return favorite.alias;
}
}
return sourceName;
}
function add(entityId: string): void {
if (!isValidEntityId(entityId) || isSelected(entityId)) {
return;
}
var nextFavorites = cloneFavorites();
nextFavorites.push({ id: entityId, alias: "" });
values.favorites = nextFavorites;
}
function remove(entityId: string): void {
var nextFavorites = [];
var removed = false;
for (var index = 0; index < values.favorites.length; index++) {
var favorite = values.favorites[index];
if (favorite.id === entityId) {
removed = true;
continue;
}
nextFavorites.push({ id: favorite.id, alias: favorite.alias });
}
if (removed) {
values.favorites = nextFavorites;
}
}
function setAlias(entityId: string, alias: string): void {
var nextFavorites = cloneFavorites();
var updated = false;
var trimmedAlias = String(alias).trim();
for (var index = 0; index < nextFavorites.length; index++) {
if (nextFavorites[index].id === entityId) {
nextFavorites[index] = { id: entityId, alias: trimmedAlias };
updated = true;
break;
}
}
if (updated) {
values.favorites = nextFavorites;
}
}
function move(entityId: string, targetIndex: int): void {
var nextFavorites = cloneFavorites();
var currentIndex = -1;
for (var index = 0; index < nextFavorites.length; index++) {
if (nextFavorites[index].id === entityId) {
currentIndex = index;
break;
}
}
if (currentIndex === -1) {
return;
}
var favorite = nextFavorites.splice(currentIndex, 1)[0];
var clampedIndex = Math.max(0, Math.min(targetIndex, nextFavorites.length));
nextFavorites.splice(clampedIndex, 0, favorite);
values.favorites = nextFavorites;
}
function retrySave(): void {
preferencesFile.writeAdapter();
}
}
@@ -0,0 +1,398 @@
pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// The schema is the single source of truth for every user-changeable setting.
//
// One entry describes a setting completely: its name, type, default, valid
// range, which group it belongs to, and how to label it. Persistence,
// validation, reset, and (from Stage 3) the settings UI itself are all derived
// from these entries rather than restated.
//
// Adding a setting means adding one entry here. It does not mean editing a
// property alias, a JSON adapter, a change handler, and a reset function --
// which is what it used to mean, and why the settings app stalled at sixteen
// knobs while forty comparable values stayed hardcoded one file away.
//
// Entry fields
// key unique identifier; also the JSON key on disk
// type "bool" | "int" | "real" | "string" | "enum"
// def shipped default, used when the file is absent or a value is invalid
// min/max inclusive bounds for int and real; values outside are clamped
// step UI increment for int and real
// options for "enum": [{ value, label }], the only accepted values
// group grouping id, used to build settings pages
// label short UI name
// detail one line explaining what changing it does
// internal true for state the shell keeps but the user never edits directly
// pattern for "string": a regular expression the value must match in full
// hypr present when the setting maps onto an Hyprland option:
// path the hl.config table path, e.g. ["decoration","blur","size"]
// option the getoption path used to read the value back
// readAs which field getoption returns it in -- "int", "bool",
// "float", "str", or "css" (gaps, returned as a box)
//
// Anything with a `hypr` block is applied live by services/SystemSettings.qml
// and read at startup by config/dot/hypr/prefs.lua, using the same key. The Lua
// keeps the shipped value as its fallback, so the config still works with no
// settings file at all.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import QtQuick
Singleton {
id: root
readonly property var entries: [
// ── Clock ───────────────────────────────────────────────────────────
{
key: "use24Hour", type: "bool", def: false, group: "clock",
label: "24-hour time",
detail: "Use 18:30 instead of 6:30 PM"
},
{
key: "showSeconds", type: "bool", def: true, group: "clock",
label: "Show seconds",
detail: "Keep a precise clock in the center of the bar"
},
{
key: "showWeekday", type: "bool", def: true, group: "clock",
label: "Show weekday",
detail: "Include the abbreviated weekday before the date"
},
// ── Vitals ──────────────────────────────────────────────────────────
{
key: "showCpu", type: "bool", def: true, group: "vitals",
label: "Processor",
detail: "Show processor usage beside the workspace indicator"
},
{
key: "showMemory", type: "bool", def: true, group: "vitals",
label: "Memory",
detail: "Show memory usage beside the workspace indicator"
},
{
key: "showGpu", type: "bool", def: true, group: "vitals",
label: "Graphics",
detail: "Show graphics usage beside the workspace indicator"
},
// ── Dock ────────────────────────────────────────────────────────────
{
key: "dockAutohide", type: "bool", def: true, group: "dock",
label: "Automatically hide the Dock",
detail: "Reveal it at the bottom edge when a workspace is occupied"
},
{
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
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,
group: "dock",
label: "Hide delay",
detail: "Prevents flicker when crossing between icons"
},
// ── Focus ───────────────────────────────────────────────────────────
{
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
group: "focus",
label: "Focus session length",
detail: "How long a focus session runs before it ends itself"
},
// ── Display policy ──────────────────────────────────────────────────
// These three are written to the compositor and verified by read-back.
// See services/SystemSettings.qml for why the exit code cannot be
// trusted for either hyprctl keyword or hyprctl eval.
{
key: "autoHdr", type: "bool", def: true, group: "display",
label: "Game-aware HDR",
detail: "Hand HDR to fullscreen games while the desktop stays SDR",
hypr: { path: ["render", "cm_auto_hdr"], option: "render:cm_auto_hdr", readAs: "int" }
},
{
key: "vrrPolicy", type: "enum", def: 3, group: "display",
label: "Variable refresh rate",
detail: "Content-aware matches the display to what is on screen",
options: [
{ value: 0, label: "Off" },
{ value: 3, label: "Content-aware" }
],
hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" }
},
{
key: "directScanoutPolicy", type: "enum", def: 2, group: "display",
label: "Direct scanout",
detail: "Lets fullscreen content bypass compositing",
options: [
{ value: 0, label: "Off" },
{ value: 2, label: "Automatic" }
],
hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" }
},
// ── Window appearance ───────────────────────────────────────────────
// These adjust the parameters of the Prism identity -- how much space,
// how soft, how much motion -- rather than replacing it. Shipped values
// are duplicated as the fallbacks in config/dot/hypr/looks.lua so that
// the Hyprland config still stands on its own.
{
key: "gapsIn", type: "int", def: 5, min: 0, max: 40, step: 1,
group: "windows",
label: "Inner gaps",
detail: "Space between neighbouring tiled windows",
hypr: { path: ["general", "gaps_in"], option: "general:gaps_in", readAs: "css" }
},
{
key: "gapsOut", type: "int", def: 10, min: 0, max: 80, step: 1,
group: "windows",
label: "Outer gaps",
detail: "Space between the tiled area and the screen edge",
hypr: { path: ["general", "gaps_out"], option: "general:gaps_out", readAs: "css" }
},
{
key: "borderSize", type: "int", def: 2, min: 0, max: 10, step: 1,
group: "windows",
label: "Border width",
detail: "Thickness of the gradient border on the focused window",
hypr: { path: ["general", "border_size"], option: "general:border_size", readAs: "int" }
},
{
key: "windowRounding", type: "int", def: 18, min: 0, max: 40, step: 1,
group: "windows",
label: "Corner radius",
detail: "Matches the shell's popover radius so windows and panels agree",
hypr: { path: ["decoration", "rounding"], option: "decoration:rounding", readAs: "int" }
},
{
key: "inactiveOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
group: "windows",
label: "Unfocused window opacity",
detail: "Fade windows that do not have focus",
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
},
// ── Effects ─────────────────────────────────────────────────────────
{
key: "blurEnabled", type: "bool", def: true, group: "effects",
label: "Blur",
detail: "Blur the desktop behind translucent surfaces",
hypr: { path: ["decoration", "blur", "enabled"], option: "decoration:blur:enabled", readAs: "bool" }
},
{
key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1,
group: "effects",
label: "Blur radius",
detail: "Larger is softer and costs more frame time",
hypr: { path: ["decoration", "blur", "size"], option: "decoration:blur:size", readAs: "int" }
},
{
key: "blurPasses", type: "int", def: 3, min: 1, max: 5, step: 1,
group: "effects",
label: "Blur passes",
detail: "More passes look smoother and cost more frame time",
hypr: { path: ["decoration", "blur", "passes"], option: "decoration:blur:passes", readAs: "int" }
},
{
key: "shadowEnabled", type: "bool", def: true, group: "effects",
label: "Window shadows",
detail: "Lift windows off the wallpaper with a soft shadow",
hypr: { path: ["decoration", "shadow", "enabled"], option: "decoration:shadow:enabled", readAs: "bool" }
},
{
key: "shadowRange", type: "int", def: 20, min: 0, max: 60, step: 1,
group: "effects",
label: "Shadow size",
detail: "How far the shadow spreads from the window edge",
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
},
{
key: "glowEnabled", type: "bool", def: true, group: "effects",
label: "Focus glow",
detail: "A faint halo behind the focused window",
hypr: { path: ["decoration", "glow", "enabled"], option: "decoration:glow:enabled", readAs: "bool" }
},
{
key: "glowRange", type: "int", def: 8, min: 0, max: 30, step: 1,
group: "effects",
label: "Glow size",
detail: "Kept small deliberately: the gradient border is the signature",
hypr: { path: ["decoration", "glow", "range"], option: "decoration:glow:range", readAs: "int" }
},
{
key: "animationsEnabled", type: "bool", def: true, group: "effects",
label: "Animations",
detail: "Window, workspace, and panel motion",
hypr: { path: ["animations", "enabled"], option: "animations:enabled", readAs: "bool" }
},
// ── Input ───────────────────────────────────────────────────────────
{
key: "keyboardLayout", type: "string", def: "us", group: "input",
// Reaches an hl.config string, so it is constrained to the shape of
// an XKB layout list and nothing else.
pattern: "^[a-z]{2,8}(,[a-z]{2,8})*$",
label: "Keyboard layout",
detail: "XKB layout name, or a comma-separated list to switch between",
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
},
{
key: "numlockByDefault", type: "bool", def: true, group: "input",
label: "Num Lock on login",
detail: "Turn Num Lock on when the session starts",
hypr: { path: ["input", "numlock_by_default"], option: "input:numlock_by_default", readAs: "bool" }
},
{
key: "keyRepeatDelay", type: "int", def: 500, min: 150, max: 1000, step: 25,
group: "input",
label: "Repeat delay",
detail: "How long a key is held before it starts repeating",
hypr: { path: ["input", "repeat_delay"], option: "input:repeat_delay", readAs: "int" }
},
{
key: "keyRepeatRate", type: "int", def: 33, min: 5, max: 100, step: 1,
group: "input",
label: "Repeat rate",
detail: "How many characters a second a held key produces",
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
},
{
key: "followMouse", type: "enum", def: 1, group: "input",
label: "Focus follows pointer",
detail: "Click to focus matches GNOME; sloppy focus follows the pointer",
options: [
{ value: 0, label: "Never" },
{ value: 1, label: "Click to focus" },
{ value: 2, label: "Sloppy focus" }
],
hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" }
},
{
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
group: "input",
label: "Pointer speed",
detail: "Zero is flat, unaccelerated response",
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
},
{
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
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" }
},
// ── Night light ─────────────────────────────────────────────────────
{
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
label: "Night Light",
detail: "Shift the display warmer to reduce blue light"
},
{
key: "nightLightAutomatic", type: "bool", def: false, group: "nightLight",
label: "Schedule automatically",
detail: "Turn Night Light on and off at the scheduled hours"
},
{
key: "nightLightTemperature", type: "int", def: 3500, min: 2000, max: 6500, step: 100,
group: "nightLight",
label: "Color temperature",
detail: "Lower is warmer"
},
// ── Internal ────────────────────────────────────────────────────────
{
key: "lastPage", type: "string", def: "home", group: "internal",
internal: true,
label: "Last settings page",
detail: "Restores the page Settings was left on"
}
]
// key -> entry, built once. Every lookup below goes through this rather than
// scanning `entries`, since get/set are called from bindings.
readonly property var byKey: {
const index = {};
for (const entry of root.entries)
index[entry.key] = entry;
return index;
}
readonly property var userKeys: root.entries.filter(entry => !entry.internal).map(entry => entry.key)
function spec(key: string): var {
return root.byKey[key] ?? null;
}
function has(key: string): bool {
return root.byKey[key] !== undefined;
}
function defaultFor(key: string): var {
const entry = root.byKey[key];
return entry ? entry.def : undefined;
}
function defaults(): var {
const out = {};
for (const entry of root.entries)
out[entry.key] = entry.def;
return out;
}
function inGroup(group: string): var {
return root.entries.filter(entry => entry.group === group && !entry.internal);
}
// Entries the compositor owns, used to build one hl.config{} payload.
function hyprEntries(): var {
return root.entries.filter(entry => entry.hypr !== undefined);
}
// Returns the value coerced into the entry's type and range, or `undefined`
// if it cannot be represented at all. Out-of-range numbers are clamped
// rather than rejected: a stale file with a since-narrowed bound should
// still yield a usable desktop.
function coerce(key: string, value: var): var {
const entry = root.byKey[key];
if (!entry || value === undefined || value === null)
return undefined;
switch (entry.type) {
case "bool":
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
return undefined;
case "int":
case "real": {
const numeric = Number(value);
if (!isFinite(numeric)) return undefined;
const rounded = entry.type === "int" ? Math.round(numeric) : numeric;
const lower = entry.min !== undefined ? Math.max(rounded, entry.min) : rounded;
return entry.max !== undefined ? Math.min(lower, entry.max) : lower;
}
case "enum":
return entry.options.some(option => option.value === value) ? value : undefined;
case "string": {
const text = typeof value === "string" ? value : String(value);
// A constrained string is rejected rather than sanitised. Several
// of these are serialised into an hl.config payload, and quietly
// stripping characters would turn a typo into a different setting
// instead of an error the user can see.
if (entry.pattern && !new RegExp(entry.pattern).test(text))
return undefined;
return text;
}
}
return undefined;
}
}
+12 -12
View File
@@ -15,9 +15,9 @@ Singleton {
// ── Clock ───────────────────────────────────────────────────────────────
// Carried over from GNOME: 12-hour, weekday + date shown, seconds on.
readonly property bool use24Hour: DesktopPreferences.use24Hour
readonly property bool showSeconds: DesktopPreferences.showSeconds
readonly property bool showWeekday: DesktopPreferences.showWeekday
readonly property bool use24Hour: DesktopPreferences.get("use24Hour")
readonly property bool showSeconds: DesktopPreferences.get("showSeconds")
readonly property bool showWeekday: DesktopPreferences.get("showWeekday")
// ── Weather ─────────────────────────────────────────────────────────────
// Coordinates taken from the GNOME night-light setting, which had already
@@ -35,9 +35,9 @@ Singleton {
// The GNOME Vitals extension showed processor usage, memory usage and GPU
// usage, in that order. Same here.
readonly property int vitalsIntervalMs: 2000
readonly property bool showCpu: DesktopPreferences.showCpu
readonly property bool showMemory: DesktopPreferences.showMemory
readonly property bool showGpu: DesktopPreferences.showGpu
readonly property bool showCpu: DesktopPreferences.get("showCpu")
readonly property bool showMemory: DesktopPreferences.get("showMemory")
readonly property bool showGpu: DesktopPreferences.get("showGpu")
// amdgpu exposes utilisation here. Verified present on this machine; the
// widget hides itself if the path is missing rather than showing zeros.
@@ -45,10 +45,10 @@ Singleton {
// ── Night light ─────────────────────────────────────────────────────────
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
readonly property int nightLightTemperature: DesktopPreferences.nightLightTemperature
readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature")
readonly property real nightLightFrom: 17.0
readonly property real nightLightTo: 10.0
readonly property bool nightLightEnabledByDefault: DesktopPreferences.nightLightEnabled
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
// ── Notifications ───────────────────────────────────────────────────────
readonly property int notificationTimeoutMs: 5000
@@ -59,7 +59,7 @@ Singleton {
// ── Focus ──────────────────────────────────────────────────────────────
// One deliberate default rather than a preset picker: quick settings and
// the keyboard shortcut should start a useful session in one action.
readonly property int focusDurationMinutes: DesktopPreferences.focusDurationMinutes
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
// ── Dock ────────────────────────────────────────────────────────────────
// Pinned apps, in order, taken from the GNOME dash favourites.
@@ -84,16 +84,16 @@ Singleton {
// Dash-to-Dock was set to intellihide against all windows: the dock hides
// when any window would overlap it, and comes back on hover.
readonly property bool dockAutohide: DesktopPreferences.dockAutohide
readonly property bool dockAutohide: DesktopPreferences.get("dockAutohide")
// 0: reveal the instant the pointer reaches the bottom edge. A reveal delay
// is indistinguishable from lag, because the user has already committed to
// the gesture by the time the strip is hit.
readonly property int dockRevealDelayMs: DesktopPreferences.dockRevealDelayMs
readonly property int dockRevealDelayMs: DesktopPreferences.get("dockRevealDelayMs")
// Hiding keeps a delay, so brushing past the bottom edge or crossing the
// gap between two icons doesn't make the dock flicker.
readonly property int dockHideDelayMs: DesktopPreferences.dockHideDelayMs
readonly property int dockHideDelayMs: DesktopPreferences.get("dockHideDelayMs")
// ── Capture ─────────────────────────────────────────────────────────────
readonly property string screenshotDir: "Pictures/Screenshots"
+2
View File
@@ -1,4 +1,6 @@
module qs.config
singleton DesktopPreferences 1.0 DesktopPreferences.qml
singleton PreferenceSchema 1.0 PreferenceSchema.qml
singleton HomePreferences 1.0 HomePreferences.qml
singleton Settings 1.0 Settings.qml
singleton Theme 1.0 Theme.qml
@@ -0,0 +1,54 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.modules.quicksettings
ShellRoot {
id: root
property int confirmedValue: 30
property int commitCount: 0
property int lastCommit: -1
HomeBrightnessSlider {
id: slider
width: 200
value: root.confirmedValue
accessibleName: "Desk lamp brightness"
onCommitted: value => {
root.commitCount += 1;
root.lastCommit = value;
}
}
IpcHandler {
target: "home-brightness-slider-test"
function reset(value: int): void {
root.confirmedValue = value;
root.commitCount = 0;
root.lastCommit = -1;
slider.cancelPointerInteraction();
}
function external(value: int): void { root.confirmedValue = value; }
function press(position: int): void { slider.beginPointerInteraction(position); }
function move(position: int): void { slider.movePointerInteraction(position); }
function release(): void { slider.releasePointerInteraction(); }
function cancel(): void { slider.cancelPointerInteraction(); }
function wheel(delta: int): void { slider.commitWheel(delta); }
function status(): string {
return JSON.stringify({
confirmedValue: root.confirmedValue,
previewValue: slider.previewValue,
interactionActive: slider.interactionActive,
commitCount: root.commitCount,
lastCommit: root.lastCommit,
accessibleRoleIsSlider: slider.Accessible.role === Accessible.Slider,
accessibleName: slider.Accessible.name,
accessibleDescription: slider.Accessible.description,
accessibleFocusable: slider.Accessible.focusable
});
}
}
}
@@ -0,0 +1,25 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
ShellRoot {
IpcHandler {
target: "home-pref-test"
function initialize(idsJson: string): void { HomePreferences.initialize(JSON.parse(idsJson)); }
function add(id: string): void { HomePreferences.add(id); }
function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); }
function move(id: string, index: int): void { HomePreferences.move(id, index); }
function remove(id: string): void { HomePreferences.remove(id); }
function status(): string {
return JSON.stringify({
initialized: HomePreferences.initialized,
favorites: HomePreferences.favorites,
saveError: HomePreferences.saveError,
stateDir: Quickshell.stateDir
});
}
}
}
@@ -0,0 +1,169 @@
// A light dimmer that previews locally and sends one value when interaction
// ends. Home Assistant never sees the intermediate pointer positions.
import QtQuick
import qs.config
Item {
id: root
property int value: 0
property int previewValue: 0
property bool interactionActive: false
property string accessibleName: "Brightness"
readonly property bool pressed: root.interactionActive
signal previewChanged(int value)
signal committed(int value)
implicitWidth: 160
implicitHeight: 32
activeFocusOnTab: root.enabled
opacity: root.enabled ? 1 : 0.42
Accessible.role: Accessible.Slider
Accessible.name: root.accessibleName
// Qt 6.11's installed Accessible attached type has no structured value or
// range properties, so expose both in the supported live description.
Accessible.description: root.previewValue + " percent, range 0 to 100"
Accessible.focusable: root.enabled
Accessible.focused: root.activeFocus
Accessible.onIncreaseAction: root.commitStep(5)
Accessible.onDecreaseAction: root.commitStep(-5)
onValueChanged: {
if (!root.interactionActive)
root.updatePreview(root.value, false);
}
Component.onCompleted: root.updatePreview(root.value, false)
Keys.onLeftPressed: root.commitStep(-5)
Keys.onDownPressed: root.commitStep(-5)
Keys.onRightPressed: root.commitStep(5)
Keys.onUpPressed: root.commitStep(5)
Rectangle {
id: track
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: 10
radius: 5
color: Theme.alpha(Theme.fg, 0.105)
border.width: root.activeFocus ? 1 : 0
border.color: Theme.alpha(Theme.warn, 0.72)
Rectangle {
id: fill
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: track.width * root.previewValue / 100
radius: track.radius
color: Theme.warn
Behavior on width {
enabled: !root.interactionActive
NumberAnimation {
duration: Theme.durFast
easing.type: Easing.OutQuad
}
}
}
Rectangle {
id: knob
anchors.verticalCenter: parent.verticalCenter
x: Math.max(0, Math.min(track.width - width,
track.width * root.previewValue / 100 - width / 2))
width: 16
height: 16
radius: 8
color: Theme.fg
border.width: 1
border.color: Theme.alpha(Theme.bgDark, 0.38)
}
}
// The 32 px interaction surface is intentionally much taller than the
// 10 px track, while remaining inside this component's layout bounds.
MouseArea {
id: drag
anchors.fill: parent
enabled: root.enabled
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onPressed: event => {
root.forceActiveFocus();
root.beginPointerInteraction(event.x);
event.accepted = true;
}
onPositionChanged: event => root.movePointerInteraction(event.x)
onReleased: event => {
root.releasePointerInteraction();
event.accepted = true;
}
onCanceled: root.cancelPointerInteraction()
onWheel: event => {
root.commitWheel(event.angleDelta.y);
event.accepted = true;
}
}
function clamp(candidate: real): int {
return Math.max(0, Math.min(100, Math.round(candidate)));
}
function updatePreview(candidate: real, announce: bool): void {
const nextValue = root.clamp(candidate);
if (root.previewValue === nextValue)
return;
root.previewValue = nextValue;
if (announce)
root.previewChanged(nextValue);
}
function previewAt(pointerX: real): void {
root.updatePreview(pointerX / Math.max(1, drag.width) * 100, true);
}
function beginPointerInteraction(pointerX: real): void {
if (!root.enabled)
return;
root.interactionActive = true;
root.previewAt(pointerX);
}
function movePointerInteraction(pointerX: real): void {
if (root.interactionActive)
root.previewAt(pointerX);
}
function releasePointerInteraction(): void {
if (!root.interactionActive)
return;
root.interactionActive = false;
root.committed(root.previewValue);
}
function cancelPointerInteraction(): void {
root.interactionActive = false;
root.updatePreview(root.value, false);
}
function commitWheel(delta: int): void {
if (delta === 0)
return;
root.commitStep(delta > 0 ? 5 : -5);
}
function commitStep(delta: int): void {
if (!root.enabled)
return;
root.updatePreview(root.previewValue + delta, true);
root.committed(root.previewValue);
}
}
@@ -8,6 +8,9 @@ Item {
property bool expanded: false
signal toggleExpanded
readonly property bool hasSelection: HomeAssistant.selectedEntities.length > 0
readonly property bool showSetup: HomeAssistant.phase === "ready" && !root.hasSelection
implicitHeight: content.implicitHeight
Column {
@@ -18,46 +21,50 @@ Item {
ControlSectionHeader {
width: parent.width
label: "Home"
action: HomeAssistant.entities.length > 0
? HomeAssistant.entities.length + " accessories " + (root.expanded ? "⌃" : "›")
: "Retry"
action: root.headerAction()
actionEnabled: HomeAssistant.phase !== "loading"
onActionTriggered: {
if (HomeAssistant.entities.length > 0)
if (root.hasSelection) {
root.toggleExpanded();
else
} else if (root.showSetup) {
root.openHomeSettings();
} else {
HomeAssistant.refresh();
}
}
}
Rectangle {
id: homeCard
id: restingCard
width: parent.width
height: visible ? homeGrid.implicitHeight + 20 : 0
visible: HomeAssistant.entities.length > 0
height: visible ? restingGrid.implicitHeight + 20 : 0
visible: root.hasSelection && !root.expanded
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.accent, 0.045)
color: Theme.alpha(Theme.warn, 0.028)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.12)
border.color: Theme.alpha(Theme.warn, 0.095)
Grid {
id: homeGrid
id: restingGrid
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 10
columns: 4
spacing: 7
columns: 2
spacing: 8
Repeater {
model: HomeAssistant.visibleEntities
HomeTile {
required property var modelData
width: (homeGrid.width - homeGrid.spacing * 3) / 4
width: (restingGrid.width - restingGrid.spacing) / 2
entity: modelData
busy: HomeAssistant.busyEntityId === modelData.id
onActivated: HomeAssistant.toggleEntity(modelData.id)
busy: HomeAssistant.isBusy(modelData.id)
pendingBrightness: HomeAssistant.pendingFor(modelData.id)
actionError: HomeAssistant.errorFor(modelData.id)
onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
}
}
}
@@ -65,28 +72,32 @@ Item {
Rectangle {
width: parent.width
height: visible ? 66 : 0
visible: HomeAssistant.entities.length === 0
height: visible ? 72 : 0
visible: !root.hasSelection
radius: Theme.cardRadius
color: Theme.alpha(Theme.fg, 0.05)
color: root.showSetup
? Theme.alpha(Theme.accent, 0.055)
: Theme.alpha(Theme.fg, 0.045)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.045)
border.color: root.showSetup
? Theme.alpha(Theme.accent, 0.12)
: Theme.alpha(Theme.fg, 0.055)
Text {
id: stateIcon
anchors.left: parent.left
anchors.leftMargin: 12
anchors.leftMargin: 13
anchors.verticalCenter: parent.verticalCenter
text: HomeAssistant.phase === "loading" ? "\u{F0772}" : "\u{F02DC}"
color: HomeAssistant.phase === "loading" ? Theme.accent : Theme.fgDim
text: root.showSetup ? "\u{F0335}" : "\u{F02DC}"
color: root.showSetup ? Theme.accent : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 16
font.pixelSize: 17
}
Column {
anchors.left: stateIcon.right
anchors.leftMargin: 11
anchors.right: openHome.left
anchors.right: emptyAction.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 3
@@ -111,61 +122,66 @@ Item {
}
Rectangle {
id: openHome
id: emptyAction
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
width: 48
height: 28
radius: 9
width: root.showSetup ? 68 : 52
height: 30
radius: 10
visible: HomeAssistant.phase !== "loading"
color: openMouse.containsMouse
color: emptyActionMouse.containsMouse
? Theme.alpha(Theme.accent, 0.20)
: Theme.alpha(Theme.accent, 0.11)
: Theme.alpha(Theme.accent, 0.105)
Text {
anchors.centerIn: parent
text: "Open"
text: root.showSetup ? "Manage" : "Open"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
font.weight: Font.DemiBold
}
MouseArea {
id: openMouse
id: emptyActionMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: HomeAssistant.open()
onClicked: {
if (root.showSetup)
root.openHomeSettings();
else
HomeAssistant.open();
}
}
}
}
Rectangle {
width: parent.width
height: visible ? 30 : 0
height: visible ? 34 : 0
visible: HomeAssistant.stale
radius: 9
color: Theme.alpha(Theme.warn, 0.08)
radius: 10
color: Theme.alpha(Theme.warn, 0.075)
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "Showing the last known state"
text: "Last known state · " + root.countSummary()
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
anchors.right: parent.right
anchors.rightMargin: 10
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "Retry"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
font.weight: Font.DemiBold
MouseArea {
anchors.fill: parent
anchors.margins: -6
@@ -177,88 +193,66 @@ Item {
Section {
width: parent.width
expanded: root.expanded && HomeAssistant.entities.length > 0
expanded: root.expanded && root.hasSelection
ScrollColumn {
width: parent.width
maxHeight: 270
spacing: 2
maxHeight: 324
spacing: 8
Grid {
id: expandedGrid
width: parent.width
columns: 2
spacing: 8
Repeater {
model: HomeAssistant.entities
model: HomeAssistant.selectedEntities
Rectangle {
id: entityRow
HomeTile {
required property var modelData
width: parent.width
height: 44
radius: 10
color: entityMouse.containsMouse
? Theme.alpha(Theme.fg, 0.09)
: "transparent"
width: (expandedGrid.width - expandedGrid.spacing) / 2
entity: modelData
busy: HomeAssistant.isBusy(modelData.id)
pendingBrightness: HomeAssistant.pendingFor(modelData.id)
actionError: HomeAssistant.errorFor(modelData.id)
onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
}
}
}
Text {
id: entityGlyph
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0335}"
color: entityRow.modelData.active ? Theme.warn : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 14
}
Column {
anchors.left: entityGlyph.right
anchors.leftMargin: 10
anchors.right: entityState.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
RowButton {
width: parent.width
text: entityRow.modelData.name
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
width: parent.width
text: !entityRow.modelData.available ? "Unavailable" : entityRow.modelData.state
color: Theme.fgMuted
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
Text {
id: entityState
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: HomeAssistant.busyEntityId === entityRow.modelData.id
? "Working"
: (entityRow.modelData.active ? "On" : "Off")
color: entityRow.modelData.active ? Theme.warn : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
MouseArea {
id: entityMouse
anchors.fill: parent
enabled: entityRow.modelData.available && HomeAssistant.busyEntityId === ""
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: HomeAssistant.toggleEntity(entityRow.modelData.id)
icon: "preferences-system-symbolic"
label: "Manage in Settings"
sublabel: root.countSummary()
onClicked: root.openHomeSettings()
}
}
}
}
function headerAction(): string {
if (root.hasSelection) {
const noun = HomeAssistant.configuredCount === 1 ? "accessory" : "accessories";
return HomeAssistant.configuredCount + " " + noun + (root.expanded ? " ⌃" : " ›");
}
if (HomeAssistant.phase === "loading")
return "Loading";
if (root.showSetup)
return "Manage";
return "Retry";
}
function countSummary(): string {
return HomeAssistant.configuredCount + " selected · "
+ HomeAssistant.discoveredCount + " discovered";
}
function emptyTitle(): string {
if (root.showSetup)
return "Choose your accessories";
if (HomeAssistant.phase === "loading")
return "Loading your home";
if (HomeAssistant.lastError === "authentication-required")
@@ -269,12 +263,23 @@ Item {
}
function emptyDetail(): string {
if (root.showSetup) {
if (HomeAssistant.discoveredCount === 0)
return "No lights discovered yet";
const noun = HomeAssistant.discoveredCount === 1 ? "light" : "lights";
return HomeAssistant.discoveredCount + " " + noun + " ready to add";
}
if (HomeAssistant.phase === "loading")
return "Reading configured favourites";
return "Finding your selected lights";
if (HomeAssistant.lastError === "authentication-required")
return "Update the long-lived access token";
if (HomeAssistant.lastError === "not-configured")
return "Add a URL, token and favourites";
return "Check the connection and retry";
return "Connect Home Assistant in Settings";
return "Check the connection, then retry";
}
function openHomeSettings(): void {
ShellState.close();
ShellState.openSettings("home-phone");
}
}
@@ -6,76 +6,165 @@ Rectangle {
required property var entity
property bool busy: false
signal activated
property int pendingBrightness: -1
property string actionError: ""
implicitHeight: 72
radius: Theme.cardRadius
signal powerRequested
signal brightnessRequested(int value)
readonly property int confirmedBrightness: root.clamp(root.entity.brightnessPct ?? 0)
readonly property int displayedBrightness: brightnessSlider.previewValue
readonly property bool powerEnabled: root.entity.available && !root.busy
readonly property bool dimmerEnabled: root.entity.available
&& root.entity.dimmable
&& !root.busy
implicitHeight: 124
radius: 14
color: {
if (root.entity.active)
return Theme.alpha(Theme.warn, tileMouse.containsMouse ? 0.20 : 0.14);
return Theme.alpha(Theme.fg, tileMouse.containsMouse ? 0.10 : 0.055);
return Theme.alpha(Theme.warn, powerArea.containsMouse ? 0.145 : 0.095);
return Theme.alpha(Theme.fg, powerArea.containsMouse ? 0.078 : 0.045);
}
border.width: 1
border.color: root.entity.active
? Theme.alpha(Theme.warn, 0.20)
: Theme.alpha(Theme.fg, 0.045)
border.color: {
if (root.activeFocus)
return Theme.alpha(Theme.accent, 0.72);
if (root.entity.active)
return Theme.alpha(Theme.warn, 0.17);
return Theme.alpha(Theme.fg, 0.065);
}
activeFocusOnTab: root.powerEnabled
Behavior on color { ColorAnimation { duration: Theme.durFast } }
Behavior on border.color { ColorAnimation { duration: Theme.durFast } }
Keys.onSpacePressed: {
if (root.powerEnabled)
root.powerRequested();
}
Keys.onReturnPressed: {
if (root.powerEnabled)
root.powerRequested();
}
Rectangle {
id: bulb
anchors.left: parent.left
anchors.leftMargin: 13
anchors.top: parent.top
anchors.topMargin: 13
width: 30
height: 30
radius: 10
color: root.entity.active
? Theme.alpha(Theme.warn, 0.16)
: Theme.alpha(Theme.fg, 0.065)
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.top: parent.top
anchors.topMargin: 9
text: root.busy ? "\u{F0772}" : root.glyphFor(root.entity.domain)
anchors.centerIn: parent
text: root.busy ? "\u{F0772}" : "\u{F0335}"
color: root.entity.active ? Theme.warn : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 15
}
Column {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 8
anchors.bottom: parent.bottom
anchors.bottomMargin: 8
spacing: 1
}
Text {
width: parent.width
anchors.right: parent.right
anchors.rightMargin: 13
anchors.verticalCenter: bulb.verticalCenter
text: root.entity.dimmable ? root.displayedBrightness + "%" : "—"
color: root.entity.active || root.pendingBrightness >= 0
? Theme.warn
: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.features: Theme.tabularFigures
}
Text {
id: aliasLabel
anchors.left: parent.left
anchors.leftMargin: 13
anchors.right: parent.right
anchors.rightMargin: 13
anchors.top: bulb.bottom
anchors.topMargin: 8
text: root.entity.name
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: !root.entity.available ? "Unavailable" : (root.entity.active ? "On" : "Off")
color: root.entity.active ? Theme.warn : Theme.fgMuted
anchors.left: aliasLabel.left
anchors.right: aliasLabel.right
anchors.top: aliasLabel.bottom
anchors.topMargin: 2
text: root.secondaryText()
color: root.actionError !== ""
? Theme.warn
: (root.entity.active ? Theme.warn : Theme.fgMuted)
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: 9
}
font.pixelSize: Theme.fontSizeSmall
}
MouseArea {
id: tileMouse
anchors.fill: parent
enabled: root.entity.available && !root.busy
id: powerArea
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: brightnessSlider.top
anchors.bottomMargin: 1
enabled: root.powerEnabled
hoverEnabled: true
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.activated()
onPressed: root.forceActiveFocus()
onClicked: root.powerRequested()
}
function glyphFor(domain: string): string {
if (domain === "light")
return "\u{F0335}";
if (domain === "switch")
return "\u{F0521}";
if (domain === "scene")
return "\u{F0FCE}";
return "\u{F02DC}";
HomeBrightnessSlider {
id: brightnessSlider
anchors.left: parent.left
anchors.leftMargin: 13
anchors.right: parent.right
anchors.rightMargin: 13
anchors.bottom: parent.bottom
anchors.bottomMargin: 5
value: root.pendingBrightness >= 0
? root.pendingBrightness
: root.confirmedBrightness
enabled: root.dimmerEnabled
accessibleName: root.entity.name + " brightness"
onCommitted: value => root.brightnessRequested(value)
}
function clamp(candidate: real): int {
return Math.max(0, Math.min(100, Math.round(candidate)));
}
function secondaryText(): string {
if (root.actionError !== "")
return root.errorText(root.actionError);
if (!root.entity.available)
return "Unavailable";
if (root.busy && root.pendingBrightness >= 0)
return "Setting " + root.pendingBrightness + "%";
if (root.busy)
return "Updating";
return root.entity.active ? "On" : "Off";
}
function errorText(code: string): string {
if (code === "authentication-required")
return "Authentication required";
if (code === "entity-not-discovered")
return "Light is unavailable";
return "Couldn’t update light";
}
}
@@ -0,0 +1,24 @@
import QtQuick
import qs.services
// Nonvisual owner of Phone Controls' stable action model and enablement rules.
// Keeping this logic free of delegates and icons makes it safe to exercise in
// disposable diagnostic engines.
QtObject {
id: root
readonly property var actionModels: [
{ id: "share", glyph: "\u{F0142}", label: "Send file" },
{ id: "clipboard", glyph: "\u{F014C}", label: "Clipboard" },
{ id: "ring", glyph: "\u{F009A}", label: "Ring" },
{ id: "messages", glyph: "\u{F0365}", label: "Messages" }
]
function actionEnabled(action: string): bool {
if (action === "messages")
return SystemSettings.bluebubblesAvailable;
return KdeConnect.phoneReachable
&& !KdeConnect.transferActive
&& KdeConnect.supports(action);
}
}
@@ -10,11 +10,11 @@ Item {
signal toggleExpanded
readonly property var phone: KdeConnect.preferredPhone
readonly property var actionModels: [
{ id: "share", glyph: "\u{F0142}", label: "Send file" },
{ id: "clipboard", glyph: "\u{F014C}", label: "Clipboard" },
{ id: "ring", glyph: "\u{F009A}", label: "Ring" }
].filter(item => KdeConnect.supports(item.id))
readonly property var actionModels: phoneActions.actionModels
PhoneActions {
id: phoneActions
}
implicitHeight: content.implicitHeight
@@ -120,9 +120,8 @@ Item {
Grid {
id: actionsGrid
width: parent.width
columns: Math.max(1, root.actionModels.length)
columns: 4
spacing: 7
visible: root.actionModels.length > 0
Repeater {
model: root.actionModels
@@ -136,8 +135,20 @@ Item {
color: actionMouse.containsMouse && actionMouse.enabled
? Theme.alpha(Theme.accent, 0.15)
: Theme.alpha(Theme.accent, 0.075)
border.width: actionMouse.activeFocus ? 2 : 0
border.color: Theme.accent
opacity: actionMouse.enabled ? 1 : 0.48
Accessible.role: Accessible.Button
Accessible.name: actionButton.modelData.label
Accessible.description: root.actionAccessibleDescription(actionButton.modelData.id)
Accessible.focusable: actionMouse.enabled
Accessible.focused: actionMouse.activeFocus
Accessible.onPressAction: {
if (actionMouse.enabled)
root.invoke(actionButton.modelData.id);
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
@@ -160,10 +171,13 @@ Item {
MouseArea {
id: actionMouse
anchors.fill: parent
enabled: KdeConnect.phoneReachable && !KdeConnect.transferActive
enabled: root.actionEnabled(actionButton.modelData.id)
hoverEnabled: true
activeFocusOnTab: enabled
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: root.invoke(actionButton.modelData.id)
Keys.onReturnPressed: root.invoke(actionButton.modelData.id)
Keys.onSpacePressed: root.invoke(actionButton.modelData.id)
}
}
}
@@ -172,7 +186,17 @@ Item {
Text {
width: parent.width
visible: root.phone && !KdeConnect.phoneReachable && root.actionModels.length > 0
text: "Actions become available when the iPhone reconnects"
text: "KDE Connect actions are unavailable until the iPhone reconnects"
color: Theme.fgMuted
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width
visible: !SystemSettings.bluebubblesAvailable
text: "BlueBubbles is not installed"
color: Theme.fgMuted
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
@@ -254,6 +278,20 @@ Item {
KdeConnect.sendClipboard();
else if (action === "ring")
KdeConnect.ring();
else if (action === "messages") {
if (SystemSettings.bluebubblesAvailable && SystemSettings.openApplication("bluebubbles"))
ShellState.close();
}
}
function actionEnabled(action: string): bool {
return phoneActions.actionEnabled(action);
}
function actionAccessibleDescription(action: string): string {
if (action === "messages")
return SystemSettings.bluebubblesAvailable ? "Opens BlueBubbles" : "BlueBubbles is not installed";
return root.actionEnabled(action) ? "Available through KDE Connect" : "Unavailable through KDE Connect";
}
function localPath(selectedUrl: url): string {
@@ -0,0 +1,19 @@
module qs.modules.quicksettings
AudioDeviceList 1.0 AudioDeviceList.qml
AudioSlider 1.0 AudioSlider.qml
BluetoothList 1.0 BluetoothList.qml
BrightnessControl 1.0 BrightnessControl.qml
ControlSectionHeader 1.0 ControlSectionHeader.qml
HomeBrightnessSlider 1.0 HomeBrightnessSlider.qml
HomeControls 1.0 HomeControls.qml
HomeTile 1.0 HomeTile.qml
IconButton 1.0 IconButton.qml
PhoneActions 1.0 PhoneActions.qml
PhoneControls 1.0 PhoneControls.qml
QuickSettings 1.0 QuickSettings.qml
QuickSettingsPanel 1.0 QuickSettingsPanel.qml
RecentExchange 1.0 RecentExchange.qml
RowButton 1.0 RowButton.qml
ScrollColumn 1.0 ScrollColumn.qml
Section 1.0 Section.qml
WifiList 1.0 WifiList.qml
@@ -32,29 +32,29 @@ Item {
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.use24Hour; onToggled: value => DesktopPreferences.use24Hour = value }
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.showSeconds; onToggled: value => DesktopPreferences.showSeconds = value }
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.showWeekday; onToggled: value => DesktopPreferences.showWeekday = value }
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.showCpu; onToggled: value => DesktopPreferences.showCpu = value } }
SettingRow { label: "Memory"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showMemory; onToggled: value => DesktopPreferences.showMemory = value } }
SettingRow { label: "Graphics"; divider: false; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showGpu; onToggled: value => DesktopPreferences.showGpu = value } }
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) } }
}
}
}
@@ -0,0 +1,75 @@
import QtQuick
import qs.config
Item {
id: root
required property var entity
signal addRequested(string id)
width: parent ? parent.width : 620
implicitHeight: 62
Column {
anchors.left: parent.left
anchors.right: stateCopy.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
width: parent.width
text: root.entity.sourceName
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.entity.id
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideMiddle
}
}
Text {
id: stateCopy
anchors.right: addButton.left
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
width: 92
text: root.entity.available === false ? "Unavailable" : String(root.entity.state || "Unknown")
color: root.entity.available === false ? Theme.fgMuted : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
}
SettingsButton {
id: addButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Add"
tone: "accent"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 0
border.color: activeFocus ? Theme.fg : Theme.alpha(Theme.fg, 0)
onClicked: root.addRequested(root.entity.id)
Keys.onReturnPressed: root.addRequested(root.entity.id)
Keys.onSpacePressed: root.addRequested(root.entity.id)
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
color: Theme.alpha(Theme.fg, 0.06)
}
}
@@ -25,10 +25,10 @@ Item {
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.dockAutohide; onToggled: value => DesktopPreferences.dockAutohide = value }
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.dockRevealDelayMs === 0 ? "Instant" : `${DesktopPreferences.dockRevealDelayMs} ms` }
SettingRow { label: "Hide delay"; detail: "Prevents flicker when crossing icons"; value: `${DesktopPreferences.dockHideDelayMs} ms`; divider: false }
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 }
}
SettingsCard {
@@ -0,0 +1,203 @@
import QtQuick
import qs.config
import qs.widgets
Rectangle {
id: root
required property var favorite
required property string sourceName
required property int index
required property bool featured
signal aliasCommitted(string id, string alias)
signal removeRequested(string id)
signal moveRequested(string id, int targetIndex)
readonly property bool dragging: dragHandler.active
implicitHeight: 108
radius: Theme.cardRadius
color: root.dragging
? Theme.mix(Theme.bgDark, Theme.accent, 0.09)
: Theme.alpha(Theme.bgDark, 0.7)
border.width: root.dragging ? 2 : 1
border.color: root.dragging
? Theme.alpha(Theme.accent, 0.82)
: Theme.alpha(Theme.fg, 0.07)
z: root.dragging ? 10 : 0
transform: Translate {
x: root.dragging ? dragHandler.translation.x : 0
y: root.dragging ? dragHandler.translation.y : 0
}
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: root.radius
opacity: root.dragging ? 0.82 : 0.2
}
Rectangle {
id: dragHandle
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
width: 30
height: 42
radius: 9
activeFocusOnTab: true
color: root.dragging || activeFocus
? Theme.alpha(Theme.accent, 0.14)
: (handleMouse.containsMouse ? Theme.alpha(Theme.fg, 0.09) : Theme.alpha(Theme.fg, 0.045))
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.06)
Text {
anchors.centerIn: parent
text: "⠿"
color: root.dragging ? Theme.accent : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: 16
}
MouseArea {
id: handleMouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
cursorShape: Qt.SizeAllCursor
}
DragHandler {
id: dragHandler
target: null
onActiveChanged: {
if (!active)
root.commitDrag();
}
}
Keys.onPressed: event => {
if (event.key === Qt.Key_Left || event.key === Qt.Key_Up) {
root.moveRequested(root.favorite.id, Math.max(0, root.index - 1));
event.accepted = true;
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Down) {
const grid = root.GridView.view;
const lastIndex = grid ? grid.count - 1 : root.index;
root.moveRequested(root.favorite.id, Math.min(lastIndex, root.index + 1));
event.accepted = true;
}
}
}
Rectangle {
id: aliasFrame
anchors.left: dragHandle.right
anchors.leftMargin: 10
anchors.right: removeButton.left
anchors.rightMargin: 12
anchors.top: parent.top
anchors.topMargin: 12
height: 34
radius: 8
color: Theme.alpha(Theme.fg, aliasInput.activeFocus ? 0.075 : 0.045)
border.width: aliasInput.activeFocus ? 2 : 1
border.color: aliasInput.activeFocus
? Theme.alpha(Theme.accent, 0.78)
: Theme.alpha(Theme.fg, 0.065)
TextInput {
id: aliasInput
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
activeFocusOnTab: true
text: String(root.favorite.alias || "")
color: Theme.fg
selectionColor: Theme.accent
selectedTextColor: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
verticalAlignment: TextInput.AlignVCenter
selectByMouse: true
clip: true
onEditingFinished: root.aliasCommitted(root.favorite.id, text)
Text {
anchors.fill: parent
visible: aliasInput.text === "" && !aliasInput.activeFocus
text: root.sourceName
color: Theme.fgDim
font: aliasInput.font
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
}
}
Text {
anchors.left: aliasFrame.left
anchors.right: removeButton.left
anchors.rightMargin: 12
anchors.top: aliasFrame.bottom
anchors.topMargin: 7
text: root.sourceName
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
Rectangle {
anchors.left: aliasFrame.left
anchors.bottom: parent.bottom
anchors.bottomMargin: 10
width: badgeCopy.implicitWidth + 14
height: 21
radius: Theme.pillRadius
visible: root.featured
color: Theme.alpha(Theme.accent, 0.1)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.2)
Text {
id: badgeCopy
anchors.centerIn: parent
text: "Control Center"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: 9
font.weight: Font.DemiBold
}
}
SettingsButton {
id: removeButton
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "Remove"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: root.removeRequested(root.favorite.id)
Keys.onReturnPressed: root.removeRequested(root.favorite.id)
Keys.onSpacePressed: root.removeRequested(root.favorite.id)
}
function commitDrag(): void {
const grid = root.GridView.view;
if (!grid || grid.count <= 0)
return;
const centerX = root.x + dragHandler.translation.x + root.width / 2;
const centerY = root.y + dragHandler.translation.y + root.height / 2;
const modelCount = grid.count;
const column = Math.max(0, Math.min(1, Math.floor(centerX / grid.cellWidth)));
const row = Math.max(0, Math.floor(centerY / grid.cellHeight));
root.moveRequested(root.favorite.id, Math.min(modelCount - 1, row * 2 + column));
}
}
@@ -0,0 +1,316 @@
import QtQuick
import qs.config
import qs.services
Item {
id: root
objectName: "home-phone-page"
property string lightQuery: searchInput.text.trim().toLowerCase()
readonly property var availableLights: HomeAssistant.catalog.filter(entity => {
if (HomeAssistant.selectedEntities.some(selected => selected.id === entity.id))
return false;
const haystack = (entity.sourceName + " " + entity.id).toLowerCase();
return root.lightQuery === "" || haystack.includes(root.lightQuery);
})
readonly property string availableEmptyText: root.availableLights.length > 0
? ""
: (root.lightQuery !== ""
? "No lights match that search"
: (HomeAssistant.catalog.length === 0
? "No lights discovered"
: "All discovered lights are already selected"))
readonly property var pageDiagnostics: ({
availableLightIds: root.availableLights.map(entity => entity.id),
availableEmptyText: root.availableEmptyText,
homeStatus: root.homeStatus()
})
function homeStatus(): string {
if (HomeAssistant.lastError === "authentication-required")
return "Authentication required";
if (HomeAssistant.lastError === "not-configured")
return "Home Assistant is not configured";
if (HomeAssistant.phase === "ready")
return `Connected · ${HomeAssistant.discoveredCount} lights discovered`;
if (HomeAssistant.phase === "degraded")
return "Last update unavailable · showing saved controls";
if (HomeAssistant.phase === "loading")
return "Connecting to Home Assistant";
return "Home Assistant is unavailable";
}
Flickable {
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column {
id: content
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text {
text: "Home & Phone"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 27
font.weight: Font.DemiBold
}
Text {
text: "Choose what appears in Control Center and keep phone continuity close at hand."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
bottomPadding: 6
}
SettingsCard {
title: "Home Assistant"
subtitle: root.homeStatus()
SettingRow {
label: "Light catalog"
detail: "Panama reads light state through the Home Assistant helper."
divider: false
controlWidth: 176
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
id: refreshButton
text: "Refresh"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: HomeAssistant.refresh()
Keys.onReturnPressed: HomeAssistant.refresh()
Keys.onSpacePressed: HomeAssistant.refresh()
}
SettingsButton {
id: openHomeButton
text: "Open"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: HomeAssistant.open()
Keys.onReturnPressed: HomeAssistant.open()
Keys.onSpacePressed: HomeAssistant.open()
}
}
}
}
SettingsCard {
title: "Control Center lights"
subtitle: HomeAssistant.selectedEntities.length === 0
? "Select the lights that belong on your shelf."
: `${Math.min(4, HomeAssistant.selectedEntities.length)} in Control Center · ${HomeAssistant.selectedEntities.length} selected`
Text {
width: parent.width
visible: HomeAssistant.selectedEntities.length === 0
text: "Choose lights below to build your Control Center shelf."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
topPadding: 3
bottomPadding: 13
}
GridView {
id: favoritesGrid
width: parent.width
height: Math.ceil(count / 2) * cellHeight
visible: count > 0
interactive: false
clip: false
cellWidth: width / 2
cellHeight: 116
model: HomeAssistant.selectedEntities
delegate: HomeFavoriteCard {
required property var modelData
width: GridView.view.cellWidth - 6
height: 108
favorite: ({
id: modelData.id,
alias: modelData.name === modelData.sourceName ? "" : modelData.name
})
sourceName: modelData.sourceName
featured: index < 4
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
onRemoveRequested: id => HomePreferences.remove(id)
}
}
Rectangle {
width: parent.width
height: 46
visible: HomePreferences.saveError !== ""
radius: 9
color: Theme.alpha(Theme.warn, 0.09)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.24)
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: retryButton.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: HomePreferences.saveError
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
SettingsButton {
id: retryButton
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: "Retry"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.warn : Theme.alpha(Theme.fg, 0.08)
onClicked: HomePreferences.retrySave()
Keys.onReturnPressed: HomePreferences.retrySave()
Keys.onSpacePressed: HomePreferences.retrySave()
}
}
}
SettingsCard {
title: "Available lights"
subtitle: "Search the Home Assistant catalog by source name or entity ID."
Rectangle {
width: parent.width
height: 38
radius: 10
color: Theme.alpha(Theme.fg, searchInput.activeFocus ? 0.08 : 0.05)
border.width: searchInput.activeFocus ? 2 : 1
border.color: searchInput.activeFocus
? Theme.alpha(Theme.accent, 0.72)
: Theme.alpha(Theme.fg, 0.065)
Text {
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0349}"
color: Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 14
}
TextInput {
id: searchInput
anchors.left: parent.left
anchors.leftMargin: 36
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
activeFocusOnTab: true
color: Theme.fg
selectionColor: Theme.accent
selectedTextColor: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
clip: true
Text {
anchors.fill: parent
visible: searchInput.text === "" && !searchInput.activeFocus
text: "Search available lights"
color: Theme.fgMuted
font: searchInput.font
verticalAlignment: Text.AlignVCenter
}
}
}
Column {
width: parent.width
visible: root.availableLights.length > 0
Repeater {
model: root.availableLights
AvailableLightRow {
required property var modelData
width: parent.width
entity: modelData
onAddRequested: id => HomePreferences.add(id)
}
}
}
Text {
width: parent.width
visible: root.availableLights.length === 0
text: root.availableEmptyText
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
topPadding: 18
bottomPadding: 10
}
}
SettingsCard {
title: "Phone continuity"
subtitle: "Keep the Messages handoff independent from phone connectivity."
SettingRow {
label: "Messages"
detail: "Opens BlueBubbles"
divider: false
controlWidth: 204
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Text {
anchors.verticalCenter: parent.verticalCenter
text: SystemSettings.bluebubblesAvailable ? "Installed" : "Unavailable"
color: SystemSettings.bluebubblesAvailable ? Theme.ok : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
SettingsButton {
id: openBlueBubblesButton
text: "Open"
enabled: SystemSettings.bluebubblesAvailable
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: SystemSettings.openApplication("bluebubbles")
Keys.onReturnPressed: if (enabled) SystemSettings.openApplication("bluebubbles")
Keys.onSpacePressed: if (enabled) SystemSettings.openApplication("bluebubbles")
}
}
}
}
}
}
}
@@ -54,8 +54,8 @@ Item {
SettingsButton {
required property int modelData
text: `${modelData}m`
tone: DesktopPreferences.focusDurationMinutes === modelData ? "accent" : "normal"
onClicked: DesktopPreferences.focusDurationMinutes = modelData
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
onClicked: DesktopPreferences.set("focusDurationMinutes", modelData)
}
}
}
@@ -6,6 +6,11 @@ Rectangle {
id: root
property var hostWindow: null
readonly property var homePhoneDiagnostics: pageLoader.status === Loader.Ready
&& pageLoader.item
&& pageLoader.item.objectName === "home-phone-page"
? pageLoader.item.pageDiagnostics
: ({})
color: Theme.bg
radius: 18
@@ -88,6 +93,7 @@ Rectangle {
case "appearance": return appearancePage;
case "displays": return displaysPage;
case "connectivity": return connectivityPage;
case "home-phone": return homePhonePage;
case "desktop": return desktopPage;
case "sound": return soundPage;
case "notifications": return notificationsPage;
@@ -133,6 +139,7 @@ Rectangle {
Component { id: appearancePage; AppearancePage {} }
Component { id: displaysPage; DisplaysPage {} }
Component { id: connectivityPage; ConnectivityPage {} }
Component { id: homePhonePage; HomePhonePage {} }
Component { id: desktopPage; DesktopPage {} }
Component { id: soundPage; SoundPage {} }
Component { id: notificationsPage; NotificationsPage {} }
@@ -13,6 +13,7 @@ Rectangle {
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
@@ -6,6 +6,8 @@ import qs.services
FloatingWindow {
id: root
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
title: "Panama Settings"
visible: ShellState.settingsOpen
implicitWidth: 1120
@@ -23,6 +25,7 @@ FloatingWindow {
}
SettingsShell {
id: settingsShell
anchors.fill: parent
hostWindow: root
}
@@ -2,6 +2,9 @@ module qs.modules.settings
AboutPage 1.0 AboutPage.qml
AppearancePage 1.0 AppearancePage.qml
ConnectivityPage 1.0 ConnectivityPage.qml
HomePhonePage 1.0 HomePhonePage.qml
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
AvailableLightRow 1.0 AvailableLightRow.qml
DesktopPage 1.0 DesktopPage.qml
DisplaysPage 1.0 DisplaysPage.qml
HomePage 1.0 HomePage.qml
@@ -0,0 +1,68 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.modules.quicksettings
import qs.services
// Component-only diagnostic surface. The contract replaces the two services in
// its copied configuration with fixture singletons before this file is run.
ShellRoot {
PhoneActions {
id: phoneActions
}
IpcHandler {
target: "phone-controls-test"
function fixture(name: string): void {
KdeConnect.available = false;
KdeConnect.transferActive = false;
KdeConnect.lastError = "";
if (name === "offline") {
KdeConnect.devices = [{
id: "fixture-phone",
name: "Fixture iPhone",
type: "phone",
paired: true,
reachable: false,
actions: []
}];
} else if (name === "unsupported") {
KdeConnect.available = true;
KdeConnect.devices = [{
id: "fixture-phone",
name: "Fixture iPhone",
type: "phone",
paired: true,
reachable: true,
actions: ["share"]
}];
} else if (name === "transfer") {
KdeConnect.available = true;
KdeConnect.transferActive = true;
KdeConnect.devices = [{
id: "fixture-phone",
name: "Fixture iPhone",
type: "phone",
paired: true,
reachable: true,
actions: ["share", "clipboard", "ring"]
}];
}
}
function status(): string {
return JSON.stringify({
actions: phoneActions.actionModels.map(action => ({
id: action.id,
enabled: phoneActions.actionEnabled(action.id)
})),
bluebubblesAvailable: SystemSettings.bluebubblesAvailable,
appLaunches: SystemSettings.launchCount,
phoneActions: KdeConnect.actionCount
});
}
}
}
@@ -0,0 +1,48 @@
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
ShellRoot {
IpcHandler {
target: "preference-schema-test"
// Values arrive as a JSON object so the contract can exercise real
// types -- booleans, integers, and strings -- through one entry point.
// Returns the per-key result of set(), so a rejection is observable
// rather than inferred from the value not changing.
function applyJson(payload: string): string {
const requested = JSON.parse(payload);
const accepted = {};
for (const key in requested)
accepted[key] = DesktopPreferences.set(key, requested[key]);
return JSON.stringify(accepted);
}
// Every schema key and its effective value.
function dump(): string {
const out = {};
for (const entry of PreferenceSchema.entries)
out[entry.key] = DesktopPreferences.get(entry.key);
return JSON.stringify(out);
}
// The raw in-memory store, including keys this build does not know.
function raw(): string {
return JSON.stringify(DesktopPreferences.values);
}
function defaults(): string {
return JSON.stringify(PreferenceSchema.defaults());
}
function reset(): void {
DesktopPreferences.resetDesktopDefaults();
}
function keyCount(): int {
return PreferenceSchema.entries.length;
}
}
}
@@ -18,7 +18,6 @@ import urllib.parse
import urllib.request
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import Any
ENV_KEYS = (
@@ -49,7 +48,7 @@ class Config:
@property
def configured(self) -> bool:
return bool(self.base_url and self.token and self.entity_ids)
return bool(self.base_url and self.token)
def compact_json(value: dict[str, object]) -> str:
@@ -226,7 +225,7 @@ def request_json(
config: Config,
method: str,
path: str,
payload: dict[str, str] | None = None,
payload: Mapping[str, object] | None = None,
) -> object:
if not config.base_url or not config.token:
raise BridgeError("not-configured")
@@ -266,71 +265,74 @@ def fallback_name(entity_id: str) -> str:
return entity_id.split(".", 1)[1].replace("_", " ").title()
def normalize_entities(
raw: list[dict[str, Any]],
configured: Sequence[str],
) -> list[dict[str, object]]:
by_id = {
str(item.get("entity_id", "")): item
for item in raw
if isinstance(item, dict)
}
def normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]:
result: list[dict[str, object]] = []
for entity_id in configured:
item = by_id.get(entity_id)
if not item:
for item in raw:
if not isinstance(item, dict):
continue
entity_id = item.get("entity_id")
attributes = item.get("attributes")
if not isinstance(attributes, dict):
if not isinstance(entity_id, str) or not entity_id.startswith("light."):
continue
if not ENTITY_ID.fullmatch(entity_id) or not isinstance(attributes, dict):
continue
state = str(item.get("state", "unavailable"))
available = state not in {"unknown", "unavailable"}
friendly_name = attributes.get("friendly_name")
name = (
friendly_name.strip()
if isinstance(friendly_name, str) and friendly_name.strip()
else fallback_name(entity_id)
active = available and state == "on"
raw_brightness = attributes.get("brightness")
brightness_pct = (
round(max(0, min(255, raw_brightness)) * 100 / 255)
if active
and isinstance(raw_brightness, (int, float))
and not isinstance(raw_brightness, bool)
else 0
)
modes = attributes.get("supported_color_modes", [])
dimmable = (
isinstance(modes, list) and any(mode != "onoff" for mode in modes)
) or isinstance(raw_brightness, (int, float))
source_name = attributes.get("friendly_name")
result.append(
{
"id": entity_id,
"name": name,
"domain": entity_id.split(".", 1)[0],
"sourceName": (
source_name.strip()
if isinstance(source_name, str) and source_name.strip()
else fallback_name(entity_id)
),
"state": state,
"available": available,
"active": available
and state not in {"off", "closed", "idle", "standby"},
"active": active,
"dimmable": dimmable,
"brightnessPct": brightness_pct,
}
)
return result
def ensure_configured(entity_id: str, configured: Sequence[str]) -> str:
if entity_id not in configured:
raise ValueError("entity-not-configured")
return entity_id
def collect_snapshot(config: Config) -> dict[str, object]:
def collect_catalog(config: Config) -> dict[str, object]:
legacy_entity_ids = list(config.entity_ids)
if not config.configured:
return {
"ok": False,
"configured": False,
"generatedAt": int(time.time()),
"entities": [],
"legacyEntityIds": legacy_entity_ids,
"error": "not-configured",
}
try:
raw = request_json(config, "GET", "/api/states")
if not isinstance(raw, list):
raise BridgeError("invalid-response")
entities = normalize_entities(raw, config.entity_ids)
entities = normalize_catalog(raw)
except BridgeError as error:
return {
"ok": False,
"configured": True,
"generatedAt": int(time.time()),
"entities": [],
"legacyEntityIds": legacy_entity_ids,
"error": str(error),
}
return {
@@ -338,10 +340,28 @@ def collect_snapshot(config: Config) -> dict[str, object]:
"configured": True,
"generatedAt": int(time.time()),
"entities": entities,
"legacyEntityIds": legacy_entity_ids,
"error": "",
}
def collect_snapshot(config: Config) -> dict[str, object]:
return collect_catalog(config)
def discovered_light_ids(config: Config) -> set[str]:
raw = request_json(config, "GET", "/api/states")
if not isinstance(raw, list):
raise BridgeError("invalid-response")
return {item["id"] for item in normalize_catalog(raw) if isinstance(item["id"], str)}
def ensure_discovered(config: Config, entity_id: str) -> str:
if entity_id not in discovered_light_ids(config):
raise ValueError("entity-not-discovered")
return entity_id
def probe(config: Config) -> dict[str, object]:
if not config.configured:
return {
@@ -368,7 +388,7 @@ def probe(config: Config) -> dict[str, object]:
def toggle(config: Config, entity_id: str) -> dict[str, object]:
entity_id = ensure_configured(entity_id, config.entity_ids)
entity_id = ensure_discovered(config, entity_id)
request_json(
config,
"POST",
@@ -378,6 +398,31 @@ def toggle(config: Config, entity_id: str) -> dict[str, object]:
return {"ok": True, "entityId": entity_id, "error": ""}
def set_brightness(
config: Config, entity_id: str, percent: int
) -> dict[str, object]:
if isinstance(percent, bool) or not isinstance(percent, int) or not 0 <= percent <= 100:
raise ValueError("invalid-brightness")
ensure_discovered(config, entity_id)
if percent == 0:
path = "/api/services/light/turn_off"
payload = {"entity_id": entity_id}
else:
path = "/api/services/light/turn_on"
payload = {"entity_id": entity_id, "brightness_pct": percent}
request_json(config, "POST", path, payload)
return {"ok": True, "entityId": entity_id, "brightnessPct": percent, "error": ""}
def parse_brightness(value: str) -> int:
if not re.fullmatch(r"(?:0|[1-9][0-9]{0,2})", value):
raise ValueError("invalid-brightness")
percent = int(value)
if percent > 100:
raise ValueError("invalid-brightness")
return percent
def open_home(config: Config) -> dict[str, object]:
if not config.base_url:
return {"ok": False, "error": "not-configured"}
@@ -409,8 +454,8 @@ def main(argv: list[str]) -> int:
if command == "probe" and len(argv) <= 1:
result = probe(config)
success = bool(result["reachable"])
elif command == "snapshot" and len(argv) == 1:
result = collect_snapshot(config)
elif command in {"catalog", "snapshot"} and len(argv) == 1:
result = collect_catalog(config)
success = bool(result["ok"])
elif command == "toggle" and len(argv) == 2:
try:
@@ -420,6 +465,14 @@ def main(argv: list[str]) -> int:
except BridgeError as error:
result = {"ok": False, "error": str(error)}
success = bool(result["ok"])
elif command == "brightness" and len(argv) == 3:
try:
result = set_brightness(config, argv[1], parse_brightness(argv[2]))
except ValueError as error:
result = {"ok": False, "error": str(error)}
except BridgeError as error:
result = {"ok": False, "error": str(error)}
success = bool(result["ok"])
elif command == "open" and len(argv) == 1:
result = open_home(config)
success = bool(result["ok"])
+485 -68
View File
@@ -1,11 +1,12 @@
pragma Singleton
// Home Assistant state for the Control Center. Credentials and REST details
// remain behind the helper; QML receives configured favourites only.
// remain behind the helper; QML composes its catalog with Panama preferences.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
@@ -14,26 +15,53 @@ Singleton {
// "loading" | "ready" | "degraded" | "unavailable"
property string phase: "loading"
property var entities: []
property var catalog: []
property var selectedEntities: []
property bool stale: false
property string busyEntityId: ""
property string lastError: ""
property bool fixtureMode: false
property var fixtureFavorites: []
property string fixtureProcessMode: ""
readonly property var visibleEntities: root.entities.slice(0, 4)
readonly property int configuredCount: root.entities.length
property var actionQueue: []
property var activeAction: null
property var busyEntityIds: []
property var pendingBrightness: ({})
property var entityErrors: ({})
property string actionResponseText: ""
property bool actionStreamFinished: false
property bool actionExited: false
property int actionExitCode: 0
property bool fixtureTransitionDraining: false
property bool fixtureTransitionStreamFinished: false
property bool fixtureTransitionExited: false
property var pendingFixtureTarget: null
property int delayedFixtureExitCode: 0
readonly property var visibleEntities: root.selectedEntities.slice(0, 4)
readonly property int discoveredCount: root.catalog.length
readonly property int configuredCount: root.selectedEntities.length
readonly property bool actionProcessRunning: actionProc.running
// Temporary aliases keep the current Home controls usable until the shelf
// switches to the selected-entity and per-entity action interfaces.
readonly property var entities: root.selectedEntities
readonly property string busyEntityId: root.busyEntityIds.length > 0
? root.busyEntityIds[0]
: ""
function refresh(): void {
if (root.fixtureMode || refreshProc.running)
return;
if (root.entities.length === 0)
if (root.catalog.length === 0)
root.phase = "loading";
refreshProc.running = true;
}
function consumeSnapshot(text: string): void {
function consumeCatalog(text: string): void {
if (root.fixtureMode)
return;
let result = null;
try {
result = JSON.parse(text);
@@ -42,7 +70,11 @@ Singleton {
}
if (result.ok === true) {
root.entities = Array.isArray(result.entities) ? result.entities : [];
root.catalog = Array.isArray(result.entities) ? result.entities : [];
HomePreferences.initialize(Array.isArray(result.legacyEntityIds)
? result.legacyEntityIds
: []);
root.rebuildSelection();
root.phase = "ready";
root.stale = false;
root.lastError = "";
@@ -50,7 +82,7 @@ Singleton {
}
root.lastError = String(result.error || "unreachable");
if (root.entities.length > 0) {
if (root.catalog.length > 0) {
root.phase = "degraded";
root.stale = true;
} else {
@@ -59,50 +91,261 @@ Singleton {
}
}
function rebuildSelection(): void {
const favorites = root.fixtureMode
? root.fixtureFavorites
: HomePreferences.favorites;
const catalogById = {};
for (let index = 0; index < root.catalog.length; index++) {
const entity = root.catalog[index];
catalogById[entity.id] = entity;
}
const nextSelection = [];
for (let index = 0; index < favorites.length; index++) {
const favorite = favorites[index];
const entity = catalogById[favorite.id];
const alias = String(favorite.alias || "").trim();
if (entity) {
nextSelection.push({
id: entity.id,
sourceName: entity.sourceName,
name: alias || entity.sourceName,
state: entity.state,
available: entity.available,
active: entity.active,
dimmable: entity.dimmable,
brightnessPct: entity.brightnessPct
});
continue;
}
const sourceName = favorite.id.split(".")[1].split("_").join(" ");
nextSelection.push({
id: favorite.id,
sourceName,
name: alias || sourceName,
state: "unavailable",
available: false,
active: false,
dimmable: false,
brightnessPct: 0
});
}
root.selectedEntities = nextSelection;
}
Connections {
target: HomePreferences
function onFavoritesChanged(): void {
if (!root.fixtureMode)
root.rebuildSelection();
}
}
function isBusy(entityId: string): bool {
return root.busyEntityIds.indexOf(entityId) >= 0;
}
function pendingFor(entityId: string): int {
return Object.prototype.hasOwnProperty.call(root.pendingBrightness, entityId)
? root.pendingBrightness[entityId]
: -1;
}
function errorFor(entityId: string): string {
return String(root.entityErrors[entityId] || "");
}
function toggleEntity(entityId: string): void {
if (entityId === "" || root.busyEntityId !== "")
root.enqueueAction({ kind: "toggle", entityId });
}
function setBrightness(entityId: string, percent: int): void {
if (!Number.isInteger(percent) || percent < 0 || percent > 100)
return;
if (root.fixtureMode) {
root.entities = root.entities.map(entity => {
if (entity.id !== entityId)
root.enqueueAction({ kind: "brightness", entityId, percent });
}
function enqueueAction(action: var): void {
if (root.fixtureTransitionDraining)
return;
if (!action || (action.kind !== "toggle" && action.kind !== "brightness"))
return;
if (typeof action.entityId !== "string" || action.entityId === "" || root.isBusy(action.entityId))
return;
const entity = root.catalog.find(candidate => candidate.id === action.entityId);
if (!entity || !entity.available || (action.kind === "brightness" && !entity.dimmable))
return;
const nextErrors = Object.assign({}, root.entityErrors);
delete nextErrors[action.entityId];
root.entityErrors = nextErrors;
root.busyEntityIds = root.busyEntityIds.concat([action.entityId]);
if (action.kind === "brightness") {
const nextPending = Object.assign({}, root.pendingBrightness);
nextPending[action.entityId] = action.percent;
root.pendingBrightness = nextPending;
}
if (root.fixtureMode && root.fixtureProcessMode === "") {
root.applyFixtureAction(action);
root.finishActionState(action.entityId);
return;
}
root.actionQueue = root.actionQueue.concat([action]);
root.startNextAction();
}
function startNextAction(): void {
if (root.fixtureTransitionDraining
|| actionProc.running
|| root.activeAction !== null
|| root.actionQueue.length === 0) {
return;
}
root.activeAction = root.actionQueue[0];
root.actionResponseText = "";
root.actionStreamFinished = false;
root.actionExited = false;
root.actionExitCode = 0;
if (root.fixtureProcessMode !== "") {
actionProc.command = root.fixtureActionCommand(root.activeAction);
} else {
actionProc.command = root.activeAction.kind === "brightness"
? [root.helperPath, "brightness", root.activeAction.entityId, String(root.activeAction.percent)]
: [root.helperPath, "toggle", root.activeAction.entityId];
}
actionProc.running = true;
}
function fixtureActionCommand(action: var): var {
if (root.fixtureProcessMode === "delayed-exit")
return ["/usr/bin/sh", "-c", "printf '%s' '{\"ok\":true}'; exit 7"];
if (root.fixtureProcessMode === "no-output"
&& action.entityId === "light.fixture_kitchen") {
return ["/usr/bin/sh", "-c", "sleep 0.5; exit 7"];
}
if (root.fixtureProcessMode === "slow-success")
return ["/usr/bin/sh", "-c", "sleep 2; printf '%s' '{\"ok\":true}'"];
return ["/usr/bin/sh", "-c", "sleep 0.5; printf '%s' '{\"ok\":true}'"];
}
function handleActionStreamFinished(text: string): void {
if (root.fixtureTransitionDraining) {
root.fixtureTransitionStreamFinished = true;
fixtureTransitionTimer.restart();
return;
}
if (root.activeAction === null || root.actionStreamFinished)
return;
root.actionResponseText = text;
root.actionStreamFinished = true;
actionCompletionTimer.restart();
}
function handleActionExited(exitCode: int): void {
// This fixture-only delay gives the contract a deterministic window
// where the process stopped but its exit bookkeeping is still pending.
if (root.fixtureMode
&& root.fixtureProcessMode === "delayed-exit"
&& root.activeAction !== null
&& !root.fixtureTransitionDraining) {
root.delayedFixtureExitCode = exitCode;
delayedFixtureExitTimer.restart();
return;
}
root.recordActionExited(exitCode);
}
function recordActionExited(exitCode: int): void {
if (root.fixtureTransitionDraining) {
root.fixtureTransitionExited = true;
fixtureTransitionTimer.restart();
return;
}
if (root.activeAction === null || root.actionExited)
return;
root.actionExited = true;
root.actionExitCode = exitCode;
actionCompletionTimer.restart();
}
function tryCompleteAction(): void {
if (root.activeAction === null)
return;
if (actionProc.running) {
actionCompletionTimer.restart();
return;
}
if (!root.actionExited || !root.actionStreamFinished)
return;
root.consumeAction(root.actionResponseText, root.actionExitCode);
}
function consumeAction(text: string, exitCode: int): void {
if (root.activeAction === null)
return;
const completedAction = root.activeAction;
let ok = false;
let errorCode = text === "" ? "action-failed" : "invalid-response";
try {
const result = JSON.parse(text);
ok = exitCode === 0 && result.ok === true;
errorCode = String(result.error || "action-failed");
} catch (error) {
// Empty output from a failed process is an action failure, while
// malformed non-empty output remains an invalid response.
}
actionCompletionTimer.stop();
root.actionQueue = root.actionQueue.slice(1);
root.activeAction = null;
root.finishActionState(completedAction.entityId);
const nextErrors = Object.assign({}, root.entityErrors);
if (ok) {
delete nextErrors[completedAction.entityId];
if (root.fixtureMode && root.fixtureProcessMode !== "")
root.applyFixtureAction(completedAction);
else
refreshDelay.restart();
} else {
nextErrors[completedAction.entityId] = errorCode;
}
root.entityErrors = nextErrors;
queueAdvanceTimer.restart();
}
function finishActionState(entityId: string): void {
root.busyEntityIds = root.busyEntityIds.filter(id => id !== entityId);
const nextPending = Object.assign({}, root.pendingBrightness);
delete nextPending[entityId];
root.pendingBrightness = nextPending;
}
function applyFixtureAction(action: var): void {
root.catalog = root.catalog.map(entity => {
if (entity.id !== action.entityId)
return entity;
if (action.kind === "brightness") {
return Object.assign({}, entity, {
state: action.percent === 0 ? "off" : "on",
active: action.percent > 0,
brightnessPct: action.percent
});
}
return Object.assign({}, entity, {
active: !entity.active,
state: entity.active ? "off" : "on"
});
});
return;
}
if (!root.entities.some(entity => entity.id === entityId))
return;
root.busyEntityId = entityId;
actionProc.command = [root.helperPath, "toggle", entityId];
actionProc.running = true;
}
function consumeAction(text: string): void {
if (root.busyEntityId === "")
return;
let ok = false;
let errorCode = "action-failed";
try {
const result = JSON.parse(text);
ok = result.ok === true;
errorCode = String(result.error || errorCode);
} catch (error) {
errorCode = "invalid-response";
}
root.busyEntityId = "";
if (ok) {
root.lastError = "";
refreshDelay.restart();
} else {
root.lastError = errorCode;
if (root.entities.length > 0) {
root.phase = "degraded";
root.stale = true;
}
}
root.rebuildSelection();
}
function open(): void {
@@ -111,61 +354,235 @@ Singleton {
function fixtureEntities(): var {
return [
{ id: "light.fixture_all", name: "All lights", domain: "light", state: "on", available: true, active: true },
{ id: "light.fixture_kitchen", name: "Kitchen", domain: "light", state: "on", available: true, active: true },
{ id: "light.fixture_living", name: "Living room", domain: "light", state: "off", available: true, active: false },
{ id: "light.fixture_bedroom", name: "Bedroom", domain: "light", state: "on", available: true, active: true },
{ id: "light.fixture_hall", name: "Hall", domain: "light", state: "off", available: true, active: false },
{ id: "light.fixture_desk", name: "Desk", domain: "light", state: "off", available: true, active: false },
{ id: "light.fixture_corner", name: "Corner lamp", domain: "light", state: "unavailable", available: false, active: false }
{ id: "light.fixture_all", sourceName: "All lights", state: "on", available: true, active: true, dimmable: true, brightnessPct: 82 },
{ id: "light.fixture_kitchen", sourceName: "Kitchen", state: "on", available: true, active: true, dimmable: true, brightnessPct: 71 },
{ id: "light.fixture_living", sourceName: "Living room", state: "off", available: true, active: false, dimmable: true, brightnessPct: 36 },
{ id: "light.fixture_bedroom", sourceName: "Bedroom", state: "on", available: true, active: true, dimmable: true, brightnessPct: 48 },
{ id: "light.fixture_hall", sourceName: "Hall", state: "off", available: true, active: false, dimmable: false, brightnessPct: 0 },
{ id: "light.fixture_desk", sourceName: "Desk", state: "on", available: true, active: true, dimmable: true, brightnessPct: 24 },
{ id: "light.fixture_corner", sourceName: "Corner lamp", state: "unavailable", available: false, active: false, dimmable: false, brightnessPct: 0 }
];
}
function applyFixture(name: string): void {
if (["ready", "stale", "unavailable"].indexOf(name) < 0)
function fixturePreferenceRecords(): var {
return [
{ id: "light.fixture_all", alias: " Whole home " },
{ id: "light.fixture_kitchen", alias: "Kitchen island" },
{ id: "light.fixture_living", alias: "" },
{ id: "light.fixture_bedroom", alias: "" },
{ id: "light.fixture_hall", alias: "" },
{ id: "light.fixture_desk", alias: "Office" },
{ id: "light.fixture_corner", alias: "" }
];
}
function resetActionState(): void {
actionCompletionTimer.stop();
queueAdvanceTimer.stop();
root.actionQueue = [];
root.activeAction = null;
root.busyEntityIds = [];
root.pendingBrightness = {};
root.entityErrors = {};
root.actionResponseText = "";
root.actionStreamFinished = false;
root.actionExited = false;
root.actionExitCode = 0;
}
function beginFixtureTransition(): void {
if (root.fixtureTransitionDraining)
return;
const shouldDrain = root.fixtureMode
&& root.fixtureProcessMode !== ""
&& (actionProc.running || root.activeAction !== null);
const streamAlreadyFinished = root.actionStreamFinished;
const exitAlreadyObserved = root.actionExited;
fixtureTransitionTimer.stop();
root.fixtureTransitionDraining = shouldDrain;
root.fixtureTransitionStreamFinished = shouldDrain && streamAlreadyFinished;
root.fixtureTransitionExited = shouldDrain && exitAlreadyObserved;
root.resetActionState();
if (!shouldDrain)
return;
if (actionProc.running)
actionProc.running = false;
fixtureTransitionTimer.restart();
}
function tryFinishFixtureTransition(): void {
if (!root.fixtureTransitionDraining)
return;
if (actionProc.running) {
fixtureTransitionTimer.restart();
return;
}
if (!root.fixtureTransitionStreamFinished || !root.fixtureTransitionExited)
return;
const target = root.pendingFixtureTarget;
root.pendingFixtureTarget = null;
root.fixtureTransitionDraining = false;
root.fixtureTransitionStreamFinished = false;
root.fixtureTransitionExited = false;
if (target !== null)
root.installFixtureTarget(target);
queueAdvanceTimer.restart();
}
function applyFixture(name: string): void {
if (["ready", "available-extra", "stale", "stale-authentication", "stale-not-configured",
"unavailable", "missing-selected", "action-error",
"process-actions", "process-no-output", "process-delayed-exit",
"process-slow-actions"].indexOf(name) < 0)
return;
root.requestFixtureTarget({ fixture: true, name });
}
function requestFixtureTarget(target: var): void {
if (root.fixtureTransitionDraining) {
// Preserve the old process's callback guard and keep only the
// latest replacement requested during that drain.
root.pendingFixtureTarget = target;
root.resetActionState();
return;
}
root.pendingFixtureTarget = target;
root.beginFixtureTransition();
if (root.fixtureTransitionDraining)
return;
root.pendingFixtureTarget = null;
root.installFixtureTarget(target);
}
function installFixtureTarget(target: var): void {
if (target.fixture)
root.installFixture(target.name);
else
root.installLiveState();
}
function installFixture(name: string): void {
root.fixtureMode = true;
root.busyEntityId = "";
root.fixtureProcessMode = name === "process-actions"
? "success"
: (name === "process-no-output"
? "no-output"
: (name === "process-delayed-exit"
? "delayed-exit"
: (name === "process-slow-actions" ? "slow-success" : "")));
if (name === "unavailable") {
root.entities = [];
root.catalog = [];
root.fixtureFavorites = [];
root.rebuildSelection();
root.phase = "unavailable";
root.stale = false;
root.lastError = "not-configured";
return;
}
root.entities = root.fixtureEntities();
root.phase = name === "stale" ? "degraded" : "ready";
root.stale = name === "stale";
root.lastError = name === "stale" ? "unreachable" : "";
root.catalog = root.fixtureEntities();
root.fixtureFavorites = root.fixturePreferenceRecords();
if (name === "available-extra") {
root.catalog = root.catalog.concat([{
id: "light.fixture_guest",
sourceName: "Guest lamp",
state: "off",
available: true,
active: false,
dimmable: true,
brightnessPct: 0
}]);
}
if (name === "missing-selected") {
root.fixtureFavorites = root.fixtureFavorites.concat([
{ id: "light.fixture_missing", alias: "Porch" }
]);
}
root.rebuildSelection();
const isStale = ["stale", "stale-authentication", "stale-not-configured"].indexOf(name) >= 0;
root.phase = isStale ? "degraded" : "ready";
root.stale = isStale;
root.lastError = name === "stale-authentication"
? "authentication-required"
: (name === "stale-not-configured"
? "not-configured"
: (name === "stale" ? "unreachable" : ""));
if (name === "action-error") {
root.entityErrors = {
"light.fixture_kitchen": "request-failed"
};
}
}
function clearFixture(): void {
root.requestFixtureTarget({ fixture: false, name: "" });
}
function installLiveState(): void {
root.fixtureMode = false;
root.fixtureProcessMode = "";
root.phase = "loading";
root.entities = [];
root.catalog = [];
root.selectedEntities = [];
root.fixtureFavorites = [];
root.stale = false;
root.busyEntityId = "";
root.lastError = "";
root.refresh();
}
Process {
id: refreshProc
command: [root.helperPath, "snapshot"]
command: [root.helperPath, "catalog"]
stdout: StdioCollector {
onStreamFinished: root.consumeSnapshot(this.text)
onStreamFinished: root.consumeCatalog(this.text)
}
}
Process {
id: actionProc
stdout: StdioCollector {
onStreamFinished: root.consumeAction(this.text)
onStreamFinished: root.handleActionStreamFinished(this.text)
}
onExited: (code, status) => {
if (root.busyEntityId !== "")
root.consumeAction("");
onExited: (code, status) => root.handleActionExited(code)
}
Timer {
id: actionCompletionTimer
interval: 0
onTriggered: root.tryCompleteAction()
}
Timer {
id: queueAdvanceTimer
interval: 0
onTriggered: {
if (root.fixtureTransitionDraining)
return;
if (actionProc.running) {
queueAdvanceTimer.restart();
return;
}
root.startNextAction();
}
}
Timer {
id: fixtureTransitionTimer
interval: 0
onTriggered: root.tryFinishFixtureTransition()
}
Timer {
id: delayedFixtureExitTimer
interval: 1000
onTriggered: root.recordActionExited(root.delayedFixtureExitCode)
}
Timer {
@@ -33,7 +33,7 @@ Singleton {
// Follow Settings.nightLightFrom .. nightLightTo instead of the manual
// switch. Off by default because GNOME's schedule was disabled.
property bool automatic: DesktopPreferences.nightLightAutomatic
property bool automatic: DesktopPreferences.get("nightLightAutomatic")
// What is actually applied right now.
readonly property bool active: root.automatic ? root.scheduled : root.enabled
@@ -81,13 +81,13 @@ Singleton {
// Re-tune in place; restarting the daemon would flash the display.
onTemperatureChanged: {
DesktopPreferences.nightLightTemperature = root.temperature;
DesktopPreferences.set("nightLightTemperature", root.temperature);
if (daemon.running)
Quickshell.execDetached(["hyprctl", "hyprsunset", "temperature", String(root.temperature)]);
}
onEnabledChanged: DesktopPreferences.nightLightEnabled = root.enabled
onAutomaticChanged: DesktopPreferences.nightLightAutomatic = root.automatic
onEnabledChanged: DesktopPreferences.set("nightLightEnabled", root.enabled)
onAutomaticChanged: DesktopPreferences.set("nightLightAutomatic", root.automatic)
onActiveChanged: {
if (!root.initialized)
@@ -92,9 +92,9 @@ Singleton {
}
function openSettings(page: string): void {
const allowed = ["home", "appearance", "displays", "connectivity", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"];
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"];
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
DesktopPreferences.lastPage = root.settingsPage;
DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true;
}
@@ -103,7 +103,7 @@ Singleton {
root.closeSettings();
return;
}
root.openSettings(DesktopPreferences.lastPage || "home");
root.openSettings(DesktopPreferences.get("lastPage") || "home");
}
function closeSettings(): void {
+207 -49
View File
@@ -27,17 +27,41 @@ Singleton {
property bool hyprpaperActive: false
property bool hypridleActive: false
property bool vicinaeActive: false
property bool bluebubblesDetected: false
property string hyprlandVersion: ""
property string quickshellVersion: "0.3.0"
property string lastError: ""
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|| autoHdrWrite.running || vrrWrite.running || directScanoutWrite.running
|| configWrite.running || configVerify.running || bluebubblesQuery.running
readonly property bool autoHdr: DesktopPreferences.autoHdr
readonly property int vrrPolicy: DesktopPreferences.vrrPolicy
readonly property int directScanoutPolicy: DesktopPreferences.directScanoutPolicy
readonly property bool bluebubblesAvailable: root.bluebubblesDetected
readonly property bool autoHdr: DesktopPreferences.get("autoHdr")
readonly property int vrrPolicy: DesktopPreferences.get("vrrPolicy")
readonly property int directScanoutPolicy: DesktopPreferences.get("directScanoutPolicy")
// ── The Hyprland write boundary ─────────────────────────────────────────
// Every option Panama may write, with the hl.config path used to set it and
// the getoption path used to read it back. The UI never names an option or
// supplies a raw value: it calls a setter, which resolves the option here
// and range-checks the value against `allowed`. Nothing user-supplied is
// ever interpolated into the payload.
//
// `hyprctl keyword` is deliberately NOT used. On a Lua-configured Hyprland
// it refuses the write, prints "keyword can't work with non-legacy parsers"
// to stdout, and still exits 0 -- so code branching on the exit status
// believes it succeeded. `hyprctl eval` has the same hazard: it exits 0 on
// syntax and runtime errors, reporting them as an "error:" line instead.
//
// Success therefore means exactly one thing here: the value was read back
// from the compositor and matched what was requested.
//
// The set of writable options is not restated here: it is every schema
// entry carrying a `hypr` block. Adding a live-adjustable Hyprland setting
// is a schema entry plus a prefs.get() call in the Lua, and needs no new
// code in this file.
Process {
id: monitorQuery
@@ -79,42 +103,39 @@ Singleton {
}
}
// Applies a validated batch of options in one `hl.config{}` call, then hands
// off to configVerify. Never commits anything on its own: an "ok" here only
// means Hyprland parsed the payload.
Process {
id: autoHdrWrite
property bool requested: true
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
DesktopPreferences.autoHdr = requested;
root.lastError = "";
} else {
root.lastError = "Hyprland rejected the HDR policy.";
id: configWrite
// id -> integer value, already validated by applyOptions().
property var pending: ({})
stdout: StdioCollector {
onStreamFinished: {
if (this.text.indexOf("error:") >= 0) {
root.reportWriteFailure(configWrite.pending, this.text);
return;
}
root.verifyPending();
}
}
}
Process {
id: vrrWrite
property int requested: 3
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
DesktopPreferences.vrrPolicy = requested;
root.lastError = "";
} else {
root.lastError = "Hyprland rejected the VRR policy.";
}
}
id: bluebubblesQuery
command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]
onExited: (exitCode, exitStatus) => root.bluebubblesDetected = exitCode === 0
}
// Reads the written options back out of the compositor. This is the only
// thing that decides whether a write succeeded.
Process {
id: directScanoutWrite
property int requested: 2
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
DesktopPreferences.directScanoutPolicy = requested;
root.lastError = "";
} else {
root.lastError = "Hyprland rejected the direct-scanout policy.";
}
id: configVerify
stdout: StdioCollector {
onStreamFinished: root.commitVerified(configWrite.pending, this.text)
}
}
@@ -136,6 +157,8 @@ Singleton {
serviceQuery.running = true;
if (!versionQuery.running && !root.hyprlandVersion)
versionQuery.running = true;
if (!bluebubblesQuery.running)
bluebubblesQuery.running = true;
}
function parseMonitors(text: string): void {
@@ -172,33 +195,167 @@ Singleton {
}
}
// ── Applying options ────────────────────────────────────────────────────
// `values` maps schema keys to values, e.g. { vrrPolicy: 3, gapsOut: 12 }.
// The whole batch is validated before anything is sent, so one bad value
// rejects the batch rather than half-applying it.
function applyOptions(values: var): bool {
const requested = {};
for (const key in values) {
const entry = PreferenceSchema.spec(key);
if (!entry || !entry.hypr) {
root.lastError = "That setting is not applied by the compositor.";
return false;
}
const coerced = PreferenceSchema.coerce(key, values[key]);
if (coerced === undefined) {
root.lastError = `Unsupported value for ${entry.label}.`;
return false;
}
requested[key] = coerced;
}
if (Object.keys(requested).length === 0)
return false;
if (configWrite.running || configVerify.running) {
root.lastError = "Another change is still being applied.";
return false;
}
configWrite.pending = requested;
configWrite.exec(["hyprctl", "eval", root.buildConfigPayload(requested)]);
return true;
}
// The value as Hyprland stores it. Several options are a toggle in the UI
// but an integer in the compositor (cm_auto_hdr, follow_mouse); `readAs`
// decides, and config/dot/hypr/prefs.lua does the same conversion via
// prefs.getInt so both sides agree.
function hyprValue(entry: var, value: var): var {
if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
return value ? 1 : 0;
return value;
}
// Serialises validated values into a nested hl.config{} call. Table paths
// come from the schema and values have already passed coerce(), including
// the pattern check on constrained strings, so nothing caller-supplied
// reaches the payload unchecked.
function buildConfigPayload(requested: var): string {
const tree = {};
for (const key in requested) {
const entry = PreferenceSchema.spec(key);
const path = entry.hypr.path;
let node = tree;
for (let i = 0; i < path.length - 1; i++)
node = node[path[i]] = node[path[i]] ?? {};
node[path[path.length - 1]] = root.serialiseValue(root.hyprValue(entry, requested[key]));
}
return `hl.config(${root.serialiseTable(tree)})`;
}
function serialiseValue(value: var): string {
if (typeof value === "boolean")
return value ? "true" : "false";
if (typeof value === "number")
return String(value);
// Strings only reach here after the schema's pattern check; quoting is
// belt-and-braces rather than the primary defence.
return `"${String(value).replace(/["\\]/g, "")}"`;
}
function serialiseTable(node: var): string {
const parts = [];
for (const name in node) {
const child = node[name];
parts.push(`${name} = ${typeof child === "string" ? child : root.serialiseTable(child)}`);
}
return `{ ${parts.join(", ")} }`;
}
function verifyPending(): void {
const options = Object.keys(configWrite.pending)
.map(key => `getoption ${PreferenceSchema.spec(key).hypr.option}`)
.join(" ; ");
configVerify.exec(["hyprctl", "-j", "--batch", options]);
}
// The compositor's answer is authoritative. Preferences are only updated for
// options that actually read back with the requested value.
function commitVerified(requested: var, text: string): void {
// Each getoption answers with its own flat JSON object, and the field
// carrying the value depends on the option's type -- int, bool, float,
// str, or css for the gap box.
const observed = {};
for (const block of text.match(/\{[^{}]*\}/g) ?? []) {
try {
const parsed = JSON.parse(block);
if (parsed.option !== undefined)
observed[parsed.option] = parsed;
} catch (error) {
// A partial line is treated as "not observed", which fails the
// comparison below rather than being mistaken for success.
}
}
const rejected = [];
for (const key in requested) {
const entry = PreferenceSchema.spec(key);
if (!root.matchesObserved(entry, requested[key], observed[entry.hypr.option])) {
rejected.push(entry.label);
continue;
}
DesktopPreferences.set(key, requested[key]);
}
root.lastError = rejected.length === 0 ? "" : `Hyprland did not apply ${rejected.join(" or ")}.`;
}
function matchesObserved(entry: var, value: var, answer: var): bool {
if (!answer)
return false;
const expected = root.hyprValue(entry, value);
switch (entry.hypr.readAs) {
case "bool":
return answer.bool === expected;
case "int":
return answer.int === expected;
case "float":
// getoption prints six decimal places; compare within that.
return Math.abs(answer.float - expected) < 1e-5;
case "str":
return answer.str === expected;
case "css":
// Gaps read back as a box, e.g. "10 10 10 10".
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
}
return false;
}
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 ")}.`;
}
function setAutoHdr(enabled: bool): void {
autoHdrWrite.requested = enabled;
autoHdrWrite.exec(["hyprctl", "keyword", "render:cm_auto_hdr", enabled ? "1" : "0"]);
root.applyOptions({ autoHdr: enabled });
}
function setVrrPolicy(policy: int): void {
if (policy !== 0 && policy !== 3) {
root.lastError = "Unsupported VRR policy.";
return;
}
vrrWrite.requested = policy;
vrrWrite.exec(["hyprctl", "keyword", "misc:vrr", String(policy)]);
root.applyOptions({ vrrPolicy: policy });
}
function setDirectScanoutPolicy(policy: int): void {
if (policy !== 0 && policy !== 2) {
root.lastError = "Unsupported direct-scanout policy.";
return;
}
directScanoutWrite.requested = policy;
directScanoutWrite.exec(["hyprctl", "keyword", "render:direct_scanout", String(policy)]);
root.applyOptions({ directScanoutPolicy: policy });
}
// Replays every compositor-owned preference in one batch at shell start, so
// a value the user changed in Settings survives a reboot even though the
// Lua config only reads the file once, at launch.
function applyPersistedDisplayPolicy(): void {
root.setAutoHdr(DesktopPreferences.autoHdr);
root.setVrrPolicy(DesktopPreferences.vrrPolicy);
root.setDirectScanoutPolicy(DesktopPreferences.directScanoutPolicy);
const values = {};
for (const entry of PreferenceSchema.hyprEntries())
values[entry.key] = DesktopPreferences.get(entry.key);
root.applyOptions(values);
}
function isGnomePanelAllowed(panel: string): bool {
@@ -225,7 +382,8 @@ Singleton {
"nextcloud": ["nextcloud"],
"rustdesk": ["rustdesk"],
"kdeconnect": ["kdeconnect-app"],
"mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"]
"mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"],
"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]
};
const command = commands[id];
if (!command) {
@@ -9,32 +9,32 @@ ShellRoot {
target: "settings-pref-test"
function applyFixture(): void {
DesktopPreferences.use24Hour = true;
DesktopPreferences.showSeconds = false;
DesktopPreferences.dockAutohide = false;
DesktopPreferences.focusDurationMinutes = 70;
DesktopPreferences.autoHdr = false;
DesktopPreferences.vrrPolicy = 0;
DesktopPreferences.directScanoutPolicy = 0;
DesktopPreferences.nightLightEnabled = true;
DesktopPreferences.nightLightAutomatic = true;
DesktopPreferences.nightLightTemperature = 4100;
DesktopPreferences.lastPage = "desktop";
DesktopPreferences.set("use24Hour", true);
DesktopPreferences.set("showSeconds", false);
DesktopPreferences.set("dockAutohide", false);
DesktopPreferences.set("focusDurationMinutes", 70);
DesktopPreferences.set("autoHdr", false);
DesktopPreferences.set("vrrPolicy", 0);
DesktopPreferences.set("directScanoutPolicy", 0);
DesktopPreferences.set("nightLightEnabled", true);
DesktopPreferences.set("nightLightAutomatic", true);
DesktopPreferences.set("nightLightTemperature", 4100);
DesktopPreferences.set("lastPage", "desktop");
}
function status(): string {
return JSON.stringify({
use24Hour: DesktopPreferences.use24Hour,
showSeconds: DesktopPreferences.showSeconds,
dockAutohide: DesktopPreferences.dockAutohide,
focusDurationMinutes: DesktopPreferences.focusDurationMinutes,
autoHdr: DesktopPreferences.autoHdr,
vrrPolicy: DesktopPreferences.vrrPolicy,
directScanoutPolicy: DesktopPreferences.directScanoutPolicy,
nightLightEnabled: DesktopPreferences.nightLightEnabled,
nightLightAutomatic: DesktopPreferences.nightLightAutomatic,
nightLightTemperature: DesktopPreferences.nightLightTemperature,
lastPage: DesktopPreferences.lastPage,
use24Hour: DesktopPreferences.get("use24Hour"),
showSeconds: DesktopPreferences.get("showSeconds"),
dockAutohide: DesktopPreferences.get("dockAutohide"),
focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes"),
autoHdr: DesktopPreferences.get("autoHdr"),
vrrPolicy: DesktopPreferences.get("vrrPolicy"),
directScanoutPolicy: DesktopPreferences.get("directScanoutPolicy"),
nightLightEnabled: DesktopPreferences.get("nightLightEnabled"),
nightLightAutomatic: DesktopPreferences.get("nightLightAutomatic"),
nightLightTemperature: DesktopPreferences.get("nightLightTemperature"),
lastPage: DesktopPreferences.get("lastPage"),
stateDir: Quickshell.stateDir
});
}
@@ -11,10 +11,22 @@ ShellRoot {
function refresh(): void { SystemSettings.refresh(); }
// One batch, matching how the shell applies persisted policy. Three
// separate setters would be refused as overlapping writes, since each
// one is only complete after its value has been read back.
function apply(autoHdr: bool, vrr: int, directScanout: int): void {
SystemSettings.setAutoHdr(autoHdr);
SystemSettings.setVrrPolicy(vrr);
SystemSettings.setDirectScanoutPolicy(directScanout);
SystemSettings.applyOptions({
autoHdr: autoHdr,
vrrPolicy: vrr,
directScanoutPolicy: directScanout
});
}
// Applies an arbitrary batch of schema keys, so the contract can cover
// every getoption answer shape -- int, bool, float, str, and the css
// box that gaps read back as -- not just the display policies.
function applyJson(payload: string): bool {
return SystemSettings.applyOptions(JSON.parse(payload));
}
function panelAllowed(panel: string): bool {
+24 -4
View File
@@ -85,7 +85,7 @@ ShellRoot {
IntelligenceResult {}
ActivityPanel {}
PowerMenu {}
SettingsWindow {}
SettingsWindow { id: settingsWindow }
// Toasts are their own always-on layer; they must be able to appear
// without any overlay being open.
@@ -258,14 +258,27 @@ ShellRoot {
function fixture(name: string): void { HomeAssistant.applyFixture(name); }
function reset(): void { HomeAssistant.clearFixture(); }
function refresh(): void { HomeAssistant.refresh(); }
function brightness(id: string, percent: int): void { HomeAssistant.setBrightness(id, percent); }
function toggle(id: string): void { HomeAssistant.toggleEntity(id); }
function status(): string {
return JSON.stringify({
fixture: HomeAssistant.fixtureMode,
phase: HomeAssistant.phase,
discoveredCount: HomeAssistant.discoveredCount,
configuredCount: HomeAssistant.configuredCount,
visibleCount: HomeAssistant.visibleEntities.length,
selectedIds: HomeAssistant.selectedEntities.map(entity => entity.id),
entities: HomeAssistant.selectedEntities,
stale: HomeAssistant.stale,
busy: HomeAssistant.busyEntityId !== "",
busy: HomeAssistant.busyEntityIds.length > 0,
busyEntityIds: HomeAssistant.busyEntityIds,
pendingBrightness: HomeAssistant.pendingBrightness,
entityErrors: HomeAssistant.entityErrors,
actionProcessRunning: HomeAssistant.actionProcessRunning,
actionStreamFinished: HomeAssistant.actionStreamFinished,
fixtureTransitionDraining: HomeAssistant.fixtureTransitionDraining,
queuedActionCount: HomeAssistant.actionQueue.length,
actionActive: HomeAssistant.activeAction !== null,
lastError: HomeAssistant.lastError
});
}
@@ -273,12 +286,18 @@ ShellRoot {
IpcHandler {
target: "settings"
function open(): void { ShellState.openSettings(DesktopPreferences.lastPage || "home"); }
function open(): void { ShellState.openSettings(DesktopPreferences.get("lastPage") || "home"); }
function toggle(): void { ShellState.toggleSettings(); }
function close(): void { ShellState.closeSettings(); }
function page(name: string): void { ShellState.openSettings(name); }
function status(): string {
return JSON.stringify({ open: ShellState.settingsOpen, page: ShellState.settingsPage });
return JSON.stringify({
open: ShellState.settingsOpen,
page: ShellState.settingsPage,
discoveredCount: HomeAssistant.discoveredCount,
selectedCount: HomeAssistant.configuredCount,
homePhone: settingsWindow.homePhoneDiagnostics
});
}
}
@@ -293,6 +312,7 @@ ShellRoot {
autoHdr: SystemSettings.autoHdr,
vrrPolicy: SystemSettings.vrrPolicy,
directScanoutPolicy: SystemSettings.directScanoutPolicy,
bluebubblesAvailable: SystemSettings.bluebubblesAvailable,
busy: SystemSettings.busy,
lastError: SystemSettings.lastError
});
@@ -70,7 +70,7 @@
- Produces: `normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]`, `collect_catalog(config: Config) -> dict[str, object]`, `discovered_light_ids(config: Config) -> set[str]`, `toggle(config: Config, entity_id: str) -> dict[str, object]`, `set_brightness(config: Config, entity_id: str, percent: int) -> dict[str, object]`, and CLI commands `catalog`, `toggle ENTITY_ID`, `brightness ENTITY_ID PERCENT`.
- Produces catalog entities with exactly `id`, `sourceName`, `state`, `available`, `active`, `dimmable`, and `brightnessPct`; the top level also contains `legacyEntityIds` for one-time migration.
- [ ] **Step 1: Expand the fake Home Assistant and write failing catalog tests**
- [x] **Step 1: Expand the fake Home Assistant and write failing catalog tests**
Change the fake `/api/states` response to include an on dimmable light, an off dimmable light, a sensor, a malformed light, and an unavailable light:
@@ -105,7 +105,7 @@ Change the fake `/api/states` response to include an on dimmable light, an off d
Add assertions that `collect_catalog(self.config())` returns Kitchen, Hall, and Corner in source order; returns no sensor, malformed entity, or raw attribute key; rounds Kitchen brightness to 50; sets Hall brightness to 0; and reports all three as dimmable.
- [ ] **Step 2: Write failing action authorization and payload tests**
- [x] **Step 2: Write failing action authorization and payload tests**
Teach the fake POST handler to accept all three exact service routes and record bodies. Add these tests:
@@ -134,7 +134,7 @@ def test_brightness_zero_uses_turn_off(self) -> None:
Add table-driven validation for `-1`, `101`, `1.5`, and `bright`, expecting `invalid-brightness` before any POST. Keep the existing authentication-redaction and configuration-precedence tests.
- [ ] **Step 3: Run the focused unit suite and verify RED**
- [x] **Step 3: Run the focused unit suite and verify RED**
Run:
@@ -144,7 +144,7 @@ python3 tests/quickshell/home_assistant_bridge_test.py -v
Expected: failures identify missing `collect_catalog`, live-catalog authorization, and `set_brightness`; the existing tests remain green.
- [ ] **Step 4: Implement normalized catalog output**
- [x] **Step 4: Implement normalized catalog output**
Change `Config.configured` to require only `base_url` and `token`; `entity_ids` becomes migration metadata, not an operational requirement. Replace configured-only normalization with:
@@ -188,7 +188,7 @@ def normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]:
`collect_catalog()` must return `legacyEntityIds: list(config.entity_ids)` on both success and safe failures, while retaining the current `ok`, `configured`, `generatedAt`, `entities`, and redacted `error` envelope. Keep `snapshot` as a compatibility alias for this release, but make QML and live shape tests call `catalog`.
- [ ] **Step 5: Implement discovered-light authorization and brightness commands**
- [x] **Step 5: Implement discovered-light authorization and brightness commands**
Fetch `/api/states` immediately before every action, derive a set only from `normalize_catalog()`, and reject anything absent with `ValueError("entity-not-discovered")`. Validate the percentage as an ASCII integer string at the CLI boundary and as an integer in `set_brightness()`; reject booleans and values outside 0–100 with `ValueError("invalid-brightness")`.
@@ -223,7 +223,7 @@ def set_brightness(config: Config, entity_id: str, percent: int) -> dict[str, ob
Do not print the discovery response, request body, URL, or token on action failure.
- [ ] **Step 6: Make unit and live read-only contracts GREEN**
- [x] **Step 6: Make unit and live read-only contracts GREEN**
Update `home-assistant-helper-contract.sh` to call `catalog`, assert the exact seven entity keys, assert `legacyEntityIds` is an array, and report only the count:
@@ -247,7 +247,7 @@ tests/quickshell/home-assistant-helper-contract.sh
Expected: all unit tests PASS; the live contract prints a redacted light count and performs GET requests only.
- [ ] **Step 7: Commit the helper boundary**
- [x] **Step 7: Commit the helper boundary**
```bash
git add config/dot/quickshell/scripts/panama-home-assistant \
@@ -271,7 +271,7 @@ git commit -m "Add Home Assistant light catalog and dimming"
- Produces: `initialized: bool`, `favorites: var`, `saveError: string`, `initialize(legacyIds)`, `isSelected(entityId)`, `aliasFor(entityId, sourceName)`, `add(entityId)`, `remove(entityId)`, `setAlias(entityId, alias)`, `move(entityId, targetIndex)`, `retrySave()`, and state file `Quickshell.stateDir + "/panama-home.json"`.
- Produces favorite records with exactly `{ id: string, alias: string }`; every mutator assigns a cloned array so QML change notification and persistence are deterministic.
- [ ] **Step 1: Write the isolated persistence harness and failing restart contract**
- [x] **Step 1: Write the isolated persistence harness and failing restart contract**
Create an IPC harness with these methods:
@@ -302,7 +302,7 @@ The Bash contract must use a temporary `XDG_STATE_HOME`, initialize `light.kitch
Then remove both records, restart again, call `initialize` with a different legacy list, and assert the selection stays empty. Assert the JSON file contains only `initialized` and `favorites` keys and no strings matching `token`, `url`, or `api` case-insensitively.
- [ ] **Step 2: Run the contract and verify RED**
- [x] **Step 2: Run the contract and verify RED**
Run:
@@ -312,7 +312,7 @@ tests/quickshell/home-preferences-contract.sh
Expected: FAIL because the singleton and harness do not exist.
- [ ] **Step 3: Implement the atomic preference singleton**
- [x] **Step 3: Implement the atomic preference singleton**
Register `singleton HomePreferences 1.0 HomePreferences.qml`. Build `HomePreferences.qml` around this adapter:
@@ -339,7 +339,7 @@ FileView {
Use a 180 ms single-shot persistence timer. `initialize()` filters IDs through `^light\.[a-z0-9_]+$`, removes duplicates while preserving order, seeds only when `initialized === false`, then sets `initialized = true`. `setAlias()` trims with `String(value).trim()`. `move()` clamps the target index to `0..length - 1`. `retrySave()` directly invokes `preferencesFile.writeAdapter()`. On save failure, leave the mutated `favorites` array untouched so the user can retry without re-entering edits.
- [ ] **Step 4: Run the restart contract and inspect the private file shape**
- [x] **Step 4: Run the restart contract and inspect the private file shape**
Run:
@@ -349,7 +349,7 @@ tests/quickshell/home-preferences-contract.sh
Expected: PASS for initial migration, trim, reorder, remove, restart persistence, and initialized-empty behavior. The temporary file is valid JSON and contains no credential-like fields.
- [ ] **Step 5: Commit preference ownership**
- [x] **Step 5: Commit preference ownership**
```bash
git add config/dot/quickshell/config/HomePreferences.qml \
@@ -373,7 +373,7 @@ git commit -m "Persist Home accessory preferences"
- Produces: `catalog: var`, `selectedEntities: var`, `visibleEntities: var`, `discoveredCount: int`, `configuredCount: int`, `busyEntityIds: var`, `pendingBrightness: var`, `entityErrors: var`, `refresh()`, `toggleEntity(id)`, `setBrightness(id, percent)`, `isBusy(id)`, `pendingFor(id)`, `errorFor(id)`, and current phase/stale/open behavior.
- Produces selected entity objects with `id`, `sourceName`, `name`, `state`, `available`, `active`, `dimmable`, and `brightnessPct`; `name` is the trimmed Panama alias or `sourceName` fallback.
- [ ] **Step 1: Extend the fixture contract for ordering, missing entities, and local action state**
- [x] **Step 1: Extend the fixture contract for ordering, missing entities, and local action state**
Change the `ready` fixture assertion to require `discoveredCount == 7`, `configuredCount == 7`, `visibleCount == 4`, and ordered aliases. Add fixture-only IPC methods `brightness(id, percent)` and `toggle(id)`, then assert:
@@ -389,7 +389,7 @@ jq -e '.entities[] | select(.id == "light.fixture_living") | .active == true and
Add `missing-selected` and `action-error` fixtures. `missing-selected` retains one preferred ID absent from catalog as unavailable. `action-error` returns `entityErrors["light.fixture_kitchen"] == "request-failed"` while Hall has no error and the global phase is still ready.
- [ ] **Step 2: Run the service contract and verify RED**
- [x] **Step 2: Run the service contract and verify RED**
Run:
@@ -399,7 +399,7 @@ tests/quickshell/control-center-services-contract.sh
Expected: the new catalog counts, selected IDs, brightness fixture action, and per-entity errors are absent.
- [ ] **Step 3: Replace configured entities with catalog/preference resolution**
- [x] **Step 3: Replace configured entities with catalog/preference resolution**
Import `qs.config`. Change the refresh command to `[helperPath, "catalog"]`. On a successful live catalog:
@@ -429,7 +429,7 @@ root.lastError = "";
Connect to `HomePreferences.favoritesChanged` and rebuild immediately. In fixture mode, resolve against fixture-local favorite records instead of mutating or reading the user's durable list.
- [ ] **Step 4: Implement per-entity sequential actions**
- [x] **Step 4: Implement per-entity sequential actions**
Replace the one global busy ID with a queue and cloned maps:
@@ -452,7 +452,7 @@ function setBrightness(entityId: string, percent: int): void {
Each accepted action adds only its entity ID to `busyEntityIds`; brightness also stores the requested percentage in `pendingBrightness`. One `Process` runs queue entries in order, allowing unrelated tiles to enqueue without a global disable. Completion removes only that entity's busy/pending fields. Success clears only that entity's error and starts the existing 350 ms catalog refresh. Failure leaves catalog/phase intact, removes the preview so the UI snaps to confirmed brightness, and writes the safe code only to `entityErrors[entityId]`. A new action on an entity clears its previous inline error.
- [ ] **Step 5: Expand fixtures and diagnostics without touching durable preferences**
- [x] **Step 5: Expand fixtures and diagnostics without touching durable preferences**
Fixture entities must include brightness percentages, dimmable flags, aliases, one off light, and one unavailable light. Add status fields:
@@ -475,7 +475,7 @@ return JSON.stringify({
Fixture actions update only in-memory fixture catalog. `clearFixture()` clears fixture action state and returns to a live `catalog` refresh; it never changes `HomePreferences`.
- [ ] **Step 6: Run service and preference regression contracts**
- [x] **Step 6: Run service and preference regression contracts**
Run:
@@ -486,7 +486,7 @@ tests/quickshell/control-center-services-contract.sh
Expected: both PASS; fixture cleanup returns live mode and the durable preference contract remains unchanged.
- [ ] **Step 7: Commit service composition**
- [x] **Step 7: Commit service composition**
```bash
git add config/dot/quickshell/services/HomeAssistant.qml \
@@ -518,7 +518,7 @@ git commit -m "Compose Home catalog with accessory preferences"
- `HomeFavoriteCard` consumes `favorite`, `sourceName`, `index`, `featured`; emits `aliasCommitted(id, alias)`, `removeRequested(id)`, and `moveRequested(id, targetIndex)`.
- `AvailableLightRow` consumes `entity`; emits `addRequested(id)`.
- [ ] **Step 1: Write failing route, component, and privacy-safe page contracts**
- [x] **Step 1: Write failing route, component, and privacy-safe page contracts**
Add `home-phone` to the route array immediately after `connectivity`. The focused page contract must assert:
@@ -534,7 +534,7 @@ rg -Fq 'HomePreferences.retrySave' config/dot/quickshell/modules/settings/HomePh
Using the ready fixture, route Settings to `home-phone` and assert one tiled `Panama Settings` client. IPC status must report `page == "home-phone"`, `discoveredCount == 7`, and `selectedCount == 7`. The test may read fixture state but must not call preference mutators.
- [ ] **Step 2: Run the settings contracts and verify RED**
- [x] **Step 2: Run the settings contracts and verify RED**
Run:
@@ -545,7 +545,7 @@ tests/quickshell/home-phone-settings-contract.sh
Expected: `home-phone` falls back to Home and the new components are missing.
- [ ] **Step 3: Add BlueBubbles availability and fixed launch mapping**
- [x] **Step 3: Add BlueBubbles availability and fixed launch mapping**
Add a dedicated probe process and expose its mutable result through a read-only public property:
@@ -568,7 +568,7 @@ Start it from `refresh()` when not running. Extend `openApplication()` with exac
Expose `bluebubblesAvailable` through the existing `settings-system` IPC status for read-only tests. Do not derive this property from KDE Connect and do not construct a shell command string.
- [ ] **Step 4: Build the page shell and Home Assistant health card**
- [x] **Step 4: Build the page shell and Home Assistant health card**
Create a `Flickable` page matching existing 34 px horizontal/30 px top insets. Use title `Home & Phone` and subtitle `Choose what appears in Control Center and keep phone continuity close at hand.` The Home Assistant card shows:
@@ -578,7 +578,7 @@ Create a `Flickable` page matching existing 34 px horizontal/30 px top insets. U
- `Home Assistant is not configured` for `not-configured`;
- Refresh and Open buttons wired only to `HomeAssistant.refresh()` and `HomeAssistant.open()`.
- [ ] **Step 5: Build selected cards with alias editing and drag reordering**
- [x] **Step 5: Build selected cards with alias editing and drag reordering**
Render `HomeAssistant.selectedEntities` in a two-column `GridView`. Each `HomeFavoriteCard` shows alias in a `TextInput`, source name beneath it, a quiet `Control Center` badge for indexes 0–3, a remove button, and a six-dot drag handle.
@@ -592,7 +592,7 @@ root.moveRequested(favorite.id, Math.min(modelCount - 1, row * 2 + column));
Commit alias on editing finished, not on every keystroke. Preserve duplicate aliases. An empty trimmed alias displays the current source name in Control Center.
- [ ] **Step 6: Build searchable Available lights and persistence failure state**
- [x] **Step 6: Build searchable Available lights and persistence failure state**
Define:
@@ -609,7 +609,7 @@ The Available lights card contains a search field and rows showing source name,
When `HomePreferences.saveError` is non-empty, show one inline amber row with the exact safe message and a Retry button calling `retrySave()`. Do not route this error through global `SystemSettings.lastError`.
- [ ] **Step 7: Add Phone continuity and route registration**
- [x] **Step 7: Add Phone continuity and route registration**
Add a final compact card showing Messages, `Opens BlueBubbles`, installed/unavailable status, and an Open button enabled only by `SystemSettings.bluebubblesAvailable`. Wire the sidebar destination between connectivity and desktop, add the loader component and qmldir registrations, and allow `home-phone` in `ShellState.openSettings()`.
@@ -624,7 +624,7 @@ Extend settings diagnostics with read-only counts:
}
```
- [ ] **Step 8: Run Settings, system, and persistence contracts**
- [x] **Step 8: Run Settings, system, and persistence contracts**
Run:
@@ -637,7 +637,7 @@ tests/quickshell/home-preferences-contract.sh
Expected: all PASS; Panama Settings remains one normal tiled client, fixture tests leave real Home preferences unchanged, and no BlueBubbles process starts.
- [ ] **Step 9: Commit the management UI**
- [x] **Step 9: Commit the management UI**
```bash
git add config/dot/quickshell/services/SystemSettings.qml \
@@ -670,7 +670,7 @@ git commit -m "Add Home and Phone settings"
- Produces: `HomeBrightnessSlider.value: int`, `enabled: bool`, `previewChanged(int)`, and `committed(int)`; `HomeTile` emits `powerRequested` and `brightnessRequested(int)`.
- Produces a two-column resting and expanded shelf with slider hit areas that do not bubble power clicks.
- [ ] **Step 1: Write failing structure and fixture interaction assertions**
- [x] **Step 1: Write failing structure and fixture interaction assertions**
Require `HomeBrightnessSlider.qml` and its qmldir entry. Static assertions must prove:
@@ -684,7 +684,7 @@ rg -Fq 'ShellState.openSettings("home-phone")' config/dot/quickshell/modules/qui
Keep the live panel mapping and exclusive expansion assertions. Add ready-fixture status assertions that resting count is four and expanded selected count is seven.
- [ ] **Step 2: Run the Control Center contract and verify RED**
- [x] **Step 2: Run the Control Center contract and verify RED**
Run:
@@ -694,13 +694,13 @@ tests/quickshell/control-center-contract.sh
Expected: the two-column shelf, slider, and Home & Phone management route are absent.
- [ ] **Step 3: Implement a slider with local preview and one commit**
- [x] **Step 3: Implement a slider with local preview and one commit**
Create a focused slider instead of changing shared `ValueSlider.qml`. It accepts integer 0–100 and keeps `previewValue` local while pressed. Pointer press/move emits `previewChanged(previewValue)`; pointer release emits `committed(previewValue)` once. Wheel steps by 5 and commits once per wheel event. External value changes update preview only when not pressed.
Use a 10 px rounded track, amber-to-warm Prism fill, 16 px light knob, and a 16 px effective vertical hit expansion. Expose the hit area only inside the slider component so tile power clicks cannot intercept dimming.
- [ ] **Step 4: Rebuild `HomeTile` as the approved larger accessory control**
- [x] **Step 4: Rebuild `HomeTile` as the approved larger accessory control**
Use a 124 px minimum height, 14 px radius, 13 px insets, active amber glass, and quiet inactive glass. Layout:
@@ -711,7 +711,7 @@ Use a 124 px minimum height, 14 px radius, 13 px insets, active amber glass, and
The tile body and bulb call `powerRequested()` only when available and not busy. The slider remains enabled only when available, dimmable, and not busy. While dragging, percentage text uses local preview. On failure the service removes pending state, causing the slider and text to bind back to confirmed `entity.brightnessPct`.
- [ ] **Step 5: Rebuild `HomeControls` resting, expanded, and setup states**
- [x] **Step 5: Rebuild `HomeControls` resting, expanded, and setup states**
Resting state uses `Grid { columns: 2; columnSpacing: 8; rowSpacing: 8 }` over `visibleEntities`. Expanded state uses the same two-column tile language over every `selectedEntities` item inside its existing `Section`/scroll boundary. Wire each delegate:
@@ -728,7 +728,7 @@ HomeTile {
At the expanded list footer add `Manage in Settings`, which closes Control Center and opens `home-phone`. If the initialized selection is empty, replace the shelf with one setup row opening `home-phone`. Preserve loading, authentication, not-configured, stale, Retry, and Open Home Assistant states, but base selected counts on `configuredCount` and discovery copy on `discoveredCount`.
- [ ] **Step 6: Run service and Control Center contracts**
- [x] **Step 6: Run service and Control Center contracts**
Run:
@@ -739,7 +739,7 @@ tests/quickshell/control-center-contract.sh
Expected: PASS. Opening and expanding Home maps one panel, displays the fixture shelf, and does not invoke the real Home Assistant API.
- [ ] **Step 7: Commit the accessory shelf**
- [x] **Step 7: Commit the accessory shelf**
```bash
git add config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml \
@@ -763,13 +763,13 @@ git commit -m "Build the Home accessory shelf"
- Consumes: `SystemSettings.bluebubblesAvailable`, `SystemSettings.openApplication("bluebubbles")`, and existing KDE Connect capability/reachability/action interfaces.
- Produces: four equal action models where `share`, `clipboard`, and `ring` use KDE Connect support/reachability, while `messages` uses only BlueBubbles availability.
- [ ] **Step 1: Write the failing independent-enable contract**
- [x] **Step 1: Write the failing independent-enable contract**
The static contract must require a Messages model, exact application ID invocation, and an enable expression independent of `KdeConnect.phoneReachable`. It must also assert the fixed command array exists in `SystemSettings.qml`. The live read-only portion calls only `settings-system status` and requires `bluebubblesAvailable == true` on this workstation.
Add a guard that fails if the script contains `openApplication` in an executed `qs ipc call`; the test must never launch the client.
- [ ] **Step 2: Run the contract and verify RED**
- [x] **Step 2: Run the contract and verify RED**
Run:
@@ -779,7 +779,7 @@ tests/quickshell/phone-messages-contract.sh
Expected: FAIL because PhoneControls still has three KDE-only actions.
- [ ] **Step 3: Split action capability from action enablement**
- [x] **Step 3: Split action capability from action enablement**
Build four fixed models:
@@ -794,11 +794,11 @@ readonly property var actionModels: [
Render four equal columns even if a KDE plugin is unavailable; unavailable actions remain visible but disabled so the card does not jump. `invoke("messages")` calls only `SystemSettings.openApplication("bluebubbles")` and closes the Control Center on successful handoff. Keep file dialog, clipboard, and ring paths unchanged.
- [ ] **Step 4: Add missing-client copy to Phone details**
- [x] **Step 4: Add missing-client copy to Phone details**
When BlueBubbles is absent, append one quiet detail row: `BlueBubbles is not installed` with no action. Keep `Actions become available when the iPhone reconnects` scoped to the three KDE actions so it does not imply Messages requires proximity.
- [ ] **Step 5: Run phone and Control Center contracts**
- [x] **Step 5: Run phone and Control Center contracts**
Run:
@@ -810,7 +810,7 @@ tests/quickshell/control-center-services-contract.sh
Expected: PASS; the offline KDE fixture still exposes an enabled Messages action when BlueBubbles is installed, and no client launches during testing.
- [ ] **Step 6: Commit Messages integration**
- [x] **Step 6: Commit Messages integration**
```bash
git add config/dot/quickshell/modules/quicksettings/PhoneControls.qml \
@@ -832,7 +832,7 @@ git commit -m "Add BlueBubbles to Phone controls"
- Consumes: completed helper, preferences, service, Settings, Accessory Shelf, and BlueBubbles integration.
- Produces: user-facing operating notes, a clean live shell, visual evidence for all required states, a final verification commit, and synchronized `origin/main`.
- [ ] **Step 1: Update durable user documentation**
- [x] **Step 1: Update durable user documentation**
Document:
@@ -844,7 +844,7 @@ Document:
Do not include entity IDs, friendly names, URLs, tokens, or the contents of the user's preference file.
- [ ] **Step 2: Run focused automated verification**
- [x] **Step 2: Run focused automated verification**
Run:
@@ -862,7 +862,7 @@ tests/quickshell/settings-pages-contract.sh
Expected: every command exits 0. The helper contract performs read-only GETs; fixtures perform no real light action; BlueBubbles remains closed unless it was already open.
- [ ] **Step 3: Run the complete Quickshell regression suite**
- [x] **Step 3: Run the complete Quickshell regression suite**
Run:
@@ -875,7 +875,7 @@ done
Expected: every contract prints PASS and exits 0. If a test opens a panel, its trap closes it and restores live service mode.
- [ ] **Step 4: Validate the live shell and logs**
- [x] **Step 4: Validate the live shell and logs**
Run:
@@ -888,7 +888,7 @@ qs log -n -t 300 --no-color | rg -i 'qml|homeassistant|homepreferences|bluebubbl
Expected: Hyprland reports success; Quickshell remains running; there are no new QML construction errors, binding loops, uncaught JavaScript exceptions, credential strings, or rapid process respawns. Investigate any warning produced by changed components before proceeding.
- [ ] **Step 5: Capture and inspect approved visual states**
- [x] **Step 5: Capture and inspect approved visual states**
Create `/tmp/panama-home-verification` and capture full-screen PNGs after each fixture/routing setup:
@@ -913,7 +913,7 @@ Reset fixtures and leave the interfaces open for the user. Ask the user to perfo
Do not perform those actions on the user's behalf.
- [ ] **Step 7: Commit documentation and plan completion**
- [x] **Step 7: Commit documentation and plan completion**
```bash
git add config/dot/hypr/README.md \
@@ -0,0 +1,240 @@
# Panama Cohesion Implementation Plan
**Goal:** Make Panama one product instead of several good parts. A user changes
how their desktop looks and behaves entirely from Panama Settings; the Lua config
holds the shipped defaults; one JSON file is the truth both sides read.
**Spec:** `docs/superpowers/specs/2026-08-17-panama-cohesion-design.md`
**Tech Stack:** Quickshell 0.3.0, Qt 6 QML, Hyprland 0.56.2 (Lua config),
`hyprctl eval`, Bash contract tests.
## Global constraints
- `hyprctl keyword` is banned. It exits 0 without acting on this build.
- Verify every write by reading the value back (`hyprctl getoption`), never by
trusting an exit code.
- No UI-supplied string is interpolated into `eval`, a shell command, or a config
value. Numbers range-checked, choices allow-listed, colours hex-validated.
- A missing or malformed `settings.json` degrades to shipped defaults. The Lua
read is `pcall`-wrapped so it can never take down the compositor config.
- Tokyo Night Moon and Prism stay the only identity; customisation adjusts its
parameters, it does not replace it.
- Motion stays event-driven at every setting. No idle repaint.
- Do not restart the running Quickshell process during development; it hot-reloads.
- Keep unrelated in-flight Panama work untouched (see the
`feat/home-accessories-customization` worktree).
---
## Stage 0 — Stop the lying (ship first, standalone)
The display-policy toggles report success while doing nothing. This is a
correctness bug in shipped behaviour and does not depend on any of the
architecture below.
**Files:** Modify `config/dot/quickshell/services/SystemSettings.qml`;
Test `tests/quickshell/settings-hyprland-write-contract.sh`
- [x] Write a contract that sets a display policy through `SystemSettings`, then
asserts via `hyprctl getoption` that the compositor value actually changed —
and that a rejected write leaves `lastError` non-empty.
- [x] Run it; confirm it fails against the current `hyprctl keyword` implementation.
- [x] Add a single `applyOptions(values)` boundary that serialises validated values
into `hl.config{}` and runs them through `hyprctl eval`.
- [x] Route `setAutoHdr`, `setVrrPolicy`, and `setDirectScanoutPolicy` through it.
- [x] Treat "wrote it back and read it back equal" as the only success condition;
persist to preferences only on verified success.
- [x] Run the contract to green, and confirm live that HDR/VRR/scanout change.
**Exit criteria:** the three toggles do what they claim, and a failed write says so.
**Landed.** `hyprctl eval` also exits 0 on syntax and runtime errors — it reports
them as an `error:` line on stdout — so exit status is useless for both commands.
`applyOptions` therefore parses stdout for the error line *and* reads every
written option back with `hyprctl -j --batch getoption`, committing to
preferences only for options that read back equal. A `writableOptions` registry
holds the group/key/option path and allow-list per option, so the UI never names
an option or supplies an unchecked value. Policy is applied as one batch, so the
shell cannot come up half-configured.
New: `tests/quickshell/settings-hyprland-write-contract.sh` — flips each policy
to a value it does not hold and reads it back, so a no-op write cannot pass.
The pre-existing `settings-system-contract.sh` re-applied the values already in
place, which is why it passed throughout the outage.
---
## Stage 1 — One schema, one store
**Files:** Create `config/dot/quickshell/config/PreferenceSchema.qml`;
Modify `config/dot/quickshell/config/DesktopPreferences.qml`,
`config/dot/quickshell/config/Settings.qml`;
Test `tests/quickshell/preference-schema-contract.sh`
Schema entry shape:
```qml
{ key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
group: "dock", label: "Hide delay",
detail: "Prevents flicker when crossing icons" }
```
- [x] Write a contract asserting: every schema key round-trips through disk;
an out-of-range value is clamped rather than stored; an unknown key in the
file is preserved rather than dropped; and `reset()` returns *every* key to
its schema default with no hand-maintained list.
- [x] Run it; confirm it fails.
- [x] Author `PreferenceSchema.qml` covering the existing 16 user-facing keys.
- [x] Rewrite `DesktopPreferences` to derive persistence, change notification,
validation, and reset from the schema — deleting the four-places-per-key
boilerplate and `resetDesktopDefaults()`'s hand-written body.
- [x] Move the store to `~/.config/panama/settings.json`, migrating the existing
file from `Quickshell.stateDir` on first run if present.
- [x] Keep `Settings.qml` as the stable public read surface; existing consumers
must not change.
- [x] Run the new contract plus `settings-preferences-contract.sh` and
`settings-pages-contract.sh` to green.
**Exit criteria:** adding a setting is one schema line; reset is complete by
construction; the store lives at a stable, user-visible path.
**Landed.** Reads go through `DesktopPreferences.get(key)` and writes through
`set(key, value)`; a `revision` counter gives function-call bindings something to
invalidate, which a bare call would not have. `Settings.qml` stays the typed
public surface — every consumer outside it was already reading through it.
`set()` returns false on an unknown key or an unrepresentable value, so a
rejection is observable instead of inferred.
Unknown keys on disk are carried through writes untouched, so rolling back to an
older Panama does not discard a newer version's settings. A corrupt file falls
back to shipped defaults rather than costing the user a working desktop. Both are
pinned by contract.
Migration reads the old `Quickshell.stateDir` file only when the new one is
absent, and never deletes the original. Verified live: the running shell adopted
all 17 values into `~/.config/panama/settings.json` with the legacy files intact.
New: `config/PreferenceSchema.qml`, `preference-schema-harness.qml`,
`tests/quickshell/preference-schema-contract.sh`. Full suite green — 6 settings
contracts, 22 other Quickshell contracts, 3 Python bridge tests.
---
## Stage 2 — Hyprland reads the same file
**Files:** Create `config/dot/hypr/prefs.lua`;
Modify `config/dot/hypr/hyprland.lua`, `looks.lua`, `input.lua`, `monitors.lua`;
Test `tests/hypr/prefs-fallback-contract.sh`
- [x] Write a contract that verifies `Hyprland --verify-config` passes with the
file absent, empty, truncated mid-object, and containing wrong-typed values —
and that each case yields the shipped default.
- [x] Run it; confirm it fails (no `prefs.lua` yet).
- [x] Implement a dependency-free JSON reader exposing `prefs.get(key, fallback)`,
`pcall`-wrapped, reading `$XDG_CONFIG_HOME/panama/settings.json`.
- [x] Require it first in `hyprland.lua`, before `looks`.
- [x] Convert the appearance and behaviour literals in `looks.lua` and `input.lua`
to `prefs.get("<key>", <current literal>)`, keeping every current value as
the fallback so shipped behaviour is byte-identical.
- [x] Extend `PreferenceSchema.qml` with the Hyprland-owned keys, each carrying
the `hl.config` path it maps to.
- [x] Have `SystemSettings` derive its `eval` payload from that mapping, so a new
Hyprland setting needs no new writer code.
- [x] Run the contract, `Hyprland --verify-config`, and a live reload to green.
**Exit criteria:** one file is the truth; the Lua is the default; Settings is the
editor; changes apply live *and* survive a reboot.
**Landed.** The compositor-adjustable surface went from 3 keys to 22.
`SystemSettings` no longer names any option: it walks `PreferenceSchema.hyprEntries()`,
builds one nested `hl.config{}` payload from each entry's table path, and verifies
against the entry's `option` path. Adding a live-adjustable Hyprland setting is now
a schema entry plus a `prefs.get` call, with no new writer code.
Verification had to learn the compositor's answer shapes: `getoption` returns the
value in a different field per type — `int`, `bool`, `float`, `str`, and `css` for
gaps, which read back as a four-value box (`"10 10 10 10"`). A verifier that only
understood `int` would have reported every other type as rejected. All five are
covered by contract.
`keyboardLayout` is the first setting whose value reaches an `hl.config` string,
so the schema gained a `pattern` field enforced in `coerce()`. The contract
includes a Lua-injection attempt through it; the value is rejected, nothing
executes, and the layout is unchanged.
Two test-hygiene bugs found and fixed along the way, both pre-existing in shape:
`settings-window-contract` leaves a Settings window that the compositor destroys
asynchronously, which made `settings-pages-contract` see a duplicate when run
straight after it — the pages contract now waits for a clean slate. And the new
write contract was persisting its deliberately-wrong values into the *real*
`~/.config/panama/settings.json`, where the next `hyprctl reload` would faithfully
apply them; it now runs against an isolated `XDG_CONFIG_HOME` while still driving
the live compositor.
Verified live: writing `gapsOut`/`windowRounding` into the shared file and running
`hyprctl reload` — the path a fresh login takes — applied both, and the compositor
and store agree on every key. Full suite green: 29 shell contracts, 3 Python
bridge tests, run sequentially.
---
## Stage 3 — Generic rows, then fill the pages
Per the visual-work rule, this stage stops for a decision before any page is
rewritten.
**Files:** Create `modules/settings/SettingsPage.qml`, `ToggleRow.qml`,
`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
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
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
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.
**Exit criteria:** no shipped behaviour value is reachable only by editing a file.
---
## Stage 4 — Shortcuts from the compositor
**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`
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.
- [ ] 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.
**Exit criteria:** the page shows every real bind, always current, and can change them.
---
## Sequencing note
Stage 0 is independent — ship it alone. Stages 1 and 2 are the architecture and
should land together, since Stage 2 is what makes Stage 1 worth doing. Stage 3
is the largest and is gated on a visual decision. Stage 4 is independent of 3 and
can run in parallel with it.
Nothing here is committed yet; `main` has 20+ uncommitted paths from prior work
that should get a restore point before Stage 1 begins.
@@ -0,0 +1,242 @@
# Panama Cohesion Design
## Purpose
Panama has grown from a Hyprland config into a desktop: 15 Lua/conf files, 130 QML
files, 18 services, a 12-page settings application, and 29 contract tests. Each
feature was built well on its own. What is missing is the seam between them.
This document audits the current state and defines the architecture that turns
the pieces into one product, with a single goal:
> **A user should never need a text editor to change how their desktop behaves.**
That goal is not currently met, and the reason is structural rather than a matter
of missing pages.
---
## Part 1 — Audit
### Finding 1 (critical, live bug): every Hyprland write from Settings is a no-op
`services/SystemSettings.qml` applies display policy with `hyprctl keyword`:
```qml
autoHdrWrite.exec(["hyprctl", "keyword", "render:cm_auto_hdr", enabled ? "1" : "0"]);
```
On a Lua-configured Hyprland, `hyprctl keyword` does not work:
```
$ hyprctl getoption decoration:rounding -j → "int": 18
$ hyprctl keyword decoration:rounding 4
keyword can't work with non-legacy parsers. Use eval.
$ echo $? → 0
$ hyprctl getoption decoration:rounding -j → "int": 18
```
It prints the refusal to **stdout** and exits **0**. `SystemSettings` branches on
`exitCode === 0`, so all three writers take the success path: they persist the
requested value to `panama-settings.json`, clear `lastError`, and the UI redraws
as if the change took effect. Nothing reached the compositor.
Game-aware HDR, VRR policy, and direct scanout have therefore never worked from
Settings, and the app confidently reports that they did. `applyPersistedDisplayPolicy()`
replays the same three no-ops one second after every shell start.
The correct mechanism on this build is `hyprctl eval`, which is verified working:
```
$ hyprctl eval 'hl.config({ decoration = { rounding = 4 } })' → ok
$ hyprctl getoption decoration:rounding -j → "int": 4
```
`eval` is also strictly more capable than `keyword` — it can set *any* config
value, including gradients, animation curves, and nested tables. It executes
arbitrary Lua, so the existing "never interpolate UI text into a command" rule
must extend to it: values are validated and serialised numerically, never
concatenated from user input.
### Finding 2: three disconnected sources of truth for the same three values
| Value | `looks.lua` | `DesktopPreferences.qml` | Applied by |
| --- | --- | --- | --- |
| `render:cm_auto_hdr` | `1` | `autoHdr: true` | `hyprctl keyword` (no-op) |
| `misc:vrr` | `3` | `vrrPolicy: 3` | `hyprctl keyword` (no-op) |
| `render:direct_scanout` | `2` | `directScanoutPolicy: 2` | `hyprctl keyword` (no-op) |
They agree today only because they were typed to agree. Editing the Lua does not
change what Settings displays; changing Settings does not touch the Lua. The Lua
side reads no shared state whatsoever — there is no bridge in either direction.
### Finding 3: the preference store costs four hand-edits per knob
Every key in `config/DesktopPreferences.qml` is written out four times: a
`property alias`, a `JsonAdapter` property, a `Connections` handler, and a line
in `resetDesktopDefaults()`. Seventeen keys produce 68 lines of pure bookkeeping.
The failure modes are silent. Omit the `Connections` handler and the setting
stops persisting with no error. Omit the reset line and "Restore defaults"
quietly skips it. This cost is the direct reason the settings app stalled at 16
user-facing knobs.
### Finding 4: ~40 comparable values are hardcoded one file away
`config/Settings.qml` still hardcodes, as `readonly`: weather latitude/longitude,
location label, temperature unit and refresh interval; the vitals poll interval
and the GPU sysfs path; the night-light schedule (`17.0`–`10.0`); all four
notification timing and history limits; the 16-entry dock pin list; the
screenshot and recording directories; and the `wf-recorder` argument string.
Every one of these is exactly the kind of thing the settings app exists for. None
is reachable from it.
### Finding 5: appearance is not adjustable at all
`Theme.qml` defines ~50 tokens, all `readonly`, none mutable. The Appearance
page's "Theme" card is two dead text rows:
```qml
SettingRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
SettingRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System" }
```
Meanwhile `looks.lua` hardcodes gaps, border size, rounding, blur, shadow, glow,
and fourteen animation curves. The page named "Appearance" adjusts a clock format
and three vitals toggles.
### Finding 6: Shortcuts is a hand-typed copy of 19 of 113 real binds
`keybinds.lua` makes 95 `hl.bind` calls producing **113** live binds. The page
hardcodes an array of **19**. It cannot show the other 94, cannot change any, and
drifts the moment a bind is edited.
The compositor already exposes the real list, and it is 74% self-describing:
```
$ hyprctl binds -j → 113 binds, 84 carrying a human description
modmask 64 key T description "Terminal"
```
### Finding 7: "Restore defaults" is incomplete by construction
`resetDesktopDefaults()` resets `DesktopPreferences` only. Anything persisted
elsewhere survives an action that claims to restore Panama's defaults.
### Finding 8: page scaffolding is copy-pasted eleven times
Every page repeats the same `Flickable` → `Column` → `x: 34` → `y: 30` →
title/subtitle block. Every toggle repeats a four-property inline anchor
incantation:
```qml
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter;
checked: DesktopPreferences.showCpu; onToggled: value => DesktopPreferences.showCpu = value }
```
Across eleven pages there are 56 rows but only ~15 toggles, 14 buttons, and 3
sliders — over half the rows are static text. Pages settled for read-only text
because a real control was expensive to add. That is a tooling problem wearing a
product problem's clothes.
### What is genuinely good and must be preserved
- The `SystemSettings` allow-list discipline — UI never builds a command string.
- The Prism design language and its restraint, documented in `Theme.qml`.
- Event-driven motion; nothing repaints while idle.
- The 29 contract tests and the spec → plan → implement workflow.
- Delegation of hardware, accounts, and printers to GNOME rather than
half-reimplementing them.
---
## Part 2 — Target architecture
Four changes, in dependency order. Each is independently useful and independently
shippable.
### A. One schema, one store
Replace the hand-maintained preference object with a declarative schema —
one entry per setting carrying key, type, default, bounds or options, group,
label, and detail:
```qml
{ key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
group: "dock", label: "Hide delay",
detail: "Prevents flicker when crossing icons" }
```
Persistence, change notification, validation, reset, and the settings UI all
derive from that one entry. Adding a knob becomes one line instead of four edits
plus a hand-built row, and reset becomes complete by construction rather than by
diligence.
The store moves from Quickshell's opaque per-shell state directory to
`~/.config/panama/settings.json`, so it is a stable path that the compositor can
also read, and one a user can back up, diff, or put in a dotfiles repo.
### B. Hyprland reads the same file
`config/dot/hypr/prefs.lua` gains a small dependency-free JSON reader and exposes
`prefs.get(key, fallback)`. `looks.lua`, `input.lua`, and `monitors.lua` read
through it, keeping their current literals as the fallback:
```lua
rounding = prefs.get("windowRounding", 18),
gaps_out = prefs.get("gapsOut", 10),
```
A missing, empty, or malformed file yields the shipped defaults. The read is
wrapped in `pcall` so a corrupt file can never take down the config.
This closes the loop:
- **Lua is the default.** It ships the curated values and works standalone.
- **The JSON is the truth.** Both sides read it.
- **Settings is the editor.** It writes the JSON *and* applies live via
`hyprctl eval`, so changes take effect immediately and survive a reboot.
### C. Generic rows, then fill the pages
Add `SettingsPage` (the repeated scaffold), plus `ToggleRow`, `SliderRow`,
`ChoiceRow`, `ActionRow`, and `TextRow`. Rewrite the eleven pages on top of them,
promote the ~40 hardcoded `Settings.qml` values into real controls, and give
Appearance genuine content: accent pair, window rounding, gaps, border size,
blur, animation speed, bar height, font scale, and wallpaper.
Appearance customisation stays inside the design language. The user picks how much
of it there is — spacing, softness, motion — not a free-form palette editor that
would let the Prism identity be dismantled by accident.
### D. Shortcuts from the compositor, then editable
Generate the Shortcuts page from `hyprctl binds -j` so it shows all 113 binds and
can never drift. Backfill descriptions for the 29 binds that lack one. Then allow
rebinding: overrides live in the same JSON, `keybinds.lua` applies them after the
defaults, and Settings applies them live with `hyprctl eval`.
---
## Constraints
- No UI-supplied string is ever interpolated into an `eval`, a shell command, or
a config value. Numbers are range-checked; choices are matched against an
allow-list; colours are validated as hex before serialisation.
- A malformed or absent `settings.json` must degrade to shipped defaults, never
to a broken compositor.
- `hyprctl keyword` is banned in this codebase. It exits 0 without acting.
- Every write path must be observably verified — read the value back rather than
trusting an exit code. Finding 1 exists because an exit code was trusted.
- Tokyo Night Moon and Prism remain the only visual identity. Customisation
adjusts its parameters, it does not replace it.
- Motion stays event-driven. No idle repaint, at any setting.
- GNOME keeps ownership of hardware, accounts, printers, and users.
## Out of scope
- Arbitrary theme/palette import.
- A global menu (previously investigated; GTK apps expose `org.gtk.Actions` but
not `org.gtk.Menus`, so coverage would be too inconsistent to ship).
- Replacing any GNOME-delegated panel.
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# config/dot/hypr/prefs.lua is read at config time by looks.lua, input.lua, and
# monitors.lua. It is therefore the one piece of Panama that can cost the user a
# working compositor rather than merely a working feature.
#
# This contract pins the only behaviour that matters: whatever is in the
# settings file -- including nothing, garbage, or values of the wrong type --
# the config still parses, and every setting either takes the stored value or
# falls back to the value shipped in the Lua.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
hypr_dir="$repo_dir/config/dot/hypr"
work="$(mktemp -d /tmp/panama-prefs-contract.XXXXXX)"
fail() {
printf 'prefs fallback contract: %s\n' "$1" >&2
exit 1
}
cleanup() {
rm -rf "$work"
}
trap cleanup EXIT
# Exercise prefs.lua directly rather than through a full compositor launch: the
# question is what the module returns, and Hyprland's own parse is covered by
# the --verify-config case at the end.
probe() {
local settings="$1"
mkdir -p "$work/config/panama"
if [[ "$settings" == "__absent__" ]]; then
rm -f "$work/config/panama/settings.json"
else
printf '%s' "$settings" >"$work/config/panama/settings.json"
fi
XDG_CONFIG_HOME="$work/config" lua -e "
package.path = '$hypr_dir/?.lua;' .. package.path
local ok, prefs = pcall(require, 'prefs')
if not ok then
print('LOAD_ERROR ' .. tostring(prefs))
os.exit(0)
end
print(string.format(
'gapsOut=%s rounding=%s blur=%s layout=%s hdr=%s',
tostring(prefs.get('gapsOut', 10)),
tostring(prefs.get('windowRounding', 18)),
tostring(prefs.get('blurEnabled', true)),
tostring(prefs.get('keyboardLayout', 'us')),
tostring(prefs.getInt('autoHdr', 1))))
" 2>&1
}
shipped='gapsOut=10 rounding=18 blur=true layout=us hdr=1'
# ── No file at all: the normal first run ─────────────────────────────────────
result="$(probe '__absent__')"
[[ "$result" == "$shipped" ]] || fail "an absent settings file did not yield shipped defaults: $result"
# ── Empty file ───────────────────────────────────────────────────────────────
result="$(probe '')"
[[ "$result" == "$shipped" ]] || fail "an empty settings file did not yield shipped defaults: $result"
# ── Whitespace only ──────────────────────────────────────────────────────────
result="$(probe '
')"
[[ "$result" == "$shipped" ]] || fail "a whitespace-only file did not yield shipped defaults: $result"
# ── Truncated mid-object, the shape a crashed write leaves behind ────────────
result="$(probe '{ "gapsOut": 24, "windowRounding":')"
[[ "$result" == "$shipped" ]] || fail "a truncated file did not yield shipped defaults: $result"
# ── Not JSON at all ──────────────────────────────────────────────────────────
result="$(probe 'gaps_out = 24')"
[[ "$result" == "$shipped" ]] || fail "a non-JSON file did not yield shipped defaults: $result"
# ── A JSON array rather than an object ───────────────────────────────────────
result="$(probe '[1, 2, 3]')"
[[ "$result" == "$shipped" ]] || fail "a top-level array did not yield shipped defaults: $result"
# ── Wrong types: each bad value falls back on its own ────────────────────────
result="$(probe '{"gapsOut": "wide", "windowRounding": 24, "blurEnabled": 3, "keyboardLayout": 7}')"
[[ "$result" == 'gapsOut=10 rounding=24 blur=true layout=us hdr=1' ]] \
|| fail "wrong-typed values did not fall back per key: $result"
# ── Good values are actually used ────────────────────────────────────────────
result="$(probe '{"gapsOut": 24, "windowRounding": 6, "blurEnabled": false, "keyboardLayout": "us,de", "autoHdr": false}')"
[[ "$result" == 'gapsOut=24 rounding=6 blur=false layout=us,de hdr=0' ]] \
|| fail "stored values were not applied: $result"
# ── A boolean maps onto an integer option, matching SystemSettings.hyprValue ─
result="$(probe '{"autoHdr": true}')"
[[ "$result" == 'gapsOut=10 rounding=18 blur=true layout=us hdr=1' ]] \
|| fail "getInt did not convert a boolean: $result"
# ── Escapes and nesting do not break the reader ──────────────────────────────
result="$(probe '{"note": "a \"quoted\" value\nwith escapes", "nested": {"a": [1, 2, {"b": null}]}, "gapsOut": 12}')"
[[ "$result" == 'gapsOut=12 rounding=18 blur=true layout=us hdr=1' ]] \
|| fail "a file with escapes and nesting was not parsed: $result"
# ── The real config parses in every one of those states ─────────────────────
# This is the case that actually protects the desktop: prefs.lua returning
# defaults is only useful if Hyprland still accepts the config around it.
#
# Hyprland resolves its own config from $HOME/.config/hypr regardless of
# XDG_CONFIG_HOME, while prefs.lua honours XDG_CONFIG_HOME. That asymmetry is
# what makes this loop useful rather than vacuous: the *real* looks.lua and
# input.lua are parsed against a *fixture* settings file. The probe cases above
# already establish that prefs.lua reads the fixture and not the live file.
for settings in '__absent__' '' '{ "gapsOut": 24, "windowRounding":' 'gaps_out = 24' '{"gapsOut": "wide"}' \
'{"gapsOut": 24, "windowRounding": 6, "blurEnabled": false, "borderSize": 0, "keyboardLayout": "us,de"}'; do
if [[ "$settings" == "__absent__" ]]; then
rm -f "$work/config/panama/settings.json"
else
mkdir -p "$work/config/panama"
printf '%s' "$settings" >"$work/config/panama/settings.json"
fi
output="$(XDG_CONFIG_HOME="$work/config" Hyprland --verify-config 2>&1 || true)"
grep -q 'config ok' <<<"$output" \
|| fail "Hyprland rejected the config with settings=<$settings>: $(tail -5 <<<"$output")"
done
trap - EXIT
cleanup
printf 'prefs fallback contract: PASS\n'
+156 -23
View File
@@ -2,72 +2,205 @@
set -euo pipefail
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source_config_path="$project_root/config/dot/quickshell"
quicksettings_path="$source_config_path/modules/quicksettings"
state_home="$(mktemp -d /tmp/panama-control-center-ui.XXXXXX)"
config_path="$state_home/quickshell"
helper_log="$state_home/home-helper.log"
shell_log="$state_home/quickshell.log"
fail() {
printf 'Control Center contract: %s\n' "$1" >&2
exit 1
}
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
qs_for_test() {
QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
QS_DISABLE_CRASH_HANDLER=1 \
PANAMA_HOME_HELPER_LOG="$helper_log" \
qs -p "$config_path" "$@"
}
stop_test_shell() {
qs_for_test kill >/dev/null 2>&1 || true
for _ in $(seq 1 80); do
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
&& ! qs_for_test ipc show >/dev/null 2>&1; then
return 0
fi
sleep 0.1
done
return 1
}
cleanup() {
qs ipc call quicksettings close >/dev/null 2>&1 || true
qs ipc call kdeconnect reset >/dev/null 2>&1 || true
qs ipc call home-assistant reset >/dev/null 2>&1 || true
qs_for_test ipc call quicksettings close >/dev/null 2>&1 || true
qs_for_test ipc call kdeconnect reset >/dev/null 2>&1 || true
qs_for_test ipc call home-assistant reset >/dev/null 2>&1 || true
if stop_test_shell; then
rm -rf "$state_home"
else
printf 'Control Center contract: branch shell did not stop; retained %s\n' \
"$state_home" >&2
fi
}
trap cleanup EXIT
start_test_shell() {
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
for _attempt in 1 2; do
qs_for_test --daemonize >"$shell_log" 2>&1
for _ in $(seq 1 80); do
if qs_for_test ipc show 2>/dev/null | rg '^target quicksettings$' >/dev/null; then
return
fi
sleep 0.1
done
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
done
sed -n '1,200p' "$shell_log" >&2
fail 'isolated branch shell did not start'
}
rg -Fq 'readonly property int controlCenterWidth: 430' \
"$project_root/config/dot/quickshell/config/Theme.qml" \
"$source_config_path/config/Theme.qml" \
|| fail 'approved Control Center width is missing'
rg -Fq 'readonly property int controlCenterTopGap: 2' \
"$project_root/config/dot/quickshell/config/Theme.qml" \
"$source_config_path/config/Theme.qml" \
|| fail 'approved top attachment is missing'
rg -Fq 'margins.top: Theme.barHeight + Theme.controlCenterTopGap' \
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettings.qml" \
"$quicksettings_path/QuickSettings.qml" \
|| fail 'Control Center is not tightly attached to the bar'
rg -Fq 'implicitWidth: Theme.controlCenterWidth' \
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettings.qml" \
"$quicksettings_path/QuickSettings.qml" \
|| fail 'Control Center window does not use its geometry token'
rg -Fq 'HomeControls' \
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \
"$quicksettings_path/QuickSettingsPanel.qml" \
|| fail 'Home controls are not mounted'
rg -Fq 'PhoneControls' \
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \
"$quicksettings_path/QuickSettingsPanel.qml" \
|| fail 'Phone controls are not mounted'
rg -Fq 'visible: KdeConnect.phoneReachable' \
"$project_root/config/dot/quickshell/modules/bar/StatusCluster.qml" \
"$source_config_path/modules/bar/StatusCluster.qml" \
|| fail 'reachable phone state is absent from the bar'
for component in ControlSectionHeader HomeControls HomeTile PhoneControls RecentExchange; do
[[ -f "$project_root/config/dot/quickshell/modules/quicksettings/$component.qml" ]] \
for component in ControlSectionHeader HomeBrightnessSlider HomeControls HomeTile PhoneActions PhoneControls RecentExchange; do
[[ -f "$quicksettings_path/$component.qml" ]] \
|| fail "$component is missing"
done
qs ipc call home-assistant fixture ready >/dev/null
qs ipc call kdeconnect fixture reachable >/dev/null
qs ipc call quicksettings open >/dev/null
[[ "$(rg -c 'id: "(share|clipboard|ring|messages)"' "$quicksettings_path/PhoneActions.qml")" -eq 4 ]] \
|| fail 'Phone controls do not expose four stable action models'
rg -Fq 'columns: 4' "$quicksettings_path/PhoneControls.qml" \
|| fail 'Phone actions are not arranged in four equal columns'
if rg -Fq '.filter(' "$quicksettings_path/PhoneActions.qml"; then
fail 'Phone action columns change when a KDE capability is unavailable'
fi
rg -Fq 'SystemSettings.bluebubblesAvailable' "$quicksettings_path/PhoneControls.qml" \
|| fail 'Messages action is not independently enabled by BlueBubbles'
rg -Fq 'KdeConnect.phoneReachable' "$quicksettings_path/PhoneControls.qml" \
|| fail 'KDE action reachability behavior is missing'
[[ -f "$quicksettings_path/qmldir" ]] \
|| fail 'quick-settings module manifest is missing'
rg -Fq 'HomeBrightnessSlider 1.0 HomeBrightnessSlider.qml' \
"$quicksettings_path/qmldir" \
|| fail 'brightness slider is not registered in the quick-settings module'
rg -Fq 'PhoneActions 1.0 PhoneActions.qml' \
"$quicksettings_path/qmldir" \
|| fail 'Phone actions model is not registered in the quick-settings module'
[[ "$(rg -c '^[[:space:]]*columns: 2$' "$quicksettings_path/HomeControls.qml")" -ge 2 ]] \
|| fail 'resting and expanded Home shelves are not both two-column grids'
rg -Fq 'model: HomeAssistant.visibleEntities' "$quicksettings_path/HomeControls.qml" \
|| fail 'resting Home shelf does not use the first four selected lights'
rg -Fq 'model: HomeAssistant.selectedEntities' "$quicksettings_path/HomeControls.qml" \
|| fail 'expanded Home shelf does not use every selected light'
rg -Fq 'HomeAssistant.pendingFor(' "$quicksettings_path/HomeControls.qml" \
|| fail 'Home tiles do not receive per-light pending brightness'
rg -Fq 'HomeAssistant.setBrightness(' "$quicksettings_path/HomeControls.qml" \
|| fail 'Home brightness commits are not wired to the service'
rg -Fq 'ShellState.openSettings("home-phone")' "$quicksettings_path/HomeControls.qml" \
|| fail 'Manage in Settings does not open Home & Phone'
rg -Fq 'signal previewChanged(int value)' "$quicksettings_path/HomeBrightnessSlider.qml" \
|| fail 'brightness slider has no preview contract'
rg -Fq 'signal committed(int value)' "$quicksettings_path/HomeBrightnessSlider.qml" \
|| fail 'brightness slider has no release-commit contract'
rg -Fq 'onReleased: event =>' "$quicksettings_path/HomeBrightnessSlider.qml" \
|| fail 'brightness slider does not route pointer release through its interaction state'
rg -Fq 'root.releasePointerInteraction();' "$quicksettings_path/HomeBrightnessSlider.qml" \
|| fail 'brightness slider release is not wired to one-shot commit semantics'
rg -Fq 'onCanceled: root.cancelPointerInteraction()' "$quicksettings_path/HomeBrightnessSlider.qml" \
|| fail 'brightness slider does not restore its external value when a Flickable steals the pointer'
if rg -Fq 'preventStealing: true' "$quicksettings_path/HomeBrightnessSlider.qml"; then
fail 'brightness slider blocks the expanded shelf from stealing vertical drags'
fi
rg -Fq 'onWheel:' "$quicksettings_path/HomeBrightnessSlider.qml" \
|| fail 'brightness slider has no wheel commit path'
rg -Fq 'onCommitted: value => root.brightnessRequested(value)' "$quicksettings_path/HomeTile.qml" \
|| fail 'Home tile does not forward the slider release commit'
rg -Fq 'accessibleName: root.entity.name + " brightness"' "$quicksettings_path/HomeTile.qml" \
|| fail 'Home tile does not give its dimmer an accessory-specific accessible name'
cp -a "$source_config_path" "$config_path"
: >"$helper_log"
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >>"$PANAMA_HOME_HELPER_LOG"
case "${1:-}" in
catalog)
printf '%s\n' '{"ok":false,"configured":false,"entities":[],"legacyEntityIds":[],"error":"test-helper"}'
;;
toggle|brightness)
printf '%s\n' '{"ok":false,"error":"contract-action-forbidden"}'
exit 73
;;
esac
EOF
chmod +x "$config_path/scripts/panama-home-assistant"
start_test_shell
qs_for_test ipc call home-assistant fixture ready >/dev/null
home_ready="$(qs_for_test ipc call home-assistant status)"
jq -e '.fixture == true and .visibleCount == 4 and .configuredCount == 7' \
<<<"$home_ready" >/dev/null \
|| fail 'Home fixture does not expose four resting and seven expanded accessories'
qs_for_test ipc call kdeconnect fixture reachable >/dev/null
qs_for_test ipc call quicksettings open >/dev/null
hyprctl layers | rg -q 'namespace: qs-popover-quicksettings' \
|| fail 'Control Center layer did not map'
jq -e '.open == true and .expandedSection == ""' \
<<<"$(qs ipc call quicksettings status)" >/dev/null \
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|| fail 'Control Center did not open in its resting state'
qs ipc call quicksettings section home >/dev/null
qs_for_test ipc call quicksettings section home >/dev/null
jq -e '.open == true and .expandedSection == "home"' \
<<<"$(qs ipc call quicksettings status)" >/dev/null \
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|| fail 'Home section did not expand'
qs ipc call quicksettings section phone >/dev/null
qs_for_test ipc call quicksettings section phone >/dev/null
jq -e '.open == true and .expandedSection == "phone"' \
<<<"$(qs ipc call quicksettings status)" >/dev/null \
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|| fail 'Phone did not replace Home as the expanded section'
qs ipc call quicksettings section phone >/dev/null
qs_for_test ipc call quicksettings section phone >/dev/null
jq -e '.open == true and .expandedSection == ""' \
<<<"$(qs ipc call quicksettings status)" >/dev/null \
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|| fail 'expanded Phone section did not collapse'
if rg '^(toggle|brightness)( |$)' "$helper_log" >&2; then
fail 'Control Center contract attempted a real Home action path'
fi
trap - EXIT
cleanup
[[ ! -e "$state_home" ]] \
|| fail 'temporary Control Center state was not removed after shell exit'
printf 'Control Center contract: PASS\n'
@@ -2,25 +2,108 @@
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
state_home="$(mktemp -d /tmp/panama-control-center-state.XXXXXX)"
source_config_path="$repo_dir/config/dot/quickshell"
config_path="$state_home/quickshell"
helper_log="$state_home/home-helper.log"
shell_log="$state_home/quickshell.log"
: >"$helper_log"
cp -a "$source_config_path" "$config_path"
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >>"$PANAMA_HOME_HELPER_LOG"
case "${1:-}" in
catalog)
printf '%s\n' '{"ok":false,"error":"test-helper"}'
;;
toggle|brightness)
printf '%s\n' '{"ok":true}'
;;
esac
EOF
chmod +x "$config_path/scripts/panama-home-assistant"
fail() {
printf 'Control Center services contract: %s\n' "$1" >&2
exit 1
}
qs_for_test() {
QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
PANAMA_HOME_HELPER_LOG="$helper_log" \
qs -p "$config_path" "$@"
}
stop_test_shell() {
qs_for_test kill >/dev/null 2>&1 || true
for _ in $(seq 1 80); do
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
&& ! qs_for_test ipc show >/dev/null 2>&1; then
return 0
fi
sleep 0.1
done
return 1
}
cleanup() {
qs ipc call kdeconnect reset >/dev/null 2>&1 || true
qs ipc call home-assistant reset >/dev/null 2>&1 || true
qs ipc call status-events reset >/dev/null 2>&1 || true
qs ipc call quicksettings close >/dev/null 2>&1 || true
qs_for_test ipc call kdeconnect reset >/dev/null 2>&1 || true
qs_for_test ipc call home-assistant reset >/dev/null 2>&1 || true
qs_for_test ipc call status-events reset >/dev/null 2>&1 || true
qs_for_test ipc call quicksettings close >/dev/null 2>&1 || true
if stop_test_shell; then
rm -rf "$state_home"
else
printf 'Control Center services contract: branch shell did not stop; retained %s\n' \
"$state_home" >&2
fi
}
trap cleanup EXIT
qs ipc show | rg -q '^target kdeconnect$' \
start_test_shell() {
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
for _attempt in 1 2; do
qs_for_test --daemonize >"$shell_log" 2>&1
for _ in $(seq 1 80); do
if qs_for_test ipc show 2>/dev/null | rg '^target home-assistant$' >/dev/null; then
return
fi
sleep 0.1
done
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
done
sed -n '1,200p' "$shell_log" >&2
fail 'isolated branch shell did not start'
}
wait_for_home_status() {
local filter="$1"
local message="$2"
local status=""
for _ in $(seq 1 80); do
status="$(qs_for_test ipc call home-assistant status)"
if jq -e "$filter" <<<"$status" >/dev/null; then
return
fi
sleep 0.1
done
printf 'Last Home status: %s\n' "$status" >&2
fail "$message"
}
start_test_shell
qs_for_test ipc show | rg '^target kdeconnect$' >/dev/null \
|| fail 'KDE Connect IPC target is missing'
qs ipc show | rg -q '^target home-assistant$' \
qs_for_test ipc show | rg '^target home-assistant$' >/dev/null \
|| fail 'Home Assistant IPC target is missing'
qs ipc call kdeconnect fixture reachable >/dev/null
qs_for_test ipc call kdeconnect fixture reachable >/dev/null
jq -e '
.fixture == true and
.available == true and
@@ -28,65 +111,270 @@ jq -e '
.actionCount == 4 and
.transferActive == false and
.ongoingCount == 0
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \
' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \
|| fail 'reachable phone fixture is malformed'
qs ipc call kdeconnect fixture offline >/dev/null
qs_for_test ipc call kdeconnect fixture offline >/dev/null
jq -e '
.fixture == true and
.available == true and
.reachable == false and
.pairedCount == 1 and
.ongoingCount == 0
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \
' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \
|| fail 'offline phone fixture is malformed'
qs ipc call kdeconnect fixture transfer >/dev/null
qs_for_test ipc call kdeconnect fixture transfer >/dev/null
jq -e '
.fixture == true and
.transferActive == true and
.transferFileName == "Fixture document.pdf" and
.ongoingCount == 1
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \
' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \
|| fail 'phone transfer did not enter Ongoing'
qs ipc call kdeconnect cancel >/dev/null
qs_for_test ipc call kdeconnect cancel >/dev/null
jq -e '.transferActive == false and .ongoingCount == 0' \
<<<"$(qs ipc call kdeconnect status)" >/dev/null \
<<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \
|| fail 'phone transfer did not leave Ongoing'
qs ipc call home-assistant fixture ready >/dev/null
qs_for_test ipc call home-assistant fixture ready >/dev/null
home_ready="$(qs_for_test ipc call home-assistant status)"
jq -e '
.fixture == true and
.phase == "ready" and
.discoveredCount == 7 and
.configuredCount == 7 and
.visibleCount == 4 and
.selectedIds[0:4] == [
"light.fixture_all",
"light.fixture_kitchen",
"light.fixture_living",
"light.fixture_bedroom"
] and
(.entities[0:4] | map(.name)) == [
"Whole home",
"Kitchen island",
"Living room",
"Bedroom"
] and
.stale == false and
.busy == false and
.lastError == ""
' <<<"$(qs ipc call home-assistant status)" >/dev/null \
' <<<"$home_ready" >/dev/null \
|| fail 'ready Home fixture is malformed'
qs ipc call home-assistant fixture stale >/dev/null
qs_for_test ipc call home-assistant brightness light.fixture_living 64 >/dev/null
jq -e '
.entities[] |
select(.id == "light.fixture_living") |
.active == true and .state == "on" and .brightnessPct == 64
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|| fail 'Home brightness fixture did not update local catalog state'
qs_for_test ipc call home-assistant toggle light.fixture_living >/dev/null
jq -e '
.entities[] |
select(.id == "light.fixture_living") |
.active == false and .state == "off"
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|| fail 'Home toggle fixture did not update local catalog state'
qs_for_test ipc call home-assistant fixture process-actions >/dev/null
qs_for_test ipc call home-assistant brightness light.fixture_living 64 >/dev/null
qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null
jq -e '
.busy == true and
.busyEntityIds == ["light.fixture_living", "light.fixture_hall"] and
.pendingBrightness["light.fixture_living"] == 64
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|| fail 'process-backed Home actions did not remain per-entity busy'
wait_for_home_status '
.busy == false and
.busyEntityIds == [] and
.pendingBrightness == {} and
(.entities[] | select(.id == "light.fixture_living") | .active == true and .brightnessPct == 64) and
(.entities[] | select(.id == "light.fixture_hall") | .active == true)
' 'process-backed Home actions did not complete in queue order'
qs_for_test ipc call home-assistant fixture process-no-output >/dev/null
qs_for_test ipc call home-assistant toggle light.fixture_kitchen >/dev/null
qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null
wait_for_home_status '
.phase == "ready" and
.busy == false and
.busyEntityIds == [] and
.entityErrors["light.fixture_kitchen"] == "action-failed" and
(.entityErrors["light.fixture_hall"] // "") == "" and
(.entities[] | select(.id == "light.fixture_hall") | .active == true)
' 'nonzero no-output Home action did not fail safely and continue the queue'
qs_for_test ipc call home-assistant fixture process-actions >/dev/null
qs_for_test ipc call home-assistant brightness light.fixture_living 88 >/dev/null
jq -e '
.busyEntityIds == ["light.fixture_living"] and
.pendingBrightness["light.fixture_living"] == 88
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|| fail 'fixture transition setup did not start an in-flight action'
qs_for_test ipc call home-assistant fixture process-no-output >/dev/null
qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null
wait_for_home_status '
.phase == "ready" and
.busy == false and
.busyEntityIds == [] and
.pendingBrightness == {} and
.entityErrors == {} and
(.entities[] |
select(.id == "light.fixture_living") |
.active == false and .brightnessPct == 36) and
(.entities[] | select(.id == "light.fixture_hall") | .active == true)
' 'switching process fixtures stranded or misattributed the new action'
qs_for_test ipc call home-assistant fixture process-delayed-exit >/dev/null
qs_for_test ipc call home-assistant brightness light.fixture_living 88 >/dev/null
wait_for_home_status '
.busyEntityIds == ["light.fixture_living"] and
.pendingBrightness["light.fixture_living"] == 88 and
.actionProcessRunning == false and
.actionStreamFinished == true
' 'nested fixture transition setup did not reach its delayed-exit window'
qs_for_test ipc call home-assistant fixture process-no-output >/dev/null
qs_for_test ipc call home-assistant fixture process-slow-actions >/dev/null
qs_for_test ipc call home-assistant brightness light.fixture_desk 57 >/dev/null
jq -e '
.fixture == true and
.fixtureTransitionDraining == true and
.busy == false and
.busyEntityIds == [] and
.pendingBrightness == {} and
.entityErrors == {} and
.queuedActionCount == 0 and
.actionActive == false and
.actionProcessRunning == false and
(.entities[] |
select(.id == "light.fixture_desk") |
.active == true and .brightnessPct == 24)
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|| fail 'nested fixture transition accepted an action during its drain'
wait_for_home_status '
.fixture == true and
.fixtureTransitionDraining == false and
.busy == false and
.queuedActionCount == 0 and
.actionActive == false and
.actionProcessRunning == false
' 'nested fixture transition did not install the latest stable fixture'
qs_for_test ipc call home-assistant brightness light.fixture_desk 57 >/dev/null
wait_for_home_status '
.phase == "ready" and
.fixtureTransitionDraining == false and
.busy == false and
.busyEntityIds == [] and
.pendingBrightness == {} and
.entityErrors == {} and
.queuedActionCount == 0 and
.actionActive == false and
(.entities[] |
select(.id == "light.fixture_living") |
.active == false and .brightnessPct == 36) and
(.entities[] |
select(.id == "light.fixture_desk") |
.active == true and .brightnessPct == 57)
' 'nested fixture transitions released the drain or corrupted the latest action'
qs_for_test ipc call home-assistant fixture process-delayed-exit >/dev/null
qs_for_test ipc call home-assistant brightness light.fixture_living 88 >/dev/null
wait_for_home_status '
.fixture == true and
.busyEntityIds == ["light.fixture_living"] and
.pendingBrightness["light.fixture_living"] == 88 and
.actionProcessRunning == false and
.actionStreamFinished == true
' 'reset-to-live setup did not reach its process drain window'
qs_for_test ipc call home-assistant reset >/dev/null
qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null
qs_for_test ipc call home-assistant brightness light.fixture_living 63 >/dev/null
jq -e '
.fixture == true and
.fixtureTransitionDraining == true and
.busy == false and
.busyEntityIds == [] and
.pendingBrightness == {} and
.entityErrors == {} and
.queuedActionCount == 0 and
.actionActive == false and
.actionProcessRunning == false
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|| fail 'reset-to-live drain accepted an action from the old fixture'
wait_for_home_status '
.fixture == false and
.fixtureTransitionDraining == false and
.busy == false and
.busyEntityIds == [] and
.pendingBrightness == {} and
.entityErrors == {} and
.queuedActionCount == 0 and
.actionActive == false and
.actionProcessRunning == false
' 'reset-to-live left stale fixture action state or started a replacement process'
if rg '^(toggle|brightness)( |$)' "$helper_log" >&2; then
fail 'reset-to-live issued a stale fixture action to the live helper'
fi
qs_for_test ipc call home-assistant fixture missing-selected >/dev/null
jq -e '
.fixture == true and
.phase == "ready" and
.discoveredCount == 7 and
.configuredCount == 8 and
(.entities[] |
select(.id == "light.fixture_missing") |
.sourceName == "fixture missing" and
.name == "Porch" and
.state == "unavailable" and
.available == false and
.active == false and
.dimmable == false and
.brightnessPct == 0)
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|| fail 'missing Home selection was not retained as unavailable'
qs_for_test ipc call home-assistant fixture action-error >/dev/null
jq -e '
.fixture == true and
.phase == "ready" and
.stale == false and
.entityErrors["light.fixture_kitchen"] == "request-failed" and
(.entityErrors["light.fixture_hall"] // "") == "" and
.lastError == ""
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|| fail 'Home action error was not isolated to one entity'
qs_for_test ipc call home-assistant fixture stale >/dev/null
jq -e '
.fixture == true and
.phase == "degraded" and
.discoveredCount == 7 and
.configuredCount == 7 and
.visibleCount == 4 and
.stale == true and
.lastError == "unreachable"
' <<<"$(qs ipc call home-assistant status)" >/dev/null \
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|| fail 'stale Home fixture did not retain entities'
qs ipc call home-assistant fixture unavailable >/dev/null
qs_for_test ipc call home-assistant fixture unavailable >/dev/null
jq -e '
.fixture == true and
.phase == "unavailable" and
.discoveredCount == 0 and
.configuredCount == 0 and
.visibleCount == 0 and
.lastError == "not-configured"
' <<<"$(qs ipc call home-assistant status)" >/dev/null \
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|| fail 'unavailable Home fixture is malformed'
trap - EXIT
cleanup
[[ ! -e "$state_home" ]] \
|| fail 'temporary Home preferences state was not removed after shell exit'
printf 'Control Center services contract: PASS\n'
@@ -0,0 +1,30 @@
pragma Singleton
import QtQuick
QtObject {
id: root
property bool available: false
property var devices: []
property bool transferActive: false
property string lastError: ""
property var recentExchange: null
property int actionCount: 0
readonly property var preferredPhone: {
const phones = root.devices.filter(device => device.type === "phone" && device.paired);
return phones.find(device => device.reachable) ?? phones[0] ?? null;
}
readonly property bool phoneReachable: root.preferredPhone?.reachable === true
readonly property var phoneActions: root.preferredPhone?.actions ?? []
function supports(action: string): bool {
return root.phoneActions.indexOf(action) >= 0;
}
function refresh(): void {}
function sendFile(path: string): void { root.actionCount += 1; }
function sendClipboard(): void { root.actionCount += 1; }
function ring(): void { root.actionCount += 1; }
}
@@ -0,0 +1,13 @@
pragma Singleton
import QtQuick
QtObject {
property bool bluebubblesAvailable: true
property int launchCount: 0
function openApplication(id: string): bool {
launchCount += 1;
return false;
}
}
@@ -12,24 +12,14 @@ helper="$project_root/config/dot/quickshell/scripts/panama-home-assistant"
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
probe="$($helper probe)"
catalog="$($helper catalog)"
jq -e '
.configured == true and
.reachable == true and
(.entityCount | type == "number" and . > 0) and
.error == ""
' <<<"$probe" >/dev/null || fail 'live API probe failed'
.ok == true and .configured == true and .error == "" and
(.entities | type == "array" and length > 0) and
([.entities[] | (keys | sort) == (["active", "available", "brightnessPct", "dimmable", "id", "sourceName", "state"] | sort)] | all) and
([.entities[] | (.id | startswith("light.")) and (.brightnessPct >= 0 and .brightnessPct <= 100)] | all) and
(.legacyEntityIds | type == "array")
' <<<"$catalog" >/dev/null || fail 'live catalog shape is invalid'
snapshot="$($helper snapshot)"
jq -e --argjson expected "$(jq '.entityCount' <<<"$probe")" '
.ok == true and
.configured == true and
.error == "" and
(.generatedAt | type == "number") and
(.entities | type == "array" and length == $expected) and
([.entities[] | (keys | sort) == (["active", "available", "domain", "id", "name", "state"] | sort)] | all) and
([.entities[] | (.active | type == "boolean") and (.available | type == "boolean")] | all)
' <<<"$snapshot" >/dev/null || fail 'live snapshot shape is invalid'
printf 'Home Assistant helper contract: PASS (configured=true, %s favourites; contents redacted)\n' \
"$(jq '.entities | length' <<<"$snapshot")"
printf 'Home Assistant helper contract: PASS (configured=true, %s lights; contents redacted)\n' \
"$(jq '.entities | length' <<<"$catalog")"
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/home-brightness-slider-harness.qml"
state_home="$(mktemp -d /tmp/panama-home-brightness-slider.XXXXXX)"
shell_log="$state_home/quickshell.log"
fail() {
printf 'Home brightness slider contract: %s\n' "$1" >&2
exit 1
}
qs_for_harness() {
QS_DISABLE_CRASH_HANDLER=1 XDG_STATE_HOME="$state_home" \
qs -p "$harness" "$@"
}
cleanup() {
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$state_home"
}
trap cleanup EXIT
start_harness() {
qs_for_harness --daemonize >"$shell_log" 2>&1
for _ in $(seq 1 50); do
if qs_for_harness ipc show 2>/dev/null \
| rg -q '^target home-brightness-slider-test$'; then
return
fi
sleep 0.1
done
sed -n '1,160p' "$shell_log" >&2
fail 'isolated slider harness did not start'
}
assert_status() {
local filter="$1"
local message="$2"
local status=""
status="$(qs_for_harness ipc call home-brightness-slider-test status)"
jq -e "$filter" <<<"$status" >/dev/null || {
printf 'Slider status: %s\n' "$status" >&2
fail "$message"
}
}
start_harness
qs_for_harness ipc call home-brightness-slider-test reset 30 >/dev/null
assert_status \
'.accessibleRoleIsSlider == true and .accessibleName == "Desk lamp brightness" and .accessibleDescription == "30 percent, range 0 to 100" and .accessibleFocusable == true' \
'slider accessibility metadata is incomplete or incorrect'
qs_for_harness ipc call home-brightness-slider-test press 140 >/dev/null
assert_status \
'.confirmedValue == 30 and .previewValue == 70 and .interactionActive == true and .commitCount == 0 and .accessibleDescription == "70 percent, range 0 to 100"' \
'pointer press did not remain a local preview'
qs_for_harness ipc call home-brightness-slider-test release >/dev/null
assert_status \
'.previewValue == 70 and .interactionActive == false and .commitCount == 1 and .lastCommit == 70' \
'one pointer release did not emit exactly one commit'
qs_for_harness ipc call home-brightness-slider-test release >/dev/null
assert_status '.commitCount == 1' 'release without an interaction emitted another commit'
qs_for_harness ipc call home-brightness-slider-test reset 30 >/dev/null
qs_for_harness ipc call home-brightness-slider-test wheel 120 >/dev/null
assert_status \
'.previewValue == 35 and .interactionActive == false and .commitCount == 1 and .lastCommit == 35' \
'one wheel event did not emit exactly one five-point commit'
qs_for_harness ipc call home-brightness-slider-test reset 30 >/dev/null
qs_for_harness ipc call home-brightness-slider-test press 160 >/dev/null
assert_status \
'.previewValue == 80 and .interactionActive == true and .commitCount == 0' \
'cancellation setup did not enter local preview state'
qs_for_harness ipc call home-brightness-slider-test external 45 >/dev/null
assert_status \
'.confirmedValue == 45 and .previewValue == 80 and .interactionActive == true and .commitCount == 0' \
'external confirmed or pending state replaced an active local preview'
qs_for_harness ipc call home-brightness-slider-test cancel >/dev/null
assert_status \
'.confirmedValue == 45 and .previewValue == 45 and .interactionActive == false and .commitCount == 0 and .lastCommit == -1' \
'canceled interaction committed or failed to restore the external value'
trap - EXIT
cleanup
printf 'Home brightness slider contract: PASS\n'
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
state_home="$(mktemp -d /tmp/panama-home-phone-settings-state.XXXXXX)"
source_config_path="$repo_dir/config/dot/quickshell"
config_path="$state_home/quickshell"
test_bin="$state_home/bin"
shell_log="$state_home/quickshell.log"
flatpak_log="$state_home/flatpak.log"
cleanup_bootstrap() {
rm -rf "$state_home"
}
trap cleanup_bootstrap EXIT
mkdir -p "$test_bin"
cp -a "$source_config_path" "$config_path"
: >"$flatpak_log"
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-}" in
catalog)
printf '%s\n' '{"ok":false,"error":"test-helper"}'
;;
toggle|brightness)
printf '%s\n' '{"ok":true}'
;;
esac
EOF
chmod +x "$config_path/scripts/panama-home-assistant"
cat >"$test_bin/hyprctl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Home and Phone contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]'
exit 0
fi
if [[ "${1:-}" == "keyword" ]]; then
exit 0
fi
exec /usr/sbin/hyprctl "$@"
EOF
chmod +x "$test_bin/hyprctl"
cat >"$test_bin/flatpak" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >>"$PANAMA_FLATPAK_LOG"
if [[ "${1:-}" == "info" && "${2:-}" == "app.bluebubbles.BlueBubbles" ]]; then
exit 0
fi
exit 97
EOF
chmod +x "$test_bin/flatpak"
fail() {
printf 'home phone settings contract: %s\n' "$1" >&2
exit 1
}
assert_contains() {
local needle="$1"
local file="$2"
rg -Fq "$needle" "$file" || fail "$file is missing: $needle"
}
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml"
favorite_card="$repo_dir/config/dot/quickshell/modules/settings/HomeFavoriteCard.qml"
available_row="$repo_dir/config/dot/quickshell/modules/settings/AvailableLightRow.qml"
system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
[[ -f "$home_page" ]] || fail 'HomePhonePage.qml is missing'
[[ -f "$favorite_card" ]] || fail 'HomeFavoriteCard.qml is missing'
[[ -f "$available_row" ]] || fail 'AvailableLightRow.qml is missing'
assert_contains 'text: "Home & Phone"' "$home_page"
assert_contains 'text: "Choose what appears in Control Center and keep phone continuity close at hand."' "$home_page"
assert_contains 'Connected · ' "$home_page"
assert_contains 'Last update unavailable · showing saved controls' "$home_page"
assert_contains 'Authentication required' "$home_page"
assert_contains 'Home Assistant is not configured' "$home_page"
assert_contains 'HomePreferences.setAlias' "$home_page"
assert_contains 'HomePreferences.move' "$home_page"
assert_contains 'HomePreferences.remove' "$home_page"
assert_contains 'HomePreferences.add' "$home_page"
assert_contains 'HomePreferences.retrySave' "$home_page"
assert_contains 'Choose lights below to build your Control Center shelf.' "$home_page"
assert_contains 'All discovered lights are already selected' "$home_page"
assert_contains 'No lights discovered' "$home_page"
assert_contains 'No lights match that search' "$home_page"
assert_contains 'Opens BlueBubbles' "$home_page"
[[ "$(rg -Fc 'required property var modelData' "$home_page")" -ge 2 ]] \
|| fail 'HomePhonePage.qml does not bind both reusable delegates to modelData'
if rg -Fq 'index: model.index' "$home_page"; then
fail 'HomePhonePage.qml reads an undefined model.index instead of the delegate index'
fi
if rg -qi 'token|bearer|api/states' "$home_page"; then
fail 'HomePhonePage.qml crosses the credential or REST privacy boundary'
fi
assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card"
assert_contains 'signal removeRequested(string id)' "$favorite_card"
assert_contains 'signal moveRequested(string id, int targetIndex)' "$favorite_card"
assert_contains 'DragHandler {' "$favorite_card"
assert_contains 'onEditingFinished:' "$favorite_card"
assert_contains 'text: "Control Center"' "$favorite_card"
assert_contains 'activeFocusOnTab: true' "$favorite_card"
assert_contains 'signal addRequested(string id)' "$available_row"
assert_contains 'activeFocusOnTab: true' "$available_row"
assert_contains 'readonly property bool bluebubblesAvailable: root.bluebubblesDetected' "$system_settings"
assert_contains 'command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]' "$system_settings"
assert_contains '"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]' "$system_settings"
qs_for_test() {
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
PANAMA_FLATPAK_LOG="$flatpak_log" qs -p "$config_path" "$@"
}
stop_test_shell() {
qs_for_test kill >/dev/null 2>&1 || true
for _ in $(seq 1 80); do
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
&& ! qs_for_test ipc show >/dev/null 2>&1; then
return 0
fi
sleep 0.1
done
return 1
}
cleanup() {
qs_for_test ipc call settings close >/dev/null 2>&1 || true
if stop_test_shell; then
rm -rf "$state_home"
else
printf 'home phone settings contract: branch shell did not stop; retained %s\n' \
"$state_home" >&2
fi
}
trap cleanup EXIT
start_test_shell() {
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
for _attempt in 1 2; do
qs_for_test --daemonize >"$shell_log" 2>&1
for _ in $(seq 1 80); do
if qs_for_test ipc show 2>/dev/null | rg '^target settings-system$' >/dev/null; then
return
fi
sleep 0.1
done
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
done
sed -n '1,240p' "$shell_log" >&2
fail 'isolated branch shell did not start'
}
start_test_shell
qs_for_test ipc call home-assistant fixture ready >/dev/null
qs_for_test ipc call settings page home-phone >/dev/null
status='{}'
for _ in $(seq 1 40); do
status="$(qs_for_test ipc call settings status | jq -c .)"
if jq -e '.page == "home-phone" and .discoveredCount == 7 and .selectedCount == 7' \
<<<"$status" >/dev/null; then
break
fi
sleep 0.1
done
jq -e '
.open == true and
.page == "home-phone" and
.discoveredCount == 7 and
.selectedCount == 7 and
.homePhone.availableLightIds == [] and
.homePhone.availableEmptyText == "All discovered lights are already selected"
' \
<<<"$status" >/dev/null || fail "Home & Phone diagnostics are incomplete: $status"
qs_for_test ipc call home-assistant fixture available-extra >/dev/null
available_status='{}'
for _ in $(seq 1 40); do
available_status="$(qs_for_test ipc call settings status | jq -c .)"
if jq -e '
.discoveredCount == 8 and
.selectedCount == 7 and
.homePhone.availableLightIds == ["light.fixture_guest"]
' <<<"$available_status" >/dev/null; then
break
fi
sleep 0.1
done
jq -e '
.discoveredCount == 8 and
.selectedCount == 7 and
.homePhone.availableLightIds == ["light.fixture_guest"]
' <<<"$available_status" >/dev/null \
|| fail "an unselected fixture light was not the only available row: $available_status"
qs_for_test ipc call home-assistant fixture stale-authentication >/dev/null
authentication_status='{}'
for _ in $(seq 1 40); do
authentication_status="$(qs_for_test ipc call settings status | jq -c .)"
if jq -e '
.discoveredCount == 7 and
.selectedCount == 7 and
.homePhone.homeStatus == "Authentication required"
' <<<"$authentication_status" >/dev/null; then
break
fi
sleep 0.1
done
jq -e '
.discoveredCount == 7 and
.selectedCount == 7 and
.homePhone.homeStatus == "Authentication required"
' <<<"$authentication_status" >/dev/null \
|| fail "stale authentication did not retain actionable copy: $authentication_status"
qs_for_test ipc call home-assistant fixture stale-not-configured >/dev/null
not_configured_status='{}'
for _ in $(seq 1 40); do
not_configured_status="$(qs_for_test ipc call settings status | jq -c .)"
if jq -e '
.discoveredCount == 7 and
.selectedCount == 7 and
.homePhone.homeStatus == "Home Assistant is not configured"
' <<<"$not_configured_status" >/dev/null; then
break
fi
sleep 0.1
done
jq -e '
.discoveredCount == 7 and
.selectedCount == 7 and
.homePhone.homeStatus == "Home Assistant is not configured"
' <<<"$not_configured_status" >/dev/null \
|| fail "stale not-configured state did not retain actionable copy: $not_configured_status"
qs_for_test ipc call home-assistant fixture unavailable >/dev/null
empty_status='{}'
for _ in $(seq 1 40); do
empty_status="$(qs_for_test ipc call settings status | jq -c .)"
if jq -e '
.discoveredCount == 0 and
.selectedCount == 0 and
.homePhone.homeStatus == "Home Assistant is not configured" and
.homePhone.availableLightIds == [] and
.homePhone.availableEmptyText == "No lights discovered"
' <<<"$empty_status" >/dev/null; then
break
fi
sleep 0.1
done
jq -e '
.discoveredCount == 0 and
.selectedCount == 0 and
.homePhone.homeStatus == "Home Assistant is not configured" and
.homePhone.availableLightIds == [] and
.homePhone.availableEmptyText == "No lights discovered"
' <<<"$empty_status" >/dev/null \
|| fail "an empty catalog did not render its distinct copy: $empty_status"
system_status="$(qs_for_test ipc call settings-system status | jq -c .)"
jq -e '.bluebubblesAvailable == true' <<<"$system_status" >/dev/null \
|| fail "BlueBubbles availability was not exposed: $system_status"
shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
for _ in $(seq 1 40); do
if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null; then
break
fi
sleep 0.1
done
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
|| fail 'the branch shell did not own exactly one tiled Panama Settings client'
if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then
geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \
'.[] | select(.pid == $pid and .title == "Panama Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
[[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry'
grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH"
fi
rg -Fxq 'info app.bluebubbles.BlueBubbles' "$flatpak_log" \
|| fail 'the fixed BlueBubbles availability probe did not run'
if rg -q '^run ' "$flatpak_log"; then
fail 'the contract started BlueBubbles'
fi
if find "$state_home" -name panama-home.json -print -quit | rg -q .; then
fail 'the read-only fixture route wrote Home preferences'
fi
trap - EXIT
cleanup
[[ ! -e "$state_home" ]] || fail 'temporary Home & Phone state was not removed after shell exit'
printf 'home phone settings contract: PASS\n'
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/home-preferences-harness.qml"
state_home="$(mktemp -d /tmp/panama-home-preferences-state.XXXXXX)"
fail() {
printf 'home preferences contract: %s\n' "$1" >&2
exit 1
}
qs_for_harness() {
XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
}
cleanup() {
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$state_home"
}
trap cleanup EXIT
start_harness() {
qs_for_harness --daemonize >/dev/null
for _ in $(seq 1 40); do
if qs_for_harness ipc show 2>/dev/null | rg -q '^target home-pref-test$'; then
# Construct the lazy singleton and let its explicit reload finish
# before issuing mutations through the IPC boundary.
qs_for_harness ipc call home-pref-test status >/dev/null
sleep 0.2
return
fi
sleep 0.1
done
fail 'test IPC target did not start'
}
stop_harness() {
qs_for_harness kill >/dev/null 2>&1 || true
for _ in $(seq 1 40); do
if ! qs_for_harness ipc show >/dev/null 2>&1; then
return
fi
sleep 0.1
done
fail 'test shell did not stop cleanly'
}
status_without_state_dir() {
qs_for_harness ipc call home-pref-test status | jq -c 'del(.stateDir)'
}
wait_for_status() {
local expected="$1"
local actual=""
for _ in $(seq 1 40); do
actual="$(status_without_state_dir)"
if jq -e --argjson expected "$expected" \
'.initialized == $expected.initialized and .favorites == $expected.favorites and .saveError == $expected.saveError' \
<<<"$actual" >/dev/null; then
return
fi
sleep 0.1
done
fail "unexpected status: $actual"
}
wait_for_file_content() {
local expected="$1"
for _ in $(seq 1 40); do
state_file="$(find "$state_home" -name panama-home.json -print -quit)"
if [[ -n "$state_file" ]] \
&& jq -e --argjson expected "$expected" \
'.initialized == $expected.initialized and .favorites == $expected.favorites' \
"$state_file" >/dev/null; then
return
fi
sleep 0.1
done
fail 'preferences file did not contain the complete atomic update'
}
# A leading JSON whitespace prevents qs from expanding the array into IPC
# positional arguments; JSON.parse() intentionally accepts that whitespace.
initial_ids=' ["light.kitchen","light.hall","light.desk"]'
expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}],"saveError":""}'
expected_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}]}'
empty_expected='{"initialized":true,"favorites":[],"saveError":""}'
empty_file='{"initialized":true,"favorites":[]}'
start_harness
qs_for_harness ipc call home-pref-test initialize "$initial_ids" >/dev/null
qs_for_harness ipc call home-pref-test alias light.kitchen ' Island ' >/dev/null
qs_for_harness ipc call home-pref-test move light.desk 0 >/dev/null
qs_for_harness ipc call home-pref-test remove light.hall >/dev/null
wait_for_status "$expected"
wait_for_file_content "$expected_file"
stop_harness
start_harness
wait_for_status "$expected"
qs_for_harness ipc call home-pref-test remove light.desk >/dev/null
qs_for_harness ipc call home-pref-test remove light.kitchen >/dev/null
wait_for_status "$empty_expected"
wait_for_file_content "$empty_file"
stop_harness
start_harness
wait_for_status "$empty_expected"
qs_for_harness ipc call home-pref-test initialize ' ["light.new","light.other"]' >/dev/null
wait_for_status "$empty_expected"
jq -e '(keys | sort) == ["favorites", "initialized"]' "$state_file" >/dev/null \
|| fail 'preferences file contains keys other than initialized and favorites'
if jq -r '.. | strings' "$state_file" | rg -qi 'token|url|api'; then
fail 'preferences file contains credential-like data'
fi
trap - EXIT
cleanup
printf 'home preferences contract: PASS\n'
+140 -29
View File
@@ -14,6 +14,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROOT = pathlib.Path(__file__).resolve().parents[2]
HELPER = ROOT / "config/dot/quickshell/scripts/panama-home-assistant"
QML_SERVICE = ROOT / "config/dot/quickshell/services/HomeAssistant.qml"
loader = importlib.machinery.SourceFileLoader("panama_home_assistant", str(HELPER))
spec = importlib.util.spec_from_loader(loader.name, loader)
@@ -54,20 +55,36 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
if self.path == "/api/states":
self._json(
[
{
"entity_id": "light.hall",
"state": "off",
"attributes": {"friendly_name": "Hall"},
},
{
"entity_id": "sensor.private",
"state": "1",
"attributes": {"friendly_name": "Private"},
},
{
"entity_id": "light.kitchen",
"state": "on",
"attributes": {"friendly_name": "Kitchen"},
"attributes": {
"friendly_name": "Kitchen",
"brightness": 128,
"supported_color_modes": ["brightness"],
},
},
{
"entity_id": "light.hall",
"state": "off",
"attributes": {
"friendly_name": "Hall",
"supported_color_modes": ["color_temp"],
},
},
{"entity_id": "sensor.private", "state": "1", "attributes": {"token": "never-return"}},
{
"entity_id": "light.malformed",
"state": "on",
"attributes": "invalid",
},
{
"entity_id": "light.corner",
"state": "unavailable",
"attributes": {
"friendly_name": "Corner",
"supported_color_modes": ["brightness"],
},
},
]
)
@@ -78,7 +95,11 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
self._record(body)
if self.path == "/api/services/homeassistant/toggle":
if self.path in {
"/api/services/homeassistant/toggle",
"/api/services/light/turn_on",
"/api/services/light/turn_off",
}:
self._json([])
return
self._json({"message": "not found"}, 404)
@@ -123,33 +144,71 @@ class HomeAssistantBridgeTest(unittest.TestCase):
self.assertEqual(config.base_url, "https://home.example")
self.assertEqual(config.entity_ids, ("light.kitchen", "light.hall"))
def test_snapshot_filters_and_preserves_configured_order(self) -> None:
snapshot = bridge.collect_snapshot(self.config())
self.assertTrue(snapshot["ok"])
self.assertEqual(
[item["name"] for item in snapshot["entities"]],
["Kitchen", "Hall"],
def test_config_does_not_require_legacy_entity_ids(self) -> None:
self.assertTrue(
bridge.Config(
base_url="https://home.example",
token="fixture-token",
entity_ids=(),
).configured
)
self.assertNotIn("sensor.private", json.dumps(snapshot))
def test_legacy_entity_ids_remain_migration_metadata(self) -> None:
config = bridge.resolve_config(
{
"PANAMA_HOME_ASSISTANT_URL": "https://home.example",
"PANAMA_HOME_ASSISTANT_TOKEN": "fixture-token",
},
legacy=lambda: bridge.Config(
base_url="https://legacy.example",
token="legacy-token",
entity_ids=("light.kitchen",),
),
)
self.assertEqual(config.base_url, "https://home.example")
self.assertEqual(config.token, "fixture-token")
self.assertEqual(config.entity_ids, ("light.kitchen",))
def test_catalog_filters_and_normalizes_discovered_lights(self) -> None:
catalog = bridge.collect_catalog(self.config())
self.assertTrue(catalog["ok"])
self.assertEqual(
[item["sourceName"] for item in catalog["entities"]],
["Kitchen", "Hall", "Corner"],
)
self.assertEqual(catalog["entities"][0]["brightnessPct"], 50)
self.assertEqual(catalog["entities"][1]["brightnessPct"], 0)
self.assertTrue(all(item["dimmable"] for item in catalog["entities"]))
rendered = json.dumps(catalog)
self.assertNotIn("sensor.private", rendered)
self.assertNotIn("light.malformed", rendered)
self.assertNotIn("token", rendered)
def test_snapshot_uses_bearer_authentication(self) -> None:
bridge.collect_snapshot(self.config())
bridge.collect_catalog(self.config())
request = FakeHomeAssistant.requests[-1]
self.assertEqual(request["method"], "GET")
self.assertEqual(request["path"], "/api/states")
self.assertEqual(request["authorization"], "Bearer fixture-token")
def test_toggle_rejects_an_unconfigured_entity(self) -> None:
with self.assertRaisesRegex(ValueError, "entity-not-configured"):
bridge.ensure_configured(
"light.office",
("light.kitchen",),
def test_snapshot_remains_a_catalog_compatibility_alias(self) -> None:
snapshot = bridge.collect_snapshot(self.config())
self.assertTrue(snapshot["ok"])
self.assertEqual(
[item["sourceName"] for item in snapshot["entities"]],
["Kitchen", "Hall", "Corner"],
)
def test_toggle_calls_the_homeassistant_service(self) -> None:
result = bridge.toggle(self.config(), "light.kitchen")
def test_action_rejects_an_entity_not_in_the_live_catalog(self) -> None:
with self.assertRaisesRegex(ValueError, "entity-not-discovered"):
bridge.toggle(self.config(), "light.office")
def test_toggle_authorizes_against_discovered_catalog(self) -> None:
result = bridge.toggle(self.config(), "light.corner")
self.assertTrue(result["ok"])
request = FakeHomeAssistant.requests[-1]
@@ -157,9 +216,61 @@ class HomeAssistantBridgeTest(unittest.TestCase):
self.assertEqual(request["path"], "/api/services/homeassistant/toggle")
self.assertEqual(
json.loads(request["body"]),
{"entity_id": "light.kitchen"},
{"entity_id": "light.corner"},
)
def test_brightness_uses_turn_on_for_positive_percent(self) -> None:
bridge.set_brightness(self.config(), "light.kitchen", 62)
request = FakeHomeAssistant.requests[-1]
self.assertEqual(request["path"], "/api/services/light/turn_on")
self.assertEqual(
json.loads(request["body"]),
{"entity_id": "light.kitchen", "brightness_pct": 62},
)
def test_brightness_zero_uses_turn_off(self) -> None:
bridge.set_brightness(self.config(), "light.hall", 0)
request = FakeHomeAssistant.requests[-1]
self.assertEqual(request["path"], "/api/services/light/turn_off")
self.assertEqual(
json.loads(request["body"]),
{"entity_id": "light.hall"},
)
def test_brightness_rejects_invalid_values_before_any_request(self) -> None:
for value in (-1, 101, 1.5, "bright"):
with self.subTest(value=value):
with self.assertRaisesRegex(ValueError, "invalid-brightness"):
bridge.set_brightness(self.config(), "light.kitchen", value)
self.assertEqual(FakeHomeAssistant.requests, [])
def test_parse_brightness_rejects_invalid_cli_values(self) -> None:
for value in ("-1", "101", "1.5", "bright"):
with self.subTest(value=value):
with self.assertRaisesRegex(ValueError, "invalid-brightness"):
bridge.parse_brightness(value)
def test_qml_refreshes_through_the_catalog_command(self) -> None:
self.assertIn('command: [root.helperPath, "catalog"]', QML_SERVICE.read_text())
def test_qml_composes_catalog_and_preferences_for_selected_entities(self) -> None:
source = QML_SERVICE.read_text()
self.assertIn(
"root.catalog = Array.isArray(result.entities) ? result.entities : [];",
source,
)
self.assertIn(
"const favorites = root.fixtureMode\n"
" ? root.fixtureFavorites\n"
" : HomePreferences.favorites;",
source,
)
self.assertIn("name: alias || entity.sourceName,", source)
self.assertIn("root.selectedEntities = nextSelection;", source)
def test_authentication_error_is_redacted(self) -> None:
self.assertEqual(
bridge.public_http_error(401, "sensitive response"),
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
set -euo pipefail
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
phone_controls="$project_root/config/dot/quickshell/modules/quicksettings/PhoneControls.qml"
phone_actions="$project_root/config/dot/quickshell/modules/quicksettings/PhoneActions.qml"
quicksettings_qmldir="$project_root/config/dot/quickshell/modules/quicksettings/qmldir"
system_settings="$project_root/config/dot/quickshell/services/SystemSettings.qml"
harness="$project_root/config/dot/quickshell/phone-controls-harness.qml"
kde_fixture="$project_root/tests/quickshell/fixtures/PhoneControlsKdeConnect.qml"
settings_fixture="$project_root/tests/quickshell/fixtures/PhoneControlsSystemSettings.qml"
state_home="$(mktemp -d /tmp/panama-phone-controls.XXXXXX)"
config_path="$state_home/quickshell"
fail() {
printf 'Phone Messages contract: %s\n' "$1" >&2
exit 1
}
[[ -f "$phone_controls" ]] || fail 'Phone controls component is missing'
[[ -f "$phone_actions" ]] || fail 'Nonvisual Phone actions component is missing'
[[ -f "$system_settings" ]] || fail 'System settings service is missing'
[[ -f "$harness" ]] || fail 'Phone controls runtime harness is missing'
[[ -f "$kde_fixture" ]] || fail 'Phone controls KDE fixture is missing'
[[ -f "$settings_fixture" ]] || fail 'Phone controls System Settings fixture is missing'
rg -Fq 'PhoneActions 1.0 PhoneActions.qml' "$quicksettings_qmldir" \
|| fail 'Phone actions component is not registered'
rg -Fq 'PhoneActions {' "$phone_controls" \
|| fail 'Phone controls do not consume the shared action model'
rg -Fq 'readonly property var actionModels: phoneActions.actionModels' "$phone_controls" \
|| fail 'Phone controls do not render the shared action models'
rg -Fq 'PhoneActions {' "$harness" \
|| fail 'Runtime harness does not use the nonvisual action model'
if rg -Fq 'PhoneControls {' "$harness"; then
fail 'Runtime harness still instantiates the visual Phone controls tree'
fi
cleanup() {
XDG_STATE_HOME="$state_home" QS_DISABLE_CRASH_HANDLER=1 \
qs -p "$config_path/phone-controls-harness.qml" kill >/dev/null 2>&1 || true
rm -rf "$state_home"
}
trap cleanup EXIT
qs_for_harness() {
XDG_STATE_HOME="$state_home" QS_DISABLE_CRASH_HANDLER=1 \
qs -p "$config_path/phone-controls-harness.qml" "$@"
}
start_harness() {
cp -a "$project_root/config/dot/quickshell" "$config_path"
cp "$kde_fixture" "$config_path/services/KdeConnect.qml"
cp "$settings_fixture" "$config_path/services/SystemSettings.qml"
qs_for_harness --daemonize >/dev/null
for _ in $(seq 1 40); do
if qs_for_harness ipc show 2>/dev/null | rg -q '^target phone-controls-test$'; then
return
fi
sleep 0.1
done
fail 'isolated Phone controls harness did not start'
}
assert_fixture_state() {
local fixture="$1"
local expected="$2"
qs_for_harness ipc call phone-controls-test fixture "$fixture" >/dev/null
local actual
actual="$(qs_for_harness ipc call phone-controls-test status)"
[[ -n "$actual" ]] || fail 'Phone controls do not expose runtime action enablement'
jq -e --argjson expected "$expected" '
.bluebubblesAvailable == true and .appLaunches == 0 and .phoneActions == 0 and
(.actions | length == 4) and
([.actions[] | select(.id == "messages") | .enabled] == [true]) and
([.actions[] | select(.id != "messages") | .enabled] == $expected)
' <<<"$actual" >/dev/null \
|| fail "unexpected $fixture action state: $actual"
}
# These source checks do not evaluate the launch branch. The isolated runtime
# harness below uses fixture singletons and only reads action-model status.
rg -Fq '"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]' "$system_settings" \
|| fail 'BlueBubbles does not use the fixed Flatpak argument vector'
rg -Fq 'readonly property bool bluebubblesAvailable: root.bluebubblesDetected' "$system_settings" \
|| fail 'BlueBubbles availability is not exposed independently'
rg -Fq 'command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]' "$system_settings" \
|| fail 'BlueBubbles installed-state probe is missing'
for action in share clipboard ring messages; do
rg -Fq "id: \"$action\"" "$phone_actions" \
|| fail "Phone actions do not include $action"
done
if rg -Fq '.filter(' "$phone_actions"; then
fail 'Phone actions are filtered instead of keeping four stable columns'
fi
rg -Fq 'columns: 4' "$phone_controls" \
|| fail 'Phone actions do not use four equal columns'
rg -Fq 'root.actionModels.length - 1' "$phone_controls" \
|| fail 'Phone action widths are not calculated from the fixed model count'
rg -Fq 'SystemSettings.bluebubblesAvailable' "$phone_controls" \
|| fail 'Messages enablement does not read BlueBubbles availability'
rg -Fq 'KdeConnect.phoneReachable' "$phone_controls" \
|| fail 'KDE action reachability semantics are missing'
rg -Fq 'function actionEnabled(action: string): bool' "$phone_controls" \
|| fail 'Phone controls do not expose action enablement'
rg -Fq 'KdeConnect.supports(action)' "$phone_actions" \
|| fail 'KDE action capability semantics are missing'
rg -Fq 'else if (action === "messages")' "$phone_controls" \
|| fail 'Messages has no independent invocation branch'
rg -Fq 'SystemSettings.openApplication("bluebubbles")' "$phone_controls" \
|| fail 'Messages does not use the allow-listed BlueBubbles launcher'
rg -Fq 'BlueBubbles is not installed' "$phone_controls" \
|| fail 'Missing BlueBubbles has no quiet explanatory row'
rg -Fq 'KDE Connect actions are unavailable until the iPhone reconnects' "$phone_controls" \
|| fail 'Offline copy does not scope unavailability to KDE Connect actions'
rg -Fq 'border.width: actionMouse.activeFocus ? 2 : 0' "$phone_controls" \
|| fail 'Phone actions do not visibly indicate keyboard focus'
rg -Fq 'Accessible.role: Accessible.Button' "$phone_controls" \
|| fail 'Phone actions do not expose an Accessible button role'
rg -Fq 'Accessible.name: actionButton.modelData.label' "$phone_controls" \
|| fail 'Phone actions do not expose their accessible name'
rg -Fq 'Accessible.description: root.actionAccessibleDescription(actionButton.modelData.id)' "$phone_controls" \
|| fail 'Phone actions do not expose their availability state'
rg -Fq 'Accessible.focusable: actionMouse.enabled' "$phone_controls" \
|| fail 'Phone action accessibility does not respect disabled state'
rg -Fq 'Accessible.onPressAction:' "$phone_controls" \
|| fail 'Phone actions do not provide an accessible press handler'
# A static negative guard keeps the Messages branch from accidentally inheriting
# KDE Connect reachability, transfer, or plugin conditions.
messages_branch="$(sed -n '/else if (action === "messages")/,/^ }/p' "$phone_controls")"
printf '%s\n' "$messages_branch" | rg -Fq 'KdeConnect.' \
&& fail 'Messages is coupled to KDE Connect'
start_harness
assert_fixture_state offline '[false, false, false]'
assert_fixture_state unsupported '[true, false, false]'
assert_fixture_state transfer '[false, false, false]'
printf 'Phone Messages contract: PASS\n'
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# The preference store is derived from config/PreferenceSchema.qml rather than
# restating each key. This contract pins the properties that derivation is
# supposed to buy, so that a future change cannot quietly reintroduce the
# hand-maintained variant:
#
# * every schema key round-trips through disk
# * out-of-range numbers are clamped, not stored raw and not rejected
# * unknown keys in the file are preserved across a write
# * set() refuses a key that is not in the schema
# * reset() restores *every* schema default with no hand-written list
# * a corrupt file yields shipped defaults rather than a broken shell
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/preference-schema-harness.qml"
state_home="$(mktemp -d /tmp/panama-schema-state.XXXXXX)"
config_home="$(mktemp -d /tmp/panama-schema-config.XXXXXX)"
store="$config_home/panama/settings.json"
fail() {
printf 'preference schema contract: %s\n' "$1" >&2
exit 1
}
qs_for_harness() {
XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"
}
cleanup() {
qs_for_harness kill >/dev/null 2>&1 || true
}
trap cleanup EXIT
start_harness() {
XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
if qs_for_harness ipc show 2>/dev/null | rg -q '^target preference-schema-test$'; then
return
fi
sleep 0.1
done
fail 'test IPC target did not start'
}
stop_harness() {
qs_for_harness kill >/dev/null 2>&1 || true
for _ in $(seq 1 40); do
# A bare `return` would propagate the failed `ipc show` status, which
# under `set -e` ends the whole contract instead of the function.
qs_for_harness ipc show >/dev/null 2>&1 || return 0
sleep 0.1
done
fail 'test shell did not stop cleanly'
}
wait_for_store() {
for _ in $(seq 1 40); do
[[ -f "$store" ]] && jq -e . "$store" >/dev/null 2>&1 && return
sleep 0.1
done
fail 'preferences file was not written'
}
# ── A fresh store reports schema defaults ────────────────────────────────────
start_harness
key_count="$(qs_for_harness ipc call preference-schema-test keyCount)"
[[ "$key_count" -gt 0 ]] || fail 'schema is empty'
initial="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
defaults="$(qs_for_harness ipc call preference-schema-test defaults | jq -cS .)"
[[ "$initial" == "$defaults" ]] || fail "a fresh store did not report schema defaults: $initial"
# ── Out-of-range numbers are clamped, not stored raw ─────────────────────────
qs_for_harness ipc call preference-schema-test applyJson \
'{"dockHideDelayMs": 99999, "nightLightTemperature": 100, "focusDurationMinutes": 45}' >/dev/null
clamped="$(qs_for_harness ipc call preference-schema-test dump)"
[[ "$(jq -r .dockHideDelayMs <<<"$clamped")" == "2000" ]] \
|| fail "an above-range value was not clamped to the schema maximum: $(jq -r .dockHideDelayMs <<<"$clamped")"
[[ "$(jq -r .nightLightTemperature <<<"$clamped")" == "2000" ]] \
|| fail "a below-range value was not clamped to the schema minimum: $(jq -r .nightLightTemperature <<<"$clamped")"
# ── An unknown key is refused rather than silently accepted ──────────────────
verdict="$(qs_for_harness ipc call preference-schema-test applyJson '{"__not_a_setting__": 1}')"
[[ "$(jq -r .__not_a_setting__ <<<"$verdict")" == "false" ]] || fail 'set() accepted a key outside the schema'
qs_for_harness ipc call preference-schema-test dump | jq -e 'has("__not_a_setting__") | not' >/dev/null \
|| fail 'a key outside the schema entered the store'
# ── An out-of-range enum value is refused ────────────────────────────────────
verdict="$(qs_for_harness ipc call preference-schema-test applyJson '{"vrrPolicy": 7}')"
[[ "$(jq -r .vrrPolicy <<<"$verdict")" == "false" ]] || fail 'set() accepted an enum value outside its options'
# ── Every schema key round-trips across a restart ────────────────────────────
qs_for_harness ipc call preference-schema-test applyJson \
'{"use24Hour": true, "showSeconds": false, "showWeekday": false, "showCpu": false,
"showMemory": false, "showGpu": false, "dockAutohide": false, "dockRevealDelayMs": 75,
"dockHideDelayMs": 400, "focusDurationMinutes": 25, "autoHdr": false, "vrrPolicy": 0,
"directScanoutPolicy": 0, "nightLightEnabled": true, "nightLightAutomatic": true,
"nightLightTemperature": 4100, "lastPage": "desktop"}' >/dev/null
wait_for_store
before="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
[[ "$before" != "$defaults" ]] || fail 'the fixture did not change anything'
stop_harness
start_harness
after=""
for _ in $(seq 1 40); do
after="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
[[ "$after" == "$before" ]] && break
sleep 0.1
done
[[ "$after" == "$before" ]] || fail "values did not survive a restart: $after"
# ── A key this build does not know is carried through, not dropped ───────────
stop_harness
jq '. + {"__future_setting__": "keep me"}' "$store" >"$store.tmp" && mv "$store.tmp" "$store"
start_harness
qs_for_harness ipc call preference-schema-test applyJson '{"dockHideDelayMs": 500}' >/dev/null
for _ in $(seq 1 40); do
jq -e '.__future_setting__ == "keep me" and .dockHideDelayMs == 500' "$store" >/dev/null 2>&1 && break
sleep 0.1
done
jq -e '.__future_setting__ == "keep me"' "$store" >/dev/null \
|| fail 'a setting from a newer build was dropped on write'
# ── reset() restores every schema key, with no hand-maintained list ──────────
qs_for_harness ipc call preference-schema-test reset >/dev/null
restored=""
for _ in $(seq 1 40); do
restored="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
[[ "$restored" == "$defaults" ]] && break
sleep 0.1
done
[[ "$restored" == "$defaults" ]] || fail "reset did not restore every schema default: $restored"
jq -e '.__future_setting__ == "keep me"' "$store" >/dev/null \
|| fail 'reset discarded a setting it does not own'
# ── A corrupt file degrades to defaults instead of breaking the shell ────────
stop_harness
printf '{ this is not json' >"$store"
start_harness
corrupt="$(qs_for_harness ipc call preference-schema-test dump | jq -cS .)"
[[ "$corrupt" == "$defaults" ]] || fail "a corrupt store did not fall back to defaults: $corrupt"
trap - EXIT
cleanup
printf 'preference schema contract: PASS\n'
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# Panama Settings must actually change the compositor, not merely believe it did.
#
# The pre-existing settings-system-contract.sh applies the values the compositor
# already holds and asserts they are unchanged, so it passes whether the write
# works or does nothing at all. That is how `hyprctl keyword` silently failing --
# it prints "keyword can't work with non-legacy parsers" to stdout and exits 0 --
# went unnoticed on this Lua-configured Hyprland.
#
# This contract flips each policy to a value it does not currently hold, reads it
# back from the compositor, and restores the original. A write path that no-ops
# cannot pass it.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml"
# A verified write commits to preferences, and preferences live at
# $XDG_CONFIG_HOME/panama/settings.json. Without an isolated config home this
# contract would persist its deliberately-wrong test values into the user's real
# store, where the next `hyprctl reload` would faithfully apply them. The
# compositor is still the live one -- that is the point of the contract -- and
# the EXIT trap restores it.
config_home="$(mktemp -d /tmp/panama-write-config.XXXXXX)"
fail() {
printf 'settings hyprland write contract: %s\n' "$1" >&2
exit 1
}
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"
}
read_option() {
hyprctl -j getoption "$1" | jq -r .int
}
# Every value this contract touches is captured up front, before anything is
# changed. Capturing later risks recording a value an earlier failed run left
# behind and then "restoring" the daily-driver desktop to it.
original_auto_hdr="$(read_option render:cm_auto_hdr)"
original_vrr="$(read_option misc:vrr)"
original_direct="$(read_option render:direct_scanout)"
original_rounding="$(read_option decoration:rounding)"
original_gaps="$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')"
original_blur="$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)"
original_opacity="$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)"
original_layout="$(hyprctl -j getoption input:kb_layout | jq -r .str)"
restore() {
# Restore through hyprctl rather than the harness: if the harness write path
# is the thing that is broken, the daily-driver desktop must still come back.
# Unconditional and idempotent, so it is safe on both the pass and fail path.
hyprctl eval "hl.config({
render = { cm_auto_hdr = $original_auto_hdr, direct_scanout = $original_direct },
misc = { vrr = $original_vrr },
general = { gaps_out = $original_gaps },
input = { kb_layout = \"$original_layout\" },
decoration = {
rounding = $original_rounding,
inactive_opacity = $original_opacity,
blur = { enabled = $original_blur }
}
})" >/dev/null 2>&1 || true
qs_for_harness kill >/dev/null 2>&1 || true
rm -rf "$config_home"
}
trap restore EXIT
# Pick a target each policy does not currently hold, staying inside the values
# SystemSettings allow-lists (VRR 0|3, direct scanout 0|2).
target_auto_hdr=$([[ "$original_auto_hdr" == 1 ]] && printf false || printf true)
target_auto_hdr_int=$([[ "$target_auto_hdr" == true ]] && printf 1 || printf 0)
target_vrr=$([[ "$original_vrr" == 3 ]] && printf 0 || printf 3)
target_direct=$([[ "$original_direct" == 2 ]] && printf 0 || printf 2)
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-system-test$'; then
break
fi
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'
# ── The write must reach the compositor ──────────────────────────────────────
qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" "$target_vrr" "$target_direct" >/dev/null
applied=false
for _ in $(seq 1 40); do
if [[ "$(read_option render:cm_auto_hdr)" == "$target_auto_hdr_int" \
&& "$(read_option misc:vrr)" == "$target_vrr" \
&& "$(read_option render:direct_scanout)" == "$target_direct" ]]; then
applied=true
break
fi
sleep 0.1
done
[[ "$applied" == true ]] || fail "policy write did not reach the compositor: \
cm_auto_hdr=$(read_option render:cm_auto_hdr) (want $target_auto_hdr_int), \
vrr=$(read_option misc:vrr) (want $target_vrr), \
direct_scanout=$(read_option render:direct_scanout) (want $target_direct)"
# ── A successful write must not report an error ──────────────────────────────
last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)"
[[ -z "$last_error" ]] || fail "a successful write reported an error: $last_error"
# ── A rejected value must be refused, not silently accepted ──────────────────
qs_for_harness ipc call settings-system-test apply "$target_auto_hdr" 7 "$target_direct" >/dev/null
sleep 0.3
[[ "$(read_option misc:vrr)" == "$target_vrr" ]] || fail 'an out-of-allow-list VRR value reached the compositor'
[[ -n "$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)" ]] \
|| fail 'a rejected VRR value did not surface an error'
# ── Every getoption answer shape must be handled, not just integers ──────────
# The compositor reports each option in a different JSON field depending on its
# type, and gaps come back as a four-value box. A verifier that only understood
# "int" would report every other type as rejected.
qs_for_harness ipc call settings-system-test applyJson \
'{"windowRounding": 7, "gapsOut": 23, "blurEnabled": false, "inactiveOpacity": 0.85}' >/dev/null
typed=false
for _ in $(seq 1 40); do
if [[ "$(read_option decoration:rounding)" == "7" \
&& "$(hyprctl -j getoption general:gaps_out | jq -r .css | awk '{print $1}')" == "23" \
&& "$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool)" == "false" \
&& "$(hyprctl -j getoption decoration:inactive_opacity | jq -r '.float | (.*100|round)')" == "85" ]]; then
typed=true
break
fi
sleep 0.1
done
if [[ "$typed" != true ]]; then
fail "a typed batch did not reach the compositor: rounding=$(read_option decoration:rounding), \
gaps=$(hyprctl -j getoption general:gaps_out | jq -r .css), \
blur=$(hyprctl -j getoption decoration:blur:enabled | jq -r .bool), \
opacity=$(hyprctl -j getoption decoration:inactive_opacity | jq -r .float)"
fi
# Verification must recognise those shapes as success, not report them rejected.
last_error="$(qs_for_harness ipc call settings-system-test status | jq -r .lastError)"
if [[ -n "$last_error" ]]; then
fail "a verified typed write was reported as failed: $last_error"
fi
# ── A string that violates its schema pattern must never reach hl.config ──────
[[ "$(qs_for_harness ipc call settings-system-test applyJson '{"keyboardLayout": "us\"; os.execute(\"touch /tmp/panama-pwned\")--"}')" == "false" ]] \
|| fail 'a keyboard layout violating the schema pattern was accepted'
[[ ! -e /tmp/panama-pwned ]] || fail 'a settings value was executed as Lua'
[[ "$(hyprctl -j getoption input:kb_layout | jq -r .str)" == "$original_layout" ]] \
|| fail 'the keyboard layout changed despite a rejected value'
# ── Restoring through the real path must also work ───────────────────────────
qs_for_harness ipc call settings-system-test apply \
"$([[ "$original_auto_hdr" == 1 ]] && printf true || printf false)" \
"$original_vrr" "$original_direct" >/dev/null
restored=false
for _ in $(seq 1 40); do
if [[ "$(read_option render:cm_auto_hdr)" == "$original_auto_hdr" \
&& "$(read_option misc:vrr)" == "$original_vrr" \
&& "$(read_option render:direct_scanout)" == "$original_direct" ]]; then
restored=true
break
fi
sleep 0.1
done
[[ "$restored" == true ]] || fail 'the original policy values could not be restored through SystemSettings'
trap - EXIT
restore
printf 'settings hyprland write contract: PASS\n'
+115 -13
View File
@@ -2,34 +2,135 @@
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)"
source_config_path="$repo_dir/config/dot/quickshell"
config_path="$state_home/quickshell"
test_bin="$state_home/bin"
shell_log="$state_home/quickshell.log"
cleanup_bootstrap() {
rm -rf "$state_home"
}
trap cleanup_bootstrap EXIT
mkdir -p "$test_bin"
cp -a "$source_config_path" "$config_path"
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-}" in
catalog)
printf '%s\n' '{"ok":false,"error":"test-helper"}'
;;
toggle|brightness)
printf '%s\n' '{"ok":true}'
;;
esac
EOF
chmod +x "$config_path/scripts/panama-home-assistant"
cat >"$test_bin/hyprctl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Settings contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]'
exit 0
fi
if [[ "${1:-}" == "keyword" ]]; then
exit 0
fi
exec /usr/sbin/hyprctl "$@"
EOF
chmod +x "$test_bin/hyprctl"
cat >"$test_bin/flatpak" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "info" ]]; then
exit 1
fi
exit 97
EOF
chmod +x "$test_bin/flatpak"
fail() {
printf 'settings pages contract: %s\n' "$1" >&2
exit 1
}
qs_for_test() {
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
qs -p "$config_path" "$@"
}
stop_test_shell() {
qs_for_test kill >/dev/null 2>&1 || true
for _ in $(seq 1 80); do
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
&& ! qs_for_test ipc show >/dev/null 2>&1; then
return 0
fi
sleep 0.1
done
return 1
}
cleanup() {
qs ipc call settings close >/dev/null 2>&1 || true
qs_for_test ipc call settings close >/dev/null 2>&1 || true
if stop_test_shell; then
rm -rf "$state_home"
else
printf 'settings pages contract: branch shell did not stop; retained %s\n' \
"$state_home" >&2
fi
}
trap cleanup EXIT
pages=(home appearance displays connectivity desktop sound notifications screen-intelligence shortcuts services about)
for page in "${pages[@]}"; do
qs ipc call settings page "$page" >/dev/null
for _ in $(seq 1 20); do
[[ "$(qs ipc call settings status | jq -r .page)" == "$page" ]] && break
start_test_shell() {
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
for _attempt in 1 2; do
qs_for_test --daemonize >"$shell_log" 2>&1
for _ in $(seq 1 80); do
if qs_for_test ipc show 2>/dev/null | rg '^target settings$' >/dev/null; then
return
fi
sleep 0.1
done
[[ "$(qs ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null \
|| fail "$page created a missing or duplicate Settings window"
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
done
sed -n '1,200p' "$shell_log" >&2
fail 'isolated branch shell did not start'
}
start_test_shell
qs_for_test ipc call home-assistant fixture ready >/dev/null
shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
pages=(home appearance displays connectivity home-phone desktop sound notifications screen-intelligence shortcuts services about)
for page in "${pages[@]}"; do
qs_for_test ipc call settings page "$page" >/dev/null
for _ in $(seq 1 20); do
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] && break
sleep 0.1
done
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
|| fail "$page created a missing, floating, or duplicate Settings window"
done
qs ipc call settings page '__unsupported__' >/dev/null
[[ "$(qs ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
qs_for_test ipc call settings page '__unsupported__' >/dev/null
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
hyprctl -j binds | jq -e '.[] | select(.description == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|| fail 'Super+I is not registered as Panama Settings'
hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
|| fail 'Super+Shift+S is not registered as Screen Intelligence'
desktop_file="$HOME/.local/share/applications/panama-settings.desktop"
@@ -44,4 +145,5 @@ desktop-file-validate "$intelligence_desktop_file" >/dev/null || fail 'Screen In
trap - EXIT
cleanup
[[ ! -e "$state_home" ]] || fail 'temporary Settings state was not removed after shell exit'
printf 'settings pages contract: PASS\n'
@@ -5,6 +5,7 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/settings-preferences-harness.qml"
state_home="$(mktemp -d /tmp/panama-settings-state.XXXXXX)"
config_home="$(mktemp -d /tmp/panama-settings-config.XXXXXX)"
fail() {
printf 'settings preferences contract: %s\n' "$1" >&2
@@ -12,7 +13,7 @@ fail() {
}
qs_for_harness() {
XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"
}
cleanup() {
@@ -21,7 +22,7 @@ cleanup() {
trap cleanup EXIT
start_harness() {
XDG_STATE_HOME="$state_home" qs -p "$harness" --daemonize >/dev/null
XDG_STATE_HOME="$state_home" XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
if qs_for_harness ipc show 2>/dev/null | rg -q '^target settings-pref-test$'; then
return
@@ -51,7 +52,7 @@ before="$(qs_for_harness ipc call settings-pref-test status | jq -c 'del(.stateD
state_file=""
for _ in $(seq 1 40); do
state_file="$(find "$state_home" -name panama-settings.json -print -quit)"
state_file="$(find "$config_home" -path '*/panama/settings.json' -print -quit)"
if [[ -n "$state_file" ]] && jq -e '.lastPage == "desktop" and .focusDurationMinutes == 70' "$state_file" >/dev/null 2>&1; then
break
fi