10 Commits
Author SHA1 Message Date
Gabriel Brown cae72b5179 Make Settings sidebar content scrollable 2026-08-18 01:54:00 -04:00
Gabriel Brown b3b8e0d66d Prevent shortcut reset collisions 2026-08-18 01:47:51 -04:00
Gabriel Brown 787d2b121a Use the durable Home reset boundary 2026-08-18 01:39:34 -04:00
Gabriel Brown 1b323c2fa5 Document Settings completion plan 2026-08-18 01:36:30 -04:00
Gabriel Brown 1ca571458c Complete Panama settings controls 2026-08-18 01:36:07 -04:00
Gabriel Brown 768801dbe4 Fix application role filtering 2026-08-18 01:28:29 -04:00
Gabriel Brown 375ecfcd95 Add application and autostart settings 2026-08-18 01:28:29 -04:00
Gabriel Brown ce95b34d19 Harden Home preferences reset contract 2026-08-18 01:28:29 -04:00
Gabriel Brown 9d430a3079 Finish Home and Phone settings cohesion 2026-08-18 01:28:29 -04:00
Gabriel Brown bac68d2bfb Document the settings vocabulary and its traps
Records how to add a setting -- one schema entry -- and the failure
modes found building the pages. Every one of them fails silently rather
than loudly, which is why they are worth writing down: a wrong readAs
makes successful writes look rejected, hyprpaper accepts an all-outputs
wallpaper request and ignores it, and a copy of the Quickshell config
shares the live shell's ID so an "isolated" harness can kill the running
desktop.

Also documents the two generated configurations and why they are
generated: ~/.config/hypr is a symlink into this repository, so writing
there at runtime would commit machine state to a tracked file.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 01:15:53 -04:00
33 changed files with 2678 additions and 947 deletions
+47
View File
@@ -44,6 +44,30 @@ Validate any change without leaving your session:
Hyprland --verify-config Hyprland --verify-config
``` ```
## Generated configuration
Two ecosystem tools cannot read the shared settings file, and their configs live
in this directory — which is a symlink into the Panama repository, so writing to
them at runtime would put machine state into a tracked file. Both are therefore
generated elsewhere:
| Tool | Generated to | Pointed there by |
|---|---|---|
| `hypridle` | `$XDG_STATE_HOME/panama/hypridle.conf` | a systemd user drop-in installed by `panama-idle install` |
| `hyprpaper` | not generated — the wallpaper is applied over IPC and re-applied at shell start | — |
`quickshell/scripts/panama-idle` regenerates the hypridle config from the
settings store and restarts the daemon. `hypridle.conf` in this directory
remains the shipped default and is what runs when the drop-in is not installed;
Panama Settings shows which of the two states you are in rather than presenting
controls that quietly do nothing.
Remove the drop-in and go back to the shipped config with:
```sh
~/.config/quickshell/scripts/panama-idle remove
```
## Settings: one file, both sides ## Settings: one file, both sides
`~/.config/panama/settings.json` is shared with the Quickshell side. The `~/.config/panama/settings.json` is shared with the Quickshell side. The
@@ -67,6 +91,29 @@ wrong-typed settings file costs you your customisations and nothing else;
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still `tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still
accepts the config in each of those states. accepts the config in each of those states.
### Adding a keybind: use `bind`, not `hl.bind`
Every bind in `keybinds.lua` goes through a local `bind()` wrapper that
substitutes the chord from a stored override, so shortcuts can be moved from
Panama Settings without editing this file.
```lua
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
```
Only the **chord** is ever taken from settings — the action is always the Lua
value written here. A stored override can therefore move a shortcut but can
never make one do something else, which is what makes reading overrides from a
file the user can edit safe.
Overrides are keyed by the **shipped chord**, not the description. Descriptions
are not unique: "Calculator" is both `SUPER + C` and the `XF86Calculator`
hardware key, and keying by description moved both onto the same new chord,
silently costing the hardware key.
An override whose value is not a plausible chord is ignored in favour of the
shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
### Keybind descriptions are required ### Keybind descriptions are required
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
@@ -82,6 +82,14 @@ Singleton {
values.initialized = true; values.initialized = true;
} }
function resetHomeDefaults(): void {
persistTimer.stop();
values.favorites = [];
values.initialized = false;
root.saveError = "";
preferencesFile.writeAdapter();
}
function isSelected(entityId: string): bool { function isSelected(entityId: string): bool {
for (var index = 0; index < values.favorites.length; index++) { for (var index = 0; index < values.favorites.length; index++) {
if (values.favorites[index].id === entityId) { if (values.favorites[index].id === entityId) {
+10 -10
View File
@@ -28,13 +28,13 @@ Singleton {
// label deliberately general rather than exposing precise coordinates in // label deliberately general rather than exposing precise coordinates in
// the UI or guessing at a city from them. // the UI or guessing at a city from them.
readonly property string weatherLocation: "Local weather" readonly property string weatherLocation: "Local weather"
readonly property string temperatureUnit: "fahrenheit" readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
readonly property int weatherRefreshMinutes: 20 readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
// ── Vitals ────────────────────────────────────────────────────────────── // ── Vitals ──────────────────────────────────────────────────────────────
// The GNOME Vitals extension showed processor usage, memory usage and GPU // The GNOME Vitals extension showed processor usage, memory usage and GPU
// usage, in that order. Same here. // usage, in that order. Same here.
readonly property int vitalsIntervalMs: 2000 readonly property int vitalsIntervalMs: DesktopPreferences.get("vitalsIntervalMs")
readonly property bool showCpu: DesktopPreferences.get("showCpu") readonly property bool showCpu: DesktopPreferences.get("showCpu")
readonly property bool showMemory: DesktopPreferences.get("showMemory") readonly property bool showMemory: DesktopPreferences.get("showMemory")
readonly property bool showGpu: DesktopPreferences.get("showGpu") readonly property bool showGpu: DesktopPreferences.get("showGpu")
@@ -51,10 +51,10 @@ Singleton {
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled") readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
// ── Notifications ─────────────────────────────────────────────────────── // ── Notifications ───────────────────────────────────────────────────────
readonly property int notificationTimeoutMs: 5000 readonly property int notificationTimeoutMs: DesktopPreferences.get("notificationTimeoutMs")
readonly property int notificationTimeoutCriticalMs: 0 // 0 = never auto-expire readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
readonly property int notificationHistoryLimit: 100 readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
readonly property int maxVisibleToasts: 4 readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
// ── Focus ────────────────────────────────────────────────────────────── // ── Focus ──────────────────────────────────────────────────────────────
// One deliberate default rather than a preset picker: quick settings and // One deliberate default rather than a preset picker: quick settings and
@@ -79,9 +79,9 @@ Singleton {
readonly property int dockHideDelayMs: DesktopPreferences.get("dockHideDelayMs") readonly property int dockHideDelayMs: DesktopPreferences.get("dockHideDelayMs")
// ── Capture ───────────────────────────────────────────────────────────── // ── Capture ─────────────────────────────────────────────────────────────
readonly property string screenshotDir: "Pictures/Screenshots" readonly property string screenshotDir: DesktopPreferences.get("screenshotDir")
readonly property string recordingDir: "Videos/Recordings" readonly property string recordingDir: DesktopPreferences.get("recordingDir")
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not // Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
// cost CPU while gaming. // cost CPU while gaming.
readonly property string recorderArgs: "-c h264_vaapi -d /dev/dri/renderD128" readonly property string recorderArgs: DesktopPreferences.get("recorderArgs")
} }
@@ -13,6 +13,7 @@ ShellRoot {
function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); } function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); }
function move(id: string, index: int): void { HomePreferences.move(id, index); } function move(id: string, index: int): void { HomePreferences.move(id, index); }
function remove(id: string): void { HomePreferences.remove(id); } function remove(id: string): void { HomePreferences.remove(id); }
function reset(): void { HomePreferences.resetHomeDefaults(); }
function status(): string { function status(): string {
return JSON.stringify({ return JSON.stringify({
initialized: HomePreferences.initialized, initialized: HomePreferences.initialized,
+10 -2
View File
@@ -13,9 +13,16 @@ ShellRoot {
return Keybinds.rebind(current, next); return Keybinds.rebind(current, next);
} }
function resetBind(current: string): void { Keybinds.resetBind(current); } function resetBind(current: string): bool { return Keybinds.resetBind(current); }
function resetAll(): void { Keybinds.resetAll(); } function resetAll(): void { Keybinds.resetAll(); }
function seedResetCollision(shipped: string, current: string, occupantShipped: string): void {
const overrides = {};
overrides[shipped] = current;
overrides[occupantShipped] = shipped;
DesktopPreferences.set("keybindOverrides", overrides);
}
function chordFor(description: string): string { function chordFor(description: string): string {
const found = Keybinds.binds.find(bind => bind.description === description); const found = Keybinds.binds.find(bind => bind.description === description);
return found ? found.luaChord : ""; return found ? found.luaChord : "";
@@ -24,7 +31,8 @@ ShellRoot {
function overrideState(): string { function overrideState(): string {
return JSON.stringify({ return JSON.stringify({
overrides: Keybinds.overrides, overrides: Keybinds.overrides,
count: Object.keys(Keybinds.overrides).length count: Object.keys(Keybinds.overrides).length,
lastError: Keybinds.lastError
}); });
} }
@@ -3,39 +3,49 @@ import Quickshell
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
Flickable { title: "About Panama"
anchors.fill: parent lede: "A curated Hyprland desktop built around focus, speed, and good taste."
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingsCard {
id: content title: "Panama Desktop"
width: parent.width - 68 subtitle: "Tokyo Night Moon · Prism glass · native tiling"
x: 34
y: 30
spacing: 16
Text { text: "About Panama"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } TextRow {
Text { text: "A curated Hyprland desktop built around focus, speed, and good taste."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } label: "Hyprland"
value: SystemSettings.hyprlandVersion || "Detecting…"
}
TextRow {
label: "Quickshell"
value: SystemSettings.quickshellVersion
}
TextRow {
label: "Display"
value: SystemSettings.monitorName || "Detecting…"
}
TextRow {
label: "Configuration"
detail: Quickshell.shellDir
value: "Local"
divider: false
}
}
SettingsCard { SettingsCard {
title: "Panama Desktop" title: "Design principles"
subtitle: "Tokyo Night Moon · Prism glass · native tiling"
SettingRow { label: "Hyprland"; value: SystemSettings.hyprlandVersion || "Detecting…" }
SettingRow { label: "Quickshell"; value: SystemSettings.quickshellVersion }
SettingRow { label: "Display"; value: SystemSettings.monitorName || "Detecting…" }
SettingRow { label: "Configuration"; detail: Quickshell.shellDir; value: "Local"; divider: false }
}
SettingsCard { TextRow {
title: "Design principles" label: "Curated by default"
SettingRow { label: "Curated by default"; detail: "Strong choices instead of an incoherent matrix of switches" } detail: "Strong choices instead of an incoherent matrix of switches"
SettingRow { label: "Quiet while idle"; detail: "No continuous decorative repaint loops" } }
SettingRow { label: "Real system boundaries"; detail: "Every control either works or clearly hands off to its owner"; divider: false } TextRow {
} label: "Quiet while idle"
detail: "No continuous decorative repaint loops"
}
TextRow {
label: "Real system boundaries"
detail: "Every control either works or clearly hands off to its owner"
divider: false
} }
} }
} }
@@ -1,19 +1,217 @@
// Applications — PLACEHOLDER. // Applications and session startup.
//
// Owned by the codex agent, which is building default-application handling and
// the autostart list. This stub exists only so the page id can be routed,
// registered, and searchable before that work lands; it is expected to be
// replaced wholesale rather than edited.
import Quickshell
import QtQuick import QtQuick
import qs.config import qs.services
SettingsPage { SettingsPage {
id: root
objectName: "applications"
title: "Applications" title: "Applications"
lede: "Default applications and what starts with your session." lede: "Choose what opens your files and links, and what starts with your session."
property string expandedRole: ""
readonly property var applications: DesktopEntries.applications.values
readonly property var roles: [
{ key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] },
{ key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] },
{ key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] },
{ key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] },
{ key: "music", label: "Music", detail: "MP3 audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] },
{ key: "images", label: "Images", detail: "PNG images", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] },
{ key: "video", label: "Video", detail: "MP4 video", categorySets: [["video"]], terms: ["video player", "movie player"] }
]
function desktopId(entry: var): string {
const entryId = String(entry?.id ?? "");
return entryId.endsWith(".desktop") ? entryId : entryId + ".desktop";
}
function displayName(entry: var): string {
return String(entry?.name || entry?.genericName || root.desktopId(entry));
}
function currentHandler(role: string): string {
return String(DefaultApps.handlers[role] ?? "");
}
function currentEntry(role: string): var {
const handler = root.currentHandler(role);
return root.applications.find(entry => root.desktopId(entry) === handler) ?? null;
}
function matchesRole(entry: var, role: var): bool {
const rawCategories = Array.isArray(entry.categories)
? entry.categories
: [String(entry.categories ?? "")];
const categories = [];
for (const rawCategory of rawCategories) {
for (const value of String(rawCategory).split(";")) {
const category = value.trim().toLowerCase();
if (category !== "")
categories.push(category);
}
}
const metadata = [entry.name, entry.genericName]
.map(value => String(value ?? "").toLowerCase())
.join(" ");
return role.categorySets.some(set => set.every(category => categories.includes(category)))
|| role.terms.some(term => metadata.includes(term));
}
function choicesForRole(role: var): var {
const choices = root.applications.filter(entry => root.matchesRole(entry, role));
const currentEntry = root.currentEntry(role.key);
if (currentEntry && !choices.some(entry => root.desktopId(entry) === root.desktopId(currentEntry)))
choices.push(currentEntry);
return choices.sort((left, right) => root.displayName(left).localeCompare(root.displayName(right)));
}
TextRow {
visible: DefaultApps.lastError !== ""
label: "Application settings need attention"
detail: DefaultApps.lastError
value: ""
divider: false
}
SettingsCard { SettingsCard {
title: "Being built" title: "Default applications"
subtitle: "Default browser, mail, files, and terminal, plus the autostart list, are on their way." subtitle: "Open a row to choose from applications that advertise the matching role."
Repeater {
model: root.roles
delegate: Column {
id: roleBlock
required property var modelData
required property int index
readonly property var choices: root.choicesForRole(roleBlock.modelData)
readonly property var selectedEntry: root.currentEntry(roleBlock.modelData.key)
width: parent.width
SettingRow {
label: roleBlock.modelData.label
detail: roleBlock.modelData.detail
value: DefaultApps.busy ? "Loading…" : (
roleBlock.selectedEntry
? root.displayName(roleBlock.selectedEntry)
: (root.currentHandler(roleBlock.modelData.key) || "Not set")
)
activatable: roleBlock.choices.length > 0 && !DefaultApps.busy
divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1
onActivated: {
root.expandedRole = root.expandedRole === roleBlock.modelData.key
? ""
: roleBlock.modelData.key;
}
}
Column {
width: parent.width
visible: root.expandedRole === roleBlock.modelData.key
Repeater {
model: roleBlock.choices
delegate: SettingRow {
id: candidateRow
required property var modelData
required property int index
readonly property string candidateId: root.desktopId(candidateRow.modelData)
readonly property bool selected: candidateRow.candidateId === root.currentHandler(roleBlock.modelData.key)
label: root.displayName(candidateRow.modelData)
detail: String(candidateRow.modelData.genericName || candidateRow.modelData.comment || candidateRow.candidateId)
value: candidateRow.selected ? "Current" : ""
activatable: !candidateRow.selected && !DefaultApps.busy
divider: candidateRow.index < roleBlock.choices.length - 1 || roleBlock.index < root.roles.length - 1
onActivated: {
DefaultApps.setDefault(roleBlock.modelData.key, candidateRow.candidateId);
root.expandedRole = "";
}
}
}
}
}
}
}
SettingsCard {
title: "User autostart"
subtitle: "These desktop entries live in your user configuration. Select a row to toggle it."
TextRow {
visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0
label: "No user autostart entries"
detail: "Applications can add entries to ~/.config/autostart."
value: ""
divider: false
}
Repeater {
model: DefaultApps.autostartEntries
delegate: SettingRow {
id: autostartRow
required property var modelData
required property int index
label: autostartRow.modelData.name
detail: autostartRow.modelData.id
value: autostartRow.modelData.enabled ? "Enabled" : "Disabled"
activatable: !DefaultApps.busy
divider: autostartRow.index < DefaultApps.autostartEntries.length - 1
onActivated: DefaultApps.setAutostart(autostartRow.modelData.id, !autostartRow.modelData.enabled)
}
}
}
SettingsCard {
title: "Compositor autostart"
subtitle: "Panama starts these from Hyprland configuration. They are read-only here."
TextRow {
visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0
label: "No compositor entries found"
detail: "No hl.exec_cmd entries were found in config/dot/hypr/autostart.lua."
value: ""
divider: false
}
Repeater {
model: DefaultApps.luaAutostartEntries
delegate: TextRow {
id: luaRow
required property var modelData
required property int index
label: luaRow.modelData.name
detail: luaRow.modelData.command
value: "Hyprland"
divider: luaRow.index < DefaultApps.luaAutostartEntries.length - 1
}
}
}
SettingsCard {
title: "Refresh"
ActionRow {
label: "Reload application settings"
detail: "Re-read desktop entries, defaults, and user autostart files"
action: DefaultApps.busy ? "Refreshing…" : "Refresh"
enabled: !DefaultApps.busy
divider: false
onTriggered: DefaultApps.refresh()
}
} }
} }
@@ -5,9 +5,12 @@ import qs.config
import qs.services import qs.services
import qs.modules.quicksettings import qs.modules.quicksettings
Item { SettingsPage {
id: root id: root
title: "Network & Devices"
lede: "Connect graphically—no terminal workflow required."
readonly property var wifiDevice: { readonly property var wifiDevice: {
for (const device of Networking.devices.values) { for (const device of Networking.devices.values) {
if (device.type === DeviceType.Wifi) if (device.type === DeviceType.Wifi)
@@ -17,72 +20,73 @@ Item {
} }
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
Flickable { SettingsCard {
anchors.fill: parent title: "Wi‑Fi"
clip: true subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingRow {
id: content label: "Wi‑Fi"
width: parent.width - 68 detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
x: 34 controlWidth: 48
y: 30
spacing: 16
Text { text: "Network & Devices"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } SettingsToggle {
Text { text: "Connect graphically—no terminal workflow required."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
SettingsCard { checked: Networking.wifiEnabled
title: "Wi‑Fi" enabled: Networking.wifiHardwareEnabled
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off" onToggled: value => Networking.wifiEnabled = value
SettingRow {
label: "Wi‑Fi"
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Networking.wifiEnabled
enabled: Networking.wifiHardwareEnabled
onToggled: value => Networking.wifiEnabled = value
}
}
WifiList { width: parent.width; device: root.wifiDevice; active: true; maxHeight: 240 }
SettingRow {
label: "Advanced network settings"
detail: "VPN, wired profiles, DNS, and connection details"
divider: false
controlWidth: 104
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("network") }
}
} }
}
SettingsCard { WifiList {
title: "Bluetooth" width: parent.width
subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off" device: root.wifiDevice
SettingRow { active: true
label: "Bluetooth" maxHeight: 240
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found" }
controlWidth: 48
SettingsToggle { ActionRow {
anchors.right: parent.right label: "Advanced network settings"
anchors.verticalCenter: parent.verticalCenter detail: "VPN, wired profiles, DNS, and connection details"
checked: root.bluetoothAdapter?.enabled ?? false divider: false
enabled: root.bluetoothAdapter !== null action: "Open panel"
onToggled: value => { if (root.bluetoothAdapter) root.bluetoothAdapter.enabled = value; } onTriggered: SystemSettings.openGnomePanel("network")
} }
} }
BluetoothList { width: parent.width; active: true; maxHeight: 220 }
SettingRow { SettingsCard {
label: "Advanced Bluetooth settings" title: "Bluetooth"
detail: "Device details and system-level options" subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off"
divider: false
controlWidth: 104 SettingRow {
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("bluetooth") } label: "Bluetooth"
detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.bluetoothAdapter?.enabled ?? false
enabled: root.bluetoothAdapter !== null
onToggled: value => {
if (root.bluetoothAdapter)
root.bluetoothAdapter.enabled = value;
} }
} }
} }
BluetoothList {
width: parent.width
active: true
maxHeight: 220
}
ActionRow {
label: "Advanced Bluetooth settings"
detail: "Device details and system-level options"
divider: false
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("bluetooth")
}
} }
} }
@@ -3,64 +3,50 @@ import qs.config
import qs.services import qs.services
import qs.widgets import qs.widgets
Item { SettingsPage {
Flickable { title: "Displays"
anchors.fill: parent lede: SystemSettings.monitorDescription || "Reading the active display…"
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingsCard {
id: content title: SystemSettings.monitorName || "Active display"
width: parent.width - 68 subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}`
x: 34 TextRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
y: 30 TextRow { label: "Variable refresh"; detail: SystemSettings.monitorVrrActive ? "Active for current fullscreen content" : "Ready when game or video content requests it"; value: SystemSettings.monitorVrrActive ? "Active" : "Standby"; divider: false }
spacing: 16 }
Text { text: "Displays"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } SettingsCard {
Text { text: SystemSettings.monitorDescription || "Reading the active display…"; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } title: "Gaming display policy"
subtitle: "These values apply immediately and are restored when Panama starts."
SettingsCard { SettingRow {
title: SystemSettings.monitorName || "Active display"
subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}`
SettingRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
SettingRow { label: "Variable refresh"; detail: SystemSettings.monitorVrrActive ? "Active for current fullscreen content" : "Ready when game or video content requests it"; value: SystemSettings.monitorVrrActive ? "Active" : "Standby"; divider: false }
}
SettingsCard {
title: "Gaming display policy"
subtitle: "These values apply immediately and are restored when Panama starts."
SettingRow {
label: "Game-aware HDR" label: "Game-aware HDR"
detail: "Enter HDR only for fullscreen content that requests it" detail: "Enter HDR only for fullscreen content that requests it"
controlWidth: 48 controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) } SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) }
} }
SettingRow { SettingRow {
label: "Content-aware VRR" label: "Content-aware VRR"
detail: "Enable variable refresh only for game and video content" detail: "Enable variable refresh only for game and video content"
controlWidth: 48 controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) } SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) }
} }
SettingRow { SettingRow {
label: "Direct scanout for games" label: "Direct scanout for games"
detail: "Bypass compositing only for windows classified as games" detail: "Bypass compositing only for windows classified as games"
divider: false divider: false
controlWidth: 48 controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) } SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) }
} }
} }
SettingsCard { SettingsCard {
title: "Night Light" title: "Night Light"
SettingRow { SettingRow {
label: "Warm display colors" label: "Warm display colors"
detail: NightLight.automatic ? "Following the evening schedule" : "Manual control" detail: NightLight.automatic ? "Following the evening schedule" : "Manual control"
controlWidth: 48 controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() } SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() }
} }
SettingRow { SettingRow {
label: "Color temperature" label: "Color temperature"
detail: `${NightLight.temperature} K` detail: `${NightLight.temperature} K`
divider: false divider: false
@@ -71,27 +57,25 @@ Item {
icon: "weather-clear-night-symbolic" icon: "weather-clear-night-symbolic"
onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50 onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50
} }
} }
} }
Rectangle { Rectangle {
width: parent.width width: parent.width
height: warningText.implicitHeight + 30 height: warningText.implicitHeight + 30
radius: Theme.cardRadius radius: Theme.cardRadius
color: Theme.alpha(Theme.warn, 0.085) color: Theme.alpha(Theme.warn, 0.085)
border.width: 1 border.width: 1
border.color: Theme.alpha(Theme.warn, 0.22) border.color: Theme.alpha(Theme.warn, 0.22)
Text { Text {
id: warningText id: warningText
anchors.fill: parent anchors.fill: parent
anchors.margins: 15 anchors.margins: 15
text: "Full-time desktop HDR stays unavailable here because the current compositor path can break screenshots, OBS, Sunshine, and lock-screen capture. Game-aware HDR keeps the desktop dependable without giving up HDR games." text: "Full-time desktop HDR stays unavailable here because the current compositor path can break screenshots, OBS, Sunshine, and lock-screen capture. Game-aware HDR keeps the desktop dependable without giving up HDR games."
color: Theme.mix(Theme.fg, Theme.warn, 0.25) color: Theme.mix(Theme.fg, Theme.warn, 0.25)
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
}
}
} }
} }
} }
@@ -2,160 +2,159 @@ import QtQuick
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
id: root id: root
readonly property int openedHour: new Date().getHours() readonly property int openedHour: new Date().getHours()
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening") readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
Flickable { title: `${root.greeting}, Gabriel`
anchors.fill: parent lede: "Your Panama desktop is configured and ready."
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingsCard {
id: content title: SystemSettings.monitorDescription || "Active display"
width: parent.width - 68 subtitle: SystemSettings.monitorName || "Detecting your display…"
x: 34
y: 30
spacing: 16
Text { Grid {
text: `${root.greeting}, Gabriel` id: monitorLayout
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: 27
font.weight: Font.DemiBold
}
Text { width: parent.width
text: "Your Panama desktop is configured and ready." columns: width >= 620 ? 2 : 1
color: Theme.fgDim columnSpacing: 28
font.family: Theme.fontFamily rowSpacing: 16
font.pixelSize: Theme.fontSize
bottomPadding: 6
}
SettingsCard { Item {
title: SystemSettings.monitorDescription || "Active display" width: monitorLayout.columns === 2
subtitle: SystemSettings.monitorName || "Detecting your display…" ? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
: monitorLayout.width
height: 164
Row { Rectangle {
width: parent.width width: Math.min(parent.width - 24, 260)
height: 164 height: width * 0.64
spacing: 28 anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 5
radius: 12
color: Theme.alpha(Theme.bgDark, 0.94)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.34)
Item { Rectangle {
width: parent.width * 0.47 anchors.fill: parent
height: parent.height anchors.margins: 10
radius: 7
Rectangle { gradient: Gradient {
width: Math.min(parent.width - 24, 260) orientation: Gradient.Horizontal
height: width * 0.64 GradientStop { position: 0; color: Theme.mix(Theme.bg, Theme.accent, 0.08) }
anchors.horizontalCenter: parent.horizontalCenter GradientStop { position: 1; color: Theme.mix(Theme.bg, Theme.accentSecondary, 0.08) }
anchors.top: parent.top
anchors.topMargin: 5
radius: 12
color: Theme.alpha(Theme.bgDark, 0.94)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.34)
Rectangle {
anchors.fill: parent
anchors.margins: 10
radius: 7
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0; color: Theme.mix(Theme.bg, Theme.accent, 0.08) }
GradientStop { position: 1; color: Theme.mix(Theme.bg, Theme.accentSecondary, 0.08) }
}
Text {
anchors.centerIn: parent
text: SystemSettings.monitorName || "DISPLAY"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.letterSpacing: 1.5
}
}
} }
}
Column {
width: parent.width * 0.47
anchors.verticalCenter: parent.verticalCenter
spacing: 13
Text { Text {
text: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight}` anchors.centerIn: parent
color: Theme.fg text: SystemSettings.monitorName || "DISPLAY"
color: Theme.fgMuted
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.features: Theme.tabularFigures font.pixelSize: Theme.fontSizeSmall
font.pixelSize: Theme.fontSizeLarge font.letterSpacing: 1.5
font.weight: Font.DemiBold
}
Text {
text: `${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale`
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
}
Text {
text: `${SystemSettings.monitorFormat || "Detecting format"} · ${SystemSettings.colorPreset || "standard color"}`
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
text: SystemSettings.autoHdr ? "Game-aware HDR is ready" : "Game-aware HDR is off"
color: SystemSettings.autoHdr ? Theme.ok : Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
} }
} }
} }
} }
Row { Column {
width: parent.width width: monitorLayout.columns === 2
spacing: 16 ? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
: monitorLayout.width
height: monitorLayout.columns === 2 ? 164 : implicitHeight
spacing: 13
SettingsCard { Text {
width: (parent.width - parent.spacing) / 2 text: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight}`
title: "Quiet focus" color: Theme.fg
subtitle: "Notifications and focused work" font.family: Theme.fontFamily
font.features: Theme.tabularFigures
SettingRow { font.pixelSize: Theme.fontSizeLarge
label: "Do Not Disturb" font.weight: Font.DemiBold
detail: Notifs.doNotDisturb ? "Banners are currently quiet" : "Notification banners are visible"
divider: false
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.doNotDisturb
onToggled: value => Notifs.doNotDisturb = value
}
}
} }
Text {
SettingsCard { text: `${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale`
width: (parent.width - parent.spacing) / 2 color: Theme.fgDim
title: "Desktop services" font.family: Theme.fontFamily
subtitle: "The essentials are running" font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
SettingRow { }
label: "Sync & remote access" Text {
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}` text: `${SystemSettings.monitorFormat || "Detecting format"} · ${SystemSettings.colorPreset || "standard color"}`
divider: false color: Theme.fgDim
value: SystemSettings.nextcloudActive && SystemSettings.rustdeskActive ? "Healthy" : "Review" font.family: Theme.fontFamily
} font.pixelSize: Theme.fontSize
}
Text {
text: SystemSettings.autoHdr ? "Game-aware HDR is ready" : "Game-aware HDR is off"
color: SystemSettings.autoHdr ? Theme.ok : Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
} }
} }
} }
} }
SettingsCard {
title: "Weather"
subtitle: "Local conditions in the date menu"
ChoiceRow { setting: "temperatureUnit" }
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
}
SettingsCard {
title: "System vitals"
subtitle: "Processor, memory, and graphics activity in the bar"
SliderRow { setting: "vitalsIntervalMs"; divider: false }
}
Grid {
id: summaryCards
width: parent.width
columns: width >= 720 ? 2 : 1
columnSpacing: 16
rowSpacing: 16
SettingsCard {
width: summaryCards.columns === 2
? (summaryCards.width - summaryCards.columnSpacing) / 2
: summaryCards.width
title: "Quiet focus"
subtitle: "Notifications and focused work"
SettingRow {
label: "Do Not Disturb"
detail: Notifs.doNotDisturb ? "Banners are currently quiet" : "Notification banners are visible"
divider: false
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: Notifs.doNotDisturb
onToggled: value => Notifs.doNotDisturb = value
}
}
}
SettingsCard {
width: summaryCards.columns === 2
? (summaryCards.width - summaryCards.columnSpacing) / 2
: summaryCards.width
title: "Desktop services"
subtitle: "The essentials are running"
TextRow {
label: "Sync & remote access"
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
divider: false
value: SystemSettings.nextcloudActive && SystemSettings.rustdeskActive ? "Healthy" : "Review"
}
}
}
} }
@@ -2,9 +2,11 @@ import QtQuick
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
id: root id: root
objectName: "home-phone-page" objectName: "home-phone-page"
title: "Home & Phone"
lede: "Choose what appears in Control Center and keep phone continuity close at hand."
property string lightQuery: searchInput.text.trim().toLowerCase() property string lightQuery: searchInput.text.trim().toLowerCase()
@@ -41,274 +43,242 @@ Item {
return "Home Assistant is unavailable"; return "Home Assistant is unavailable";
} }
Flickable { SettingsCard {
anchors.fill: parent title: "Home Assistant"
clip: true subtitle: root.homeStatus()
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingRow {
id: content label: "Light catalog"
width: parent.width - 68 detail: "Panama reads light state through the Home Assistant helper."
x: 34 divider: false
y: 30 controlWidth: 176
spacing: 16
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 { Text {
text: "Home & Phone" anchors.left: parent.left
color: Theme.fg 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.family: Theme.fontFamily
font.pixelSize: 27 font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold 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 { Text {
text: "Choose what appears in Control Center and keep phone continuity close at hand." anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0349}"
color: Theme.fgDim 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.family: Theme.fontFamily
font.pixelSize: Theme.fontSize font.pixelSize: Theme.fontSize
bottomPadding: 6 clip: true
}
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 { Text {
width: parent.width anchors.fill: parent
visible: HomeAssistant.selectedEntities.length === 0 visible: searchInput.text === "" && !searchInput.activeFocus
text: "Choose lights below to build your Control Center shelf." text: "Search available lights"
color: Theme.fgDim color: Theme.fgMuted
font.family: Theme.fontFamily font: searchInput.font
font.pixelSize: Theme.fontSize verticalAlignment: Text.AlignVCenter
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 { Column {
title: "Available lights" width: parent.width
subtitle: "Search the Home Assistant catalog by source name or entity ID." visible: root.availableLights.length > 0
Rectangle { Repeater {
model: root.availableLights
AvailableLightRow {
required property var modelData
width: parent.width width: parent.width
height: 38 entity: modelData
radius: 10 onAddRequested: id => HomePreferences.add(id)
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 { Text {
width: parent.width width: parent.width
visible: root.availableLights.length > 0 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
}
}
Repeater { SettingsCard {
model: root.availableLights title: "Phone continuity"
subtitle: "Keep the Messages handoff independent from phone connectivity."
AvailableLightRow { SettingRow {
required property var modelData label: "Messages"
width: parent.width detail: "Opens BlueBubbles"
entity: modelData divider: false
onAddRequested: id => HomePreferences.add(id) controlWidth: 204
}
} Row {
} anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Text { Text {
width: parent.width anchors.verticalCenter: parent.verticalCenter
visible: root.availableLights.length === 0 text: SystemSettings.bluebubblesAvailable ? "Installed" : "Unavailable"
text: root.availableEmptyText color: SystemSettings.bluebubblesAvailable ? Theme.ok : Theme.fgMuted
color: Theme.fgDim
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
topPadding: 18
bottomPadding: 10
} }
}
SettingsCard { SettingsButton {
title: "Phone continuity" id: openBlueBubblesButton
subtitle: "Keep the Messages handoff independent from phone connectivity." text: "Open"
enabled: SystemSettings.bluebubblesAvailable
SettingRow { activeFocusOnTab: enabled
label: "Messages" border.width: activeFocus ? 2 : 1
detail: "Opens BlueBubbles" border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
divider: false onClicked: SystemSettings.openApplication("bluebubbles")
controlWidth: 204 Keys.onReturnPressed: if (enabled) SystemSettings.openApplication("bluebubbles")
Keys.onSpacePressed: if (enabled) SystemSettings.openApplication("bluebubbles")
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")
}
}
} }
} }
} }
@@ -2,78 +2,97 @@ import QtQuick
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
Flickable { title: "Notifications & Focus"
anchors.fill: parent lede: "Control interruptions without losing useful history."
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingsCard {
id: content title: "Notifications"
width: parent.width - 68
x: 34
y: 30
spacing: 16
Text { text: "Notifications & Focus"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } SettingRow {
Text { text: "Control interruptions without losing useful history."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } label: "Do Not Disturb"
detail: "Keep notifications in the center but suppress banners"
controlWidth: 48
SettingsCard { SettingsToggle {
title: "Notifications" anchors.right: parent.right
SettingRow { anchors.verticalCenter: parent.verticalCenter
label: "Do Not Disturb" checked: Notifs.doNotDisturb
detail: "Keep notifications in the center but suppress banners" onToggled: value => Notifs.doNotDisturb = value
controlWidth: 48
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: Notifs.doNotDisturb; onToggled: value => Notifs.doNotDisturb = value }
}
SettingRow { label: "Notification history"; detail: "Live notifications retained by Panama"; value: `${Notifs.history.length} items` }
SettingRow {
label: "Clear notification history"
detail: "Dismiss every item currently in the notification center"
divider: false
controlWidth: 94
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Clear all"; enabled: Notifs.history.length > 0; onClicked: Notifs.dismissAll() }
}
} }
}
TextRow {
label: "Notification history"
detail: "Live notifications retained by Panama"
value: `${Notifs.history.length} items`
}
ActionRow {
label: "Clear notification history"
detail: "Dismiss every item currently in the notification center"
divider: false
action: "Clear all"
enabled: Notifs.history.length > 0
onTriggered: Notifs.dismissAll()
}
}
SettingsCard {
title: "Banner behavior"
SliderRow { setting: "notificationTimeoutMs" }
SliderRow {
setting: "notificationTimeoutCriticalMs"
zeroLabel: "Never"
}
SliderRow { setting: "notificationHistoryLimit" }
SliderRow { setting: "maxVisibleToasts"; divider: false }
}
SettingsCard {
title: "Focus sessions"
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
SettingRow {
id: durationRow
label: "Default duration"
detail: "Used by Super+Shift+F and Quick Settings"
controlWidth: 264
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Repeater {
model: [25, 45, 60, 90]
SettingsCard {
title: "Focus sessions"
subtitle: "A focus session binds quiet mode and Caffeine to the current workspace."
SettingRow {
label: "Default duration"
detail: "Used by Super+Shift+F and Quick Settings"
controlWidth: 264
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Repeater {
model: [25, 45, 60, 90]
SettingsButton {
required property int modelData
text: `${modelData}m`
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
onClicked: DesktopPreferences.set("focusDurationMinutes", modelData)
}
}
}
}
SettingRow {
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
divider: false
controlWidth: 120
SettingsButton { SettingsButton {
anchors.right: parent.right required property int modelData
anchors.verticalCenter: parent.verticalCenter
text: FocusSession.active ? "Show controls" : "Start focus" text: `${modelData}m`
tone: FocusSession.active ? "normal" : "accent" tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
onClicked: FocusSession.reveal() onClicked: SystemSettings.commitPreference("focusDurationMinutes", modelData)
} }
} }
} }
} }
SettingRow {
label: FocusSession.active ? `Active on ${FocusSession.workspaceLabel}` : "No active focus session"
detail: FocusSession.active ? `${FocusSession.remainingText} remaining` : "Start one without leaving Settings"
divider: false
controlWidth: 120
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: FocusSession.active ? "Show controls" : "Start focus"
tone: FocusSession.active ? "normal" : "accent"
onClicked: FocusSession.reveal()
}
}
} }
} }
@@ -0,0 +1,109 @@
# Panama Settings
The control centre for everything Panama owns. Anything the system owns —
hardware, accounts, printers — is delegated to GNOME Settings and labelled as
such rather than half-reimplemented.
## Adding a setting
One schema entry. That is the whole job.
```qml
// config/PreferenceSchema.qml
{
key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1,
unit: "px", 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" }
}
```
```qml
// the page
SliderRow { setting: "blurSize" }
```
Persistence, validation, clamping, reset, search indexing, and — with a `hypr`
block — live application to the compositor and the startup replay all derive
from that entry. There is nothing else to register.
If it is compositor-backed, add the matching `prefs.get("blurSize", 8)` in
`hypr/looks.lua` so the Hyprland config still stands alone with no settings
file.
## The rows
| Component | For |
|---|---|
| `SettingsPage` | The page scaffold: title, lede, optional pinned `header` |
| `ToggleRow { setting }` | A boolean |
| `SliderRow { setting }` | A number; `zeroLabel` renders 0 as "Never"/"Instant"/"None" |
| `ChoiceRow { setting }` | An enum, as a segmented control |
| `ActionRow` | A button: opens a GNOME panel, runs a one-shot |
| `TextRow` | A genuinely read-only fact |
`TextRow` is for facts, not for settings that were merely expensive to wire.
Before Stage 3 more than half of all rows were static text standing in for
controls; that is the failure this vocabulary exists to prevent.
Rows write through `SystemSettings.commitPreference(key, value)`, which routes
compositor-backed keys through apply-and-verify and local keys straight to the
store. A row never needs to know which kind it holds.
## Things that will bite you
**`readAs` describes the answer, not the setting.** `hyprctl getoption` returns
the value in a different JSON field per type — `int`, `bool`, `float`, `str`,
and `css` for gaps (a four-value box). Declaring the wrong one does not fail
loudly: it makes every write to that key look *rejected*, and the user sees an
error for a change that worked. `tests/quickshell/schema-hypr-shape-contract.sh`
asks the compositor for the real shape of every mapped option.
**Never trust an exit code from `hyprctl`.** `keyword` refuses to work on a
Lua-configured Hyprland, prints the refusal to stdout, and exits 0. `eval` exits
0 on syntax and runtime errors too. The only trustworthy signal that a write
landed is reading the value back.
**The Settings window is tiled.** `implicitWidth` is a hint; the layout decides,
and it ranges from a half-screen split to the full display. `SliderRow` stacks
its control under the label below 520px. Test narrow.
**Binding an anchor to `undefined` does not reliably release it.** Switching
layouts that way left a slider anchored to both edges with the label squeezed
into what was left. Position explicitly instead.
**Inside a `SettingsCard`, `parent` is the card's internal Column.** So
`parent.modelData` in a nested `Repeater` is undefined and the rows silently
never appear — you get a card with a heading and nothing under it. Address the
outer model through an explicit `id`.
**A `TapHandler` declared as a child of `SettingRow` lands in the trailing
slot**, because that is the row's default property, so only the right-hand edge
becomes clickable. Use `activatable: true` with `onActivated` for a whole-row
target.
**A copy of the Quickshell config shares the live shell's ID.** Quickshell
derives the Shell ID from config *content*, not path, so
`cp -a config/dot/quickshell $tmp && qs -p $tmp kill` kills the running
desktop, and `qs -p $tmp ipc call …` can drive it. Harnesses that point at a
single distinct `.qml` file are safe; copying the whole directory is not.
## Where state lives
| File | Holds |
|---|---|
| `~/.config/panama/settings.json` | Everything in the schema. Read by the shell *and* by `hypr/prefs.lua` |
| `$XDG_STATE_HOME/panama/panama-home.json` | Home accessory favourites and aliases |
| `$XDG_STATE_HOME/panama/backups/` | Settings snapshots |
| `$XDG_STATE_HOME/panama/hypridle.conf` | Generated idle config |
`SystemSettings.restoreDefaults()` spans all of them. A reset that silently
skipped one would be worse than having no reset, because nothing would say so.
## Not stored by Panama
Timezone and network time are read from and written to `timedatectl` directly.
They belong to the machine and are shared with sessions that never see Panama's
file; storing a copy would create a second answer to a question the system
already answers.
@@ -2,9 +2,12 @@ import QtQuick
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
id: root id: root
title: "Screen Intelligence"
lede: "Turn text and codes on screen into content you can use."
Component.onCompleted: ScreenIntelligence.refresh() Component.onCompleted: ScreenIntelligence.refresh()
Timer { Timer {
@@ -13,98 +16,78 @@ Item {
onTriggered: Capture.openIntelligence() onTriggered: Capture.openIntelligence()
} }
Flickable { SettingsCard {
anchors.fill: parent title: "Read anything on screen"
clip: true subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingRow {
id: content icon: "󰗊"
width: parent.width - 68 label: "Read screen text"
x: 34 detail: "Copy, search, translate, or open detected links"
y: 30 controlWidth: 160
spacing: 16 divider: false
Text { SettingsButton {
text: "Screen Intelligence" anchors.right: parent.right
color: Theme.fg anchors.verticalCenter: parent.verticalCenter
font.family: Theme.fontFamily text: "Start reading"
font.pixelSize: 27 tone: "accent"
font.weight: Font.DemiBold enabled: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady
} onClicked: {
Text { ShellState.closeSettings();
text: "Turn text and codes on screen into content you can use." launchDelay.restart();
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
bottomPadding: 6
}
SettingsCard {
title: "Read anything on screen"
subtitle: "Select a region, window, or display. Panama recognizes it locally and gives you clean follow-up actions."
SettingRow {
icon: "󰗊"
label: "Read screen text"
detail: "Copy, search, translate, or open detected links"
controlWidth: 160
divider: false
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Start reading"
tone: "accent"
enabled: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady
onClicked: {
ShellState.closeSettings();
launchDelay.restart();
}
}
}
}
SettingsCard {
title: "Local recognition"
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
SettingRow {
label: "Text recognition"
detail: "Tesseract with the English language model"
value: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady ? "Ready" : "Needs install"
}
SettingRow {
label: "QR & barcodes"
detail: "ZBar recognizes codes alongside ordinary text"
value: ScreenIntelligence.codeReady ? "Ready" : "Needs install"
}
SettingRow {
label: "Privacy"
detail: "Only Search, Translate, and Open send the selected result to another application or service"
value: "Local first"
divider: false
}
}
SettingsCard {
title: "Shortcut"
SettingRow {
label: "Read a screen selection"
detail: "Also available as Read in the Print-screen picker"
value: "Super + Shift + S"
controlWidth: 190
divider: ScreenIntelligence.ocrReady && ScreenIntelligence.codeReady && ScreenIntelligence.englishReady
}
SettingRow {
visible: !ScreenIntelligence.ocrReady || !ScreenIntelligence.codeReady || !ScreenIntelligence.englishReady
label: "Install recognition engines"
detail: "sudo dnf install -y tesseract zbar"
value: "Required once"
divider: false
} }
} }
} }
} }
SettingsCard {
title: "Capture preferences"
subtitle: "Choose where captures go and how recordings are encoded."
ChoiceRow { setting: "screenshotDir" }
ChoiceRow { setting: "recordingDir" }
ChoiceRow { setting: "recorderArgs"; divider: false }
}
SettingsCard {
title: "Local recognition"
subtitle: "The screen image stays in Panama's cache and is deleted when you dismiss the result."
TextRow {
label: "Text recognition"
detail: "Tesseract with the English language model"
value: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady ? "Ready" : "Needs install"
}
TextRow {
label: "QR & barcodes"
detail: "ZBar recognizes codes alongside ordinary text"
value: ScreenIntelligence.codeReady ? "Ready" : "Needs install"
}
TextRow {
label: "Privacy"
detail: "Only Search, Translate, and Open send the selected result to another application or service"
value: "Local first"
divider: false
}
}
SettingsCard {
title: "Shortcut"
TextRow {
label: "Read a screen selection"
detail: "Also available as Read in the Print-screen picker"
value: "Super + Shift + S"
controlWidth: 190
divider: ScreenIntelligence.ocrReady && ScreenIntelligence.codeReady && ScreenIntelligence.englishReady
}
TextRow {
visible: !ScreenIntelligence.ocrReady || !ScreenIntelligence.codeReady || !ScreenIntelligence.englishReady
label: "Install recognition engines"
detail: "sudo dnf install -y tesseract zbar"
value: "Required once"
divider: false
}
}
} }
@@ -2,89 +2,132 @@ import QtQuick
import qs.config import qs.config
import qs.services import qs.services
Item { SettingsPage {
function status(active: bool): string { return active ? "Running" : "Stopped"; } title: "Startup & Services"
lede: "A clear view of the background tools that make the desktop feel complete."
Flickable { function status(active: bool): string {
anchors.fill: parent return active ? "Running" : "Stopped";
clip: true }
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { Item {
id: content width: parent.width
width: parent.width - 68 implicitHeight: refresh.implicitHeight
x: 34
y: 30 SettingsButton {
spacing: 16 id: refresh
anchors.right: parent.right
text: SystemSettings.busy ? "Refreshing…" : "Refresh"
enabled: !SystemSettings.busy
onClicked: SystemSettings.refresh()
}
}
SettingsCard {
title: "Your services"
SettingRow {
label: "Nextcloud"
detail: "File synchronization and tray status"
controlWidth: 190
Row { Row {
width: parent.width anchors.right: parent.right
Text { width: parent.width - refresh.width; text: "Startup & Services"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } anchors.verticalCenter: parent.verticalCenter
SettingsButton { id: refresh; text: SystemSettings.busy ? "Refreshing…" : "Refresh"; enabled: !SystemSettings.busy; onClicked: SystemSettings.refresh() } spacing: 10
} Text {
Text { text: "A clear view of the background tools that make the desktop feel complete."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.nextcloudActive)
SettingsCard { color: Theme.fgDim
title: "Your services" font.family: Theme.fontFamily
SettingRow { font.pixelSize: Theme.fontSize
label: "Nextcloud"
detail: "File synchronization and tray status"
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.nextcloudActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("nextcloud") }
}
} }
SettingRow { SettingsButton {
label: "RustDesk" text: "Open"
detail: "Remote access through the enabled system service" onClicked: SystemSettings.openApplication("nextcloud")
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.rustdeskActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("rustdesk") }
}
}
SettingRow {
label: "KDE Connect"
detail: "Phone pairing, clipboard, files, and remote controls"
divider: false
controlWidth: 190
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text { anchors.verticalCenter: parent.verticalCenter; text: status(SystemSettings.kdeconnectActive); color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize }
SettingsButton { text: "Open"; onClicked: SystemSettings.openApplication("kdeconnect") }
}
} }
} }
}
SettingsCard { SettingRow {
title: "Desktop foundation" label: "RustDesk"
SettingRow { label: "Hyprpaper"; detail: "Wallpaper service"; value: status(SystemSettings.hyprpaperActive) } detail: "Remote access through the enabled system service"
SettingRow { label: "Hypridle"; detail: "Idle and lock policy"; value: status(SystemSettings.hypridleActive) } controlWidth: 190
SettingRow { label: "Vicinae"; detail: "Spotlight-style launcher daemon"; value: status(SystemSettings.vicinaeActive); divider: false }
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.rustdeskActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("rustdesk")
}
} }
}
SettingsCard { SettingRow {
title: "Fedora system settings" label: "KDE Connect"
subtitle: "These remain owned by trusted system services and GNOME's mature panels." detail: "Phone pairing, clipboard, files, and remote controls"
SettingRow { divider: false
label: "Network, Bluetooth, printers, users, and accounts" controlWidth: 190
detail: "GNOME Settings remains searchable from the launcher too"
divider: false Row {
controlWidth: 122 anchors.right: parent.right
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open network"; onClicked: SystemSettings.openGnomePanel("network") } anchors.verticalCenter: parent.verticalCenter
spacing: 10
Text {
anchors.verticalCenter: parent.verticalCenter
text: status(SystemSettings.kdeconnectActive)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
SettingsButton {
text: "Open"
onClicked: SystemSettings.openApplication("kdeconnect")
} }
} }
} }
} }
SettingsCard {
title: "Desktop foundation"
TextRow {
label: "Hyprpaper"
detail: "Wallpaper service"
value: status(SystemSettings.hyprpaperActive)
}
TextRow {
label: "Hypridle"
detail: "Idle and lock policy"
value: status(SystemSettings.hypridleActive)
}
TextRow {
label: "Vicinae"
detail: "Spotlight-style launcher daemon"
value: status(SystemSettings.vicinaeActive)
divider: false
}
}
SettingsCard {
title: "Fedora system settings"
subtitle: "These remain owned by trusted system services and GNOME's mature panels."
ActionRow {
label: "Network, Bluetooth, printers, users, and accounts"
detail: "GNOME Settings remains searchable from the launcher too"
divider: false
action: "Open network"
onTriggered: SystemSettings.openGnomePanel("network")
}
}
} }
@@ -11,6 +11,11 @@ Rectangle {
readonly property var results: SettingsSearch.search(root.query) readonly property var results: SettingsSearch.search(root.query)
onQueryChanged: {
if (sidebarScroll)
sidebarScroll.contentY = 0;
}
function pageLabel(page: string): string { function pageLabel(page: string): string {
const found = root.destinations.find(item => item.page === page); const found = root.destinations.find(item => item.page === page);
return found ? found.label : "Settings"; return found ? found.label : "Settings";
@@ -40,8 +45,15 @@ Rectangle {
border.width: 0 border.width: 0
Column { Column {
anchors.fill: parent id: sidebarHeader
anchors.margins: 18
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 18
anchors.rightMargin: 18
anchors.topMargin: 18
height: implicitHeight
spacing: 12 spacing: 12
Text { Text {
@@ -103,149 +115,175 @@ Rectangle {
onClicked: searchInput.forceActiveFocus() onClicked: searchInput.forceActiveFocus()
} }
} }
}
Flickable {
id: sidebarScroll
anchors.left: parent.left
anchors.right: parent.right
anchors.top: sidebarHeader.bottom
anchors.bottom: healthFooter.top
anchors.leftMargin: 18
anchors.rightMargin: 18
anchors.topMargin: 12
anchors.bottomMargin: 12
contentWidth: width
contentHeight: scrollContent.implicitHeight
flickableDirection: Flickable.VerticalFlick
boundsBehavior: Flickable.StopAtBounds
clip: true
// ── Search results ──────────────────────────────────────────────────
// Typing searches the settings themselves, not the twelve page names.
// "gaps", "wallpaper", and "screenshot" all used to find nothing, which
// made the app feel far smaller than it is.
Column { Column {
width: parent.width id: scrollContent
spacing: 3
visible: root.query !== "" width: sidebarScroll.width
// ── Search results ──────────────────────────────────────────────
// Typing searches the settings themselves, not page names.
Column {
id: searchResults
Text {
width: parent.width width: parent.width
leftPadding: 4 spacing: 3
bottomPadding: 4 visible: root.query !== ""
text: root.results.length === 0
? "Nothing matches"
: root.results.length + (root.results.length === 1 ? " result" : " results")
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Repeater {
model: root.results
Rectangle {
id: hit
required property var modelData
Text {
width: parent.width width: parent.width
height: 44 leftPadding: 4
radius: 10 bottomPadding: 4
color: hitMouse.containsMouse ? Theme.alpha(Theme.fg, 0.08) : "transparent" text: root.results.length === 0
border.width: 0 ? "Nothing matches"
: root.results.length + (root.results.length === 1 ? " result" : " results")
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Column { Repeater {
anchors.left: parent.left model: root.results
anchors.right: parent.right
anchors.leftMargin: 12
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Text { Rectangle {
width: parent.width id: hit
text: hit.modelData.label
color: Theme.fg required property var modelData
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize width: parent.width
elide: Text.ElideRight height: 44
radius: 10
color: hitMouse.containsMouse ? Theme.alpha(Theme.fg, 0.08) : "transparent"
border.width: 0
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 12
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Text {
width: parent.width
text: hit.modelData.label
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
Text {
width: parent.width
text: hit.modelData.kind === "shortcut"
? hit.modelData.detail
: root.pageLabel(hit.modelData.page)
color: hit.modelData.kind === "shortcut" ? Theme.accent : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
} }
Text { MouseArea {
width: parent.width id: hitMouse
text: hit.modelData.kind === "shortcut" anchors.fill: parent
? hit.modelData.detail hoverEnabled: true
: root.pageLabel(hit.modelData.page) cursorShape: Qt.PointingHandCursor
color: hit.modelData.kind === "shortcut" ? Theme.accent : Theme.fgMuted onClicked: {
font.family: Theme.fontFamily root.pageRequested(hit.modelData.page);
font.pixelSize: Theme.fontSizeSmall searchInput.text = "";
elide: Text.ElideRight }
}
}
MouseArea {
id: hitMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.pageRequested(hit.modelData.page);
searchInput.text = "";
} }
} }
} }
} }
}
Column { Column {
width: parent.width id: navigationList
spacing: 4
visible: root.query === ""
Repeater { width: parent.width
model: root.destinations spacing: 4
visible: root.query === ""
Rectangle { Repeater {
id: navItem model: root.destinations
required property var modelData
width: parent.width
height: 40
radius: 10
color: modelData.page === root.selectedPage
? Theme.alpha(Theme.accent, 0.17)
: (navMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha * 0.55) : Theme.alpha(Theme.fg, 0))
border.width: modelData.page === root.selectedPage ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.26)
Rectangle { Rectangle {
width: 2 id: navItem
height: 18 required property var modelData
radius: 1 width: parent.width
anchors.left: parent.left height: 40
anchors.verticalCenter: parent.verticalCenter radius: 10
visible: navItem.modelData.page === root.selectedPage color: modelData.page === root.selectedPage
gradient: Gradient { ? Theme.alpha(Theme.accent, 0.17)
GradientStop { position: 0; color: Theme.accent } : (navMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha * 0.55) : Theme.alpha(Theme.fg, 0))
GradientStop { position: 1; color: Theme.accentSecondary } border.width: modelData.page === root.selectedPage ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.26)
Rectangle {
width: 2
height: 18
radius: 1
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
visible: navItem.modelData.page === root.selectedPage
gradient: Gradient {
GradientStop { position: 0; color: Theme.accent }
GradientStop { position: 1; color: Theme.accentSecondary }
}
} }
}
Text { Text {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 13 anchors.leftMargin: 13
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: 25 width: 25
text: navItem.modelData.icon text: navItem.modelData.icon
color: navItem.modelData.page === root.selectedPage ? Theme.accent : Theme.fgDim color: navItem.modelData.page === root.selectedPage ? Theme.accent : Theme.fgDim
font.family: Theme.fontMono font.family: Theme.fontMono
font.pixelSize: 15 font.pixelSize: 15
} }
Text { Text {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 47 anchors.leftMargin: 47
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 9 anchors.rightMargin: 9
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: navItem.modelData.label text: navItem.modelData.label
color: navItem.modelData.page === root.selectedPage ? Theme.fg : Theme.fgDim color: navItem.modelData.page === root.selectedPage ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize font.pixelSize: Theme.fontSize
font.weight: navItem.modelData.page === root.selectedPage ? Font.Medium : Font.Normal font.weight: navItem.modelData.page === root.selectedPage ? Font.Medium : Font.Normal
elide: Text.ElideRight elide: Text.ElideRight
} }
MouseArea { MouseArea {
id: navMouse id: navMouse
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: root.pageRequested(navItem.modelData.page) onClicked: root.pageRequested(navItem.modelData.page)
}
} }
} }
} }
@@ -253,6 +291,8 @@ Rectangle {
} }
Rectangle { Rectangle {
id: healthFooter
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
@@ -4,50 +4,61 @@ import qs.config
import qs.services import qs.services
import qs.modules.quicksettings import qs.modules.quicksettings
Item { SettingsPage {
Flickable { title: "Sound"
anchors.fill: parent lede: "Live PipeWire output, input, and device selection."
clip: true
contentWidth: width
contentHeight: content.implicitHeight + 64
boundsBehavior: Flickable.StopAtBounds
Column { SettingsCard {
id: content title: "Output"
width: parent.width - 68 subtitle: Pipewire.defaultAudioSink?.description ?? "No output device"
x: 34
y: 30
spacing: 16
Text { text: "Sound"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold } AudioSlider {
Text { text: "Live PipeWire output, input, and device selection."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 } width: parent.width
node: Pipewire.defaultAudioSink
output: true
}
Rectangle {
width: parent.width
height: 1
color: Theme.alpha(Theme.fg, 0.06)
}
AudioDeviceList {
width: parent.width
output: true
maxHeight: 190
}
}
SettingsCard { SettingsCard {
title: "Output" title: "Input"
subtitle: Pipewire.defaultAudioSink?.description ?? "No output device" subtitle: Pipewire.defaultAudioSource?.description ?? "No input device"
AudioSlider { width: parent.width; node: Pipewire.defaultAudioSink; output: true }
Rectangle { width: parent.width; height: 1; color: Theme.alpha(Theme.fg, 0.06) }
AudioDeviceList { width: parent.width; output: true; maxHeight: 190 }
}
SettingsCard { AudioSlider {
title: "Input" width: parent.width
subtitle: Pipewire.defaultAudioSource?.description ?? "No input device" node: Pipewire.defaultAudioSource
AudioSlider { width: parent.width; node: Pipewire.defaultAudioSource; output: false } output: false
Rectangle { width: parent.width; height: 1; color: Theme.alpha(Theme.fg, 0.06) } }
AudioDeviceList { width: parent.width; output: false; maxHeight: 160 } Rectangle {
} width: parent.width
height: 1
color: Theme.alpha(Theme.fg, 0.06)
}
AudioDeviceList {
width: parent.width
output: false
maxHeight: 160
}
}
SettingsCard { SettingsCard {
title: "Advanced sound" title: "Advanced sound"
SettingRow {
label: "Application volumes and profiles" ActionRow {
detail: "Open Fedora's complete sound panel" label: "Application volumes and profiles"
divider: false detail: "Open Fedora's complete sound panel"
controlWidth: 104 divider: false
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("sound") } action: "Open panel"
} onTriggered: SystemSettings.openGnomePanel("sound")
}
} }
} }
} }
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Read and update freedesktop defaults for Panama's settings page."""
from __future__ import annotations
import ast
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
ROLE_TARGETS = {
"browser": ("settings", "default-web-browser"),
"mail": ("mime", "x-scheme-handler/mailto"),
"files": ("mime", "inode/directory"),
"terminal": ("mime", "x-scheme-handler/terminal"),
"music": ("mime", "audio/mpeg"),
"images": ("mime", "image/png"),
"video": ("mime", "video/mp4"),
}
DESKTOP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$")
EXEC_CMD = re.compile(r"hl\.exec_cmd\(\s*(\"(?:\\.|[^\"\\])*\")\s*\)")
class BoundaryError(RuntimeError):
"""A user-visible validation or command failure."""
def xdg_data_roots() -> list[Path]:
data_home = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share"))
data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
def discovered_desktop_ids() -> set[str]:
desktop_ids: set[str] = set()
for root in xdg_data_roots():
applications = root / "applications"
if not applications.is_dir():
continue
for path in applications.rglob("*.desktop"):
if not path.is_file():
continue
relative = path.relative_to(applications)
desktop_ids.add("-".join(relative.parts))
return desktop_ids
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
if not DESKTOP_ID.fullmatch(desktop_id) or desktop_id not in discovered:
raise BoundaryError("That application is not available.")
def run(command: list[str]) -> str:
completed = subprocess.run(command, check=False, capture_output=True, text=True)
if completed.returncode != 0:
detail = completed.stderr.strip()
raise BoundaryError(detail or "The system default could not be updated.")
return completed.stdout.strip()
def query_handlers() -> dict[str, str]:
handlers: dict[str, str] = {}
for role, (kind, target) in ROLE_TARGETS.items():
command = (
["xdg-settings", "get", target]
if kind == "settings"
else ["xdg-mime", "query", "default", target]
)
output = run(command)
handlers[role] = output.splitlines()[0] if output else ""
return handlers
def parse_desktop_entry(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
section = ""
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as error:
raise BoundaryError(f"Could not read {path.name}.") from error
for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
section = stripped[1:-1]
continue
if section != "Desktop Entry" or "=" not in line or stripped.startswith("#"):
continue
key, value = line.split("=", 1)
values.setdefault(key.strip(), value.strip())
return values
def autostart_directory() -> Path:
config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return config_home / "autostart"
def user_autostart_entries() -> list[dict[str, object]]:
directory = autostart_directory()
if not directory.is_dir():
return []
entries: list[dict[str, object]] = []
for path in directory.glob("*.desktop"):
if path.is_symlink() or not path.is_file():
continue
values = parse_desktop_entry(path)
entries.append(
{
"id": path.name,
"name": values.get("Name", path.stem),
"enabled": values.get("Hidden", "false").lower() != "true",
}
)
return sorted(entries, key=lambda entry: (str(entry["name"]).casefold(), str(entry["id"])))
def hypr_autostart_path() -> Path:
override = os.environ.get("PANAMA_HYPR_AUTOSTART")
if override:
return Path(override)
return Path(__file__).resolve().parents[2] / "hypr" / "autostart.lua"
def lua_autostart_entries() -> list[dict[str, object]]:
path = hypr_autostart_path()
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError):
return []
commands: list[str] = []
in_start_handler = False
for line in lines:
if not in_start_handler:
in_start_handler = bool(re.search(r'hl\.on\(\s*"hyprland\.start"', line))
continue
if line.strip() == "end)":
break
match = EXEC_CMD.search(line)
if match:
try:
commands.append(ast.literal_eval(match.group(1)))
except (SyntaxError, ValueError):
continue
return [
{
"id": f"hyprland:{index}",
"name": command.split()[0].rsplit("/", 1)[-1],
"command": command,
"enabled": True,
"readOnly": True,
"source": "config/dot/hypr/autostart.lua",
}
for index, command in enumerate(commands, start=1)
]
def snapshot() -> dict[str, object]:
return {
"handlers": query_handlers(),
"autostartEntries": user_autostart_entries(),
"luaAutostartEntries": lua_autostart_entries(),
}
def set_default(role: str, desktop_id: str) -> None:
target = ROLE_TARGETS.get(role)
if target is None:
raise BoundaryError("That default application role is not supported.")
require_desktop_id(desktop_id, discovered=discovered_desktop_ids())
kind, setting = target
command = (
["xdg-settings", "set", setting, desktop_id]
if kind == "settings"
else ["xdg-mime", "default", desktop_id, setting]
)
run(command)
def update_hidden(path: Path, *, hidden: bool) -> None:
try:
original = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise BoundaryError("That autostart entry could not be read.") from error
lines = original.splitlines()
output: list[str] = []
section = ""
found_section = False
wrote_hidden = False
for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
if section == "Desktop Entry" and not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
wrote_hidden = True
section = stripped[1:-1]
found_section = found_section or section == "Desktop Entry"
output.append(line)
continue
if section == "Desktop Entry" and line.split("=", 1)[0].strip() == "Hidden":
if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
wrote_hidden = True
continue
output.append(line)
if not found_section:
raise BoundaryError("That autostart entry is not a desktop file.")
if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
mode = path.stat().st_mode
try:
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
) as temporary:
temporary.write("\n".join(output) + "\n")
temporary.flush()
os.fsync(temporary.fileno())
temporary_path = Path(temporary.name)
temporary_path.chmod(mode)
os.replace(temporary_path, path)
except OSError as error:
if "temporary_path" in locals():
temporary_path.unlink(missing_ok=True)
raise BoundaryError("That autostart entry could not be updated.") from error
def set_autostart(desktop_id: str, enabled_text: str) -> None:
if enabled_text not in {"true", "false"}:
raise BoundaryError("Autostart state must be true or false.")
if not DESKTOP_ID.fullmatch(desktop_id):
raise BoundaryError("That autostart entry is not available.")
directory = autostart_directory()
path = directory / desktop_id
try:
resolved_directory = directory.resolve(strict=True)
resolved_path = path.resolve(strict=True)
except OSError as error:
raise BoundaryError("That autostart entry is not available.") from error
if path.is_symlink() or resolved_path.parent != resolved_directory or not resolved_path.is_file():
raise BoundaryError("That autostart entry is not available.")
update_hidden(resolved_path, hidden=enabled_text == "false")
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
elif len(arguments) == 3 and arguments[0] == "set-default":
set_default(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-autostart":
set_autostart(arguments[1], arguments[2])
else:
raise BoundaryError(
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
"set-autostart DESKTOP_ID true|false"
)
except BoundaryError as error:
print(str(error), file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,102 @@
pragma Singleton
// Freedesktop default handlers and session autostart entries.
//
// The helper owns parsing and atomic desktop-file writes. This singleton keeps
// the QML side typed and reactive, and every external command crosses Process
// as an argument array.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property var handlers: ({})
property var autostartEntries: []
property var luaAutostartEntries: []
property string lastError: ""
readonly property bool busy: snapshotProcess.running || mutationProcess.running
readonly property string helper: Quickshell.shellDir + "/scripts/panama-default-apps"
readonly property var supportedRoles: ["browser", "mail", "files", "terminal", "music", "images", "video"]
Process {
id: snapshotProcess
stdout: StdioCollector {
onStreamFinished: root.applySnapshot(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Default applications could not be read. Try refreshing."
}
}
Process {
id: mutationProcess
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.lastError = "That application setting could not be changed."
return;
}
root.refresh();
}
}
function applySnapshot(text: string): void {
try {
const payload = JSON.parse(text);
root.handlers = payload.handlers ?? ({});
root.autostartEntries = payload.autostartEntries ?? [];
root.luaAutostartEntries = payload.luaAutostartEntries ?? [];
root.lastError = "";
} catch (error) {
root.lastError = "Default applications returned an unreadable response."
}
}
function refresh(): void {
if (root.busy)
return;
root.lastError = "";
snapshotProcess.exec([root.helper, "snapshot"]);
}
function knownDesktopId(desktopId: string): bool {
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$/.test(desktopId))
return false;
const entries = DesktopEntries.applications.values;
return entries.some(entry => {
const entryId = String(entry.id ?? "");
return entryId === desktopId || entryId + ".desktop" === desktopId;
});
}
function setDefault(role: string, desktopId: string): void {
if (root.busy)
return;
if (!root.supportedRoles.includes(role) || !root.knownDesktopId(desktopId)) {
root.lastError = "Choose an application from the available list."
return;
}
root.lastError = "";
mutationProcess.exec([root.helper, "set-default", role, desktopId]);
}
function setAutostart(desktopId: string, enabled: bool): void {
if (root.busy)
return;
const known = root.autostartEntries.some(entry => entry.id === desktopId);
if (!known) {
root.lastError = "That user autostart entry is no longer available."
return;
}
root.lastError = "";
mutationProcess.exec([root.helper, "set-autostart", desktopId, String(enabled)]);
}
Component.onCompleted: root.refresh()
}
+25 -3
View File
@@ -108,6 +108,17 @@ Singleton {
return ""; return "";
} }
// A moved shortcut vacates its shipped chord, so another override may use
// it legitimately. Refuse to reset the first shortcut until that occupant
// moves away; otherwise Hyprland would receive two binds on one chord.
function overrideOccupantFor(chord: string, exceptShipped: string): string {
for (const shipped in root.overrides) {
if (shipped !== exceptShipped && root.overrides[shipped] === chord)
return shipped;
}
return "";
}
function rebind(currentChord: string, newChord: string): bool { function rebind(currentChord: string, newChord: string): bool {
if (newChord === "" || newChord === currentChord) if (newChord === "" || newChord === currentChord)
return false; return false;
@@ -133,14 +144,25 @@ Singleton {
return true; return true;
} }
function resetBind(currentChord: string): void { function resetBind(currentChord: string): bool {
const shipped = root.shippedChordFor(currentChord); const shipped = root.shippedChordFor(currentChord);
if (shipped === currentChord) if (shipped === currentChord)
return; return true;
const occupant = root.overrideOccupantFor(shipped, shipped);
if (occupant !== "") {
root.lastError = `${shipped} is used by another rebound shortcut. Reset that shortcut first.`;
return false;
}
const next = Object.assign({}, root.overrides); const next = Object.assign({}, root.overrides);
delete next[shipped]; delete next[shipped];
DesktopPreferences.set("keybindOverrides", next); if (!DesktopPreferences.set("keybindOverrides", next)) {
root.lastError = "That shortcut could not be reset.";
return false;
}
root.applyReload(); root.applyReload();
return true;
} }
function resetAll(): void { function resetAll(): void {
@@ -396,12 +396,9 @@ Singleton {
// that only cleared the schema store would silently leave a customised // that only cleared the schema store would silently leave a customised
// favourites list behind while claiming to restore Panama's defaults. // favourites list behind while claiming to restore Panama's defaults.
// //
// Done through HomePreferences' public writable aliases rather than a // HomePreferences owns the write-through boundary so the state file is
// reset function of its own: clearing `favorites` and returning // rewritten before this reset can be considered complete.
// `initialized` to false is exactly the state a fresh install has, and HomePreferences.resetHomeDefaults();
// it lets initialize() seed the list again on next use.
HomePreferences.favorites = [];
HomePreferences.initialized = false;
resettleTimer.restart(); resettleTimer.restart();
} }
@@ -285,3 +285,64 @@ can run in parallel with it.
Nothing here is committed yet; `main` has 20+ uncommitted paths from prior work Nothing here is committed yet; `main` has 20+ uncommitted paths from prior work
that should get a restore point before Stage 1 begins. that should get a restore point before Stage 1 begins.
---
## Stage 5 — Replace GNOME Settings for what Panama owns
Not in the original plan. Added after the settings vocabulary made new pages
cheap enough that the limiting factor stopped being effort and started being
scope. Built jointly with the codex agent, which took default applications, the
page migrations, and the remaining hardcoded values.
- [x] **Wallpaper** — thumbnail grid, applied over hyprpaper IPC.
- [x] **Power & Lock** — screen blank, lock, suspend, lock-before-sleep.
- [x] **Date & Time** — timezone and network time via `timedatectl`.
- [x] **Accessibility** — pointer size, text scale, motion, contrast.
- [x] **Search that indexes settings**, not page names.
- [x] **Editable Dock** — reorder, unpin, add.
- [x] **Settings snapshots** — save, list, restore.
- [x] **Rebindable shortcuts.**
- [ ] Displays: resolution, refresh rate, scale, rotation.
- [ ] Per-application notification rules.
- [ ] Window rules (float/tile/workspace) as a page.
**Landed.** Each of these turned up something the compositor or its tools do
differently than documented, and in every case the failure mode was silence
rather than an error:
* **hyprpaper 0.8 ignores the "all outputs" form.** `<empty>,<path>` is accepted
and does nothing, so a wallpaper set that way appears to succeed and never
changes. Its IPC is also much smaller than older versions suggest — `preload`,
`listloaded`, `unload`, and `reload` all answer "invalid hyprpaper request".
* **`cursor:inactive_timeout` is answered as `float`, not `int`.** A wrong
`readAs` does not fail loudly; it makes every write to that key look rejected,
and the user sees an error for a change that worked.
* **Snapshot filenames collided at one-second resolution.** A save followed
promptly by a restore produced the same name twice, and the restore's own
safety snapshot overwrote the file it was about to read. Found by the
contract, which restores immediately after saving.
* **Keying bind overrides by description moved every bind sharing one.**
Rebinding `SUPER+C` also dragged `XF86Calculator` onto the same chord.
Descriptions are not unique; chords are.
* **The GNOME delegation allow-list named a panel that does not exist.**
`users` is not in `gnome-control-center --list`, so that button opened
nothing.
* **A copy of the Quickshell config shares the live shell's ID.** Quickshell
derives it from content rather than path, so an "isolated" harness built by
copying the config directory can kill or drive the running desktop. This took
the live shell down twice during development. Harnesses pointing at a single
distinct `.qml` file are unaffected.
Two configurations are now generated rather than edited, because `~/.config/hypr`
is a symlink into this repository and writing there at runtime would put machine
state into a tracked file: hypridle's config, into `XDG_STATE_HOME` with a
systemd drop-in pointing at it, and the wallpaper, which is applied over IPC and
re-applied at shell start instead of being written into `hyprpaper.conf`.
The schema gained a `json` type for structured values — the dock's pinned list
and the keybind overrides — so they live in the one settings file and are
covered by the one reset, rather than each growing a preference store of its
own. `restoreDefaults()` spans every store Panama owns, including the Home
accessory arrangement, which it reaches through that service's existing public
aliases rather than an API added for the purpose.
@@ -0,0 +1,119 @@
# Panama Settings Completion Codex Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Complete Codex's portion of Panama Settings with default-application and autostart management, shared page scaffolds, and real controls for every remaining hardcoded shell behavior value.
**Architecture:** `DefaultApps.qml` is the typed system boundary for freedesktop default handlers and user autostart entries; `ApplicationsPage.qml` binds reactively to `DesktopEntries.applications.values` and never snapshots the asynchronous model. Existing pages adopt `SettingsPage`; schema-bound values use `ChoiceRow`, `SliderRow`, and `ToggleRow`, while specialized controls and genuinely read-only facts remain specialized or use `TextRow`.
**Tech Stack:** Quickshell 0.3 QML, QtQuick, `xdg-settings`, `xdg-mime`, freedesktop `.desktop` files, Bash/Python contract tests, Hyprland.
**Spec:** `docs/superpowers/specs/2026-08-17-panama-cohesion-design.md`
**Status:** Implemented and independently reviewed on 2026-08-18. The combined
verification gate is recorded in the integrating commit history.
## Global Constraints
- Claude owns and must be the only editor of `PreferenceSchema.qml`, `SettingsSidebar.qml`, `SettingsShell.qml`, `qmldir`, `ShellState.qml`, `SystemSettings.qml`, `AppearancePage.qml`, `DesktopPage.qml`, `ShortcutsPage.qml`, and `config/dot/hypr/*`.
- Never use `hyprctl keyword`; every `hl.bind` requires a description.
- Use `DesktopEntries.applications.values` in reactive bindings. Do not call `byId()` or `heuristicLookup()` in a one-time initialization path.
- Use `SettingRow.activatable` for whole-row clicks. Nested Repeaters address outer models through explicit ids, never `parent.modelData`.
- Only these verified GNOME panels may be opened: applications, background, bluetooth, color, display, keyboard, mouse, multitasking, network, notifications, online-accounts, power, printers, privacy, search, sharing, sound, system, universal-access, wacom, wellbeing, wifi, wwan.
- No mock phase, new visual direction, color literals, or idle animation. Preserve page copy and behavior unless a dead read-only row is replaced by a real control.
- Automated tests isolate XDG config/state, do not change live defaults or autostart entries, do not launch applications, and do not invoke Home actions.
---
### Task 1: Applications, default handlers, and user autostart
**Files:**
- Create: `config/dot/quickshell/services/DefaultApps.qml`
- Create: `config/dot/quickshell/modules/settings/ApplicationsPage.qml`
- Create only if needed for a safe parser/writer boundary: `config/dot/quickshell/scripts/panama-default-apps`
- Create: `tests/quickshell/default-apps-contract.sh`
- Create: `tests/quickshell/applications-settings-contract.sh`
**Interfaces:**
- Consumes: `DesktopEntries.applications.values`, `SettingsPage`, `SettingsCard`, `SettingRow.activatable`, `ActionRow`, `TextRow`, and Claude-owned routing for page id `applications`.
- Produces: a singleton `DefaultApps` with reactive `handlers`, `autostartEntries`, `luaAutostartEntries`, `busy`, `lastError`, `refresh()`, `setDefault(role, desktopId)`, and `setAutostart(desktopId, enabled)`.
- [x] **Step 1: Write failing helper/service and page contracts**
Cover seven roles: browser (`xdg-settings default-web-browser`), mail (`x-scheme-handler/mailto`), files (`inode/directory`), terminal (`x-scheme-handler/terminal`), music (`audio/mpeg`), images (`image/png`), and video (`video/mp4`). Use temporary XDG directories and fake `xdg-settings`/`xdg-mime` binaries; assert setters pass separate arguments and reject unknown roles or desktop ids. Fixture user autostart entries must expose id/name/enabled and toggle with standard `Hidden=` semantics; `hl.exec_cmd` entries parsed from `config/dot/hypr/autostart.lua` are read-only and identify their source.
The page contract must require `DesktopEntries.applications.values`, page id/object name, all seven role labels, user and compositor autostart sections, `SettingRow.activatable`, and must reject `Component.onCompleted` snapshots plus `byId()`/`heuristicLookup()`.
- [x] **Step 2: Run focused contracts to verify RED**
```bash
tests/quickshell/default-apps-contract.sh
tests/quickshell/applications-settings-contract.sh
```
Expected: fail because the service/page and behavior do not exist.
- [x] **Step 3: Implement the minimal typed boundary and page**
All process commands use argument arrays. Validate roles against a fixed map and desktop ids against the reactive applications model or a strict freedesktop id pattern plus discovered entries. The page filters role choices from category/generic-name data, sorts by display name, keeps the current handler visible even when category metadata is sparse, and shows calm inline errors. Toggling applies only to files under `$XDG_CONFIG_HOME/autostart`; Lua entries remain read-only with explanatory copy.
- [x] **Step 4: Verify and commit**
```bash
tests/quickshell/default-apps-contract.sh
tests/quickshell/applications-settings-contract.sh
tests/quickshell/settings-pages-contract.sh
```
Commit subject: `Add application and autostart settings`.
---
### Task 2: Remaining page scaffolds and hardcoded behavior controls
**Files:**
- Modify: `config/dot/quickshell/config/Settings.qml`
- Modify: `config/dot/quickshell/modules/settings/HomePage.qml`
- Modify: `config/dot/quickshell/modules/settings/DisplaysPage.qml`
- Modify: `config/dot/quickshell/modules/settings/ConnectivityPage.qml`
- Modify: `config/dot/quickshell/modules/settings/SoundPage.qml`
- Modify: `config/dot/quickshell/modules/settings/NotificationsPage.qml`
- Modify: `config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml`
- Modify: `config/dot/quickshell/modules/settings/ServicesPage.qml`
- Modify: `config/dot/quickshell/modules/settings/AboutPage.qml`
- Modify: `tests/quickshell/settings-pages-contract.sh`
- Create: `tests/quickshell/settings-hardcoded-values-contract.sh`
**Interfaces:**
- Consumes: Claude-owned schema keys `temperatureUnit`, `weatherRefreshMinutes`, `vitalsIntervalMs`, `notificationTimeoutMs`, `notificationTimeoutCriticalMs`, `notificationHistoryLimit`, `maxVisibleToasts`, `screenshotDir`, `recordingDir`, and `recorderArgs`; shared Settings rows; existing services and system handoffs.
- Produces: the same public `Settings.qml` properties, now each reading `DesktopPreferences.get("key")`, and controls routed to weather/vitals, notifications, and capture pages.
- [x] **Step 1: Write failing contracts**
Require all ten `Settings.qml` properties to use `DesktopPreferences.get()` exactly. Require every listed page root to be `SettingsPage` and reject its copied root `Flickable` scaffold. Require weather/vitals controls on `HomePage`, notification controls on `NotificationsPage`, and capture directory/encoder controls on `ScreenIntelligencePage`; every schema-bound control uses a shared row and writes only through `SystemSettings.commitPreference` via that row.
- [x] **Step 2: Run contracts to verify RED**
```bash
tests/quickshell/settings-hardcoded-values-contract.sh
tests/quickshell/settings-pages-contract.sh
```
Expected: fail on hardcoded properties and copied page scaffolds.
- [x] **Step 3: Implement controls and migrate scaffolds**
Use `ChoiceRow` for `temperatureUnit`, `screenshotDir`, `recordingDir`, and `recorderArgs`; use `SliderRow` for numeric refresh, timeout, history, and toast limits. Give `notificationTimeoutCriticalMs` `zeroLabel: "Never"`. Preserve all specialized buttons, service status rows, display diagnostics, permission/privacy explanations, and GNOME handoff actions. Convert genuinely read-only rows to `TextRow`; delete only rows superseded by working controls.
- [x] **Step 4: Run focused and full suite, then commit**
```bash
tests/quickshell/settings-hardcoded-values-contract.sh
tests/quickshell/settings-pages-contract.sh
tests/quickshell/settings-preferences-contract.sh
tests/quickshell/settings-search-contract.sh
```
Then run every `tests/quickshell/*.sh`, every `tests/hypr/*.sh`, and all three `tests/quickshell/*_test.py` files sequentially.
Commit subject: `Complete Panama settings controls`.
@@ -0,0 +1,103 @@
# Settings Home & Phone Completion Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Finish the Home & Phone Settings page on Panama's shared Settings vocabulary and give the global reset path a named, durable Home preference API.
**Architecture:** `HomePhonePage` adopts `SettingsPage` for the shared scrolling/title/lede scaffold while retaining its specialized catalog, reorder, alias, and phone controls. `HomePreferences.resetHomeDefaults()` becomes the sole Home-store reset boundary: it returns the store to fresh-install state and writes immediately so `SystemSettings.restoreDefaults()` can call it without mutating aliases.
**Tech Stack:** Quickshell 0.3 QML, QtQuick, Bash contract tests, Hyprland.
**Spec:** `docs/superpowers/specs/2026-08-17-panama-cohesion-design.md`
## Global Constraints
- Do not touch `SystemSettings.qml`, `PreferenceSchema.qml`, keybinds, or Claude's shared row implementations in this branch.
- Preserve ordered Home favorites, aliases, first-four Control Center badges, search, drag and keyboard reorder, retry state, Home Assistant status copy, and BlueBubbles availability behavior.
- Automated tests must not toggle or dim a real light and must not launch BlueBubbles.
- New behavior follows red-green TDD; fixture and state directories remain isolated from the live desktop.
- No mock phase and no new visual direction: this is cohesion work against the already-approved design.
---
### Task 1: Shared Home & Phone page and named reset boundary
**Files:**
- Modify: `config/dot/quickshell/config/HomePreferences.qml`
- Modify: `config/dot/quickshell/home-preferences-harness.qml`
- Modify: `config/dot/quickshell/modules/settings/HomePhonePage.qml`
- Modify only if required for shared vocabulary compatibility: `config/dot/quickshell/modules/settings/HomeFavoriteCard.qml`
- Modify only if required for shared vocabulary compatibility: `config/dot/quickshell/modules/settings/AvailableLightRow.qml`
- Modify: `tests/quickshell/home-preferences-contract.sh`
- Modify: `tests/quickshell/home-phone-settings-contract.sh`
**Interfaces:**
- Consumes: `SettingsPage { title; lede; default content }`, existing `SettingsCard`, `SettingRow`, `ActionRow`, `TextRow`, `HomeAssistant`, `SystemSettings.bluebubblesAvailable`, and writable `HomePreferences` adapter state.
- Produces: `HomePreferences.resetHomeDefaults(): void`, which sets `favorites` to `[]`, sets `initialized` to `false`, clears stale save error state, and invokes `preferencesFile.writeAdapter()` immediately after stopping the debounce timer.
- [ ] **Step 1: Write failing contracts**
Add `reset()` to the isolated `home-pref-test` IPC harness. Extend `home-preferences-contract.sh` to seed aliases/order, invoke reset, and require both IPC state and `panama-home.json` to become exactly `{"initialized":false,"favorites":[]}` without waiting for the 180 ms debounce interval. Extend `home-phone-settings-contract.sh` to require `SettingsPage {`, `title: "Home & Phone"`, and the existing lede through `lede:`, while rejecting the copied root `Flickable` scaffold.
- [ ] **Step 2: Run contracts to verify RED**
```bash
tests/quickshell/home-preferences-contract.sh
tests/quickshell/home-phone-settings-contract.sh
```
Expected: the preference contract fails because `reset` is missing; the page contract fails because the page still owns a copied `Flickable` scaffold.
- [ ] **Step 3: Implement the reset API and shared page scaffold**
Implement this public boundary in `HomePreferences.qml`:
```qml
function resetHomeDefaults(): void {
persistTimer.stop();
values.favorites = [];
values.initialized = false;
root.saveError = "";
preferencesFile.writeAdapter();
}
```
Replace the `HomePhonePage` root `Item` plus nested `Flickable`/title/lede scaffold with:
```qml
SettingsPage {
id: root
objectName: "home-phone-page"
title: "Home & Phone"
lede: "Choose what appears in Control Center and keep phone continuity close at hand."
// Existing SettingsCard content remains in order.
}
```
Use shared `ActionRow` or `TextRow` only where their single-action/read-only contracts preserve all current status and accessibility behavior. Keep specialized rows when the shared primitive would lose information.
- [ ] **Step 4: Run focused contracts to GREEN**
```bash
tests/quickshell/home-preferences-contract.sh
tests/quickshell/home-phone-settings-contract.sh
tests/quickshell/settings-pages-contract.sh
tests/quickshell/settings-rows-contract.sh
tests/quickshell/settings-commit-reset-contract.sh
```
Expected: every command exits 0; no test launches BlueBubbles or changes a real Home Assistant entity.
- [ ] **Step 5: Commit**
```bash
git add config/dot/quickshell/config/HomePreferences.qml \
config/dot/quickshell/home-preferences-harness.qml \
config/dot/quickshell/modules/settings/HomePhonePage.qml \
config/dot/quickshell/modules/settings/HomeFavoriteCard.qml \
config/dot/quickshell/modules/settings/AvailableLightRow.qml \
tests/quickshell/home-preferences-contract.sh \
tests/quickshell/home-phone-settings-contract.sh \
docs/superpowers/plans/2026-08-18-settings-home-phone-completion.md
git commit -m "Finish Home and Phone settings cohesion"
```
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
printf 'applications settings contract: %s\n' "$1" >&2
exit 1
}
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
page="$project_root/config/dot/quickshell/modules/settings/ApplicationsPage.qml"
[[ -f "$page" ]] || fail 'Applications page is missing'
assert_contains() {
rg -F --quiet "$1" "$page" || fail "page is missing: $1"
}
assert_contains 'SettingsPage {'
assert_contains 'objectName: "applications"'
assert_contains 'DesktopEntries.applications.values'
assert_contains 'DefaultApps'
assert_contains 'SettingsCard {'
assert_contains 'SettingRow {'
assert_contains 'activatable:'
assert_contains 'ActionRow {'
assert_contains 'TextRow {'
for label in Browser Mail Files Terminal Music Images Video; do
assert_contains "label: \"$label\""
done
assert_contains 'title: "Default applications"'
assert_contains 'title: "User autostart"'
assert_contains 'title: "Compositor autostart"'
assert_contains 'categories'
assert_contains 'genericName'
assert_contains '.sort('
assert_contains 'currentEntry'
assert_contains 'read-only'
assert_contains 'choices.push(currentEntry)'
assert_contains 'label: "Application settings need attention"'
assert_contains 'DefaultApps.busy ? "Loading…"'
assert_contains 'visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0'
assert_contains 'visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0'
PAGE_PATH="$page" bun -e '
const source = await Bun.file(process.env.PAGE_PATH).text();
const rolesSource = source.match(/readonly property var roles:\s*(\[[\s\S]*?\n \])/);
const matcherSource = source.match(/function matchesRole\(entry: var, role: var\): bool \{([\s\S]*?)\n \}/);
if (!rolesSource || !matcherSource) {
console.error("applications settings contract: role matcher could not be loaded");
process.exit(1);
}
const roles = Function(`return (${rolesSource[1]})`)();
const matchesRole = Function("entry", "role", matcherSource[1]);
const role = key => roles.find(candidate => candidate.key === key);
const fixtures = [
{
name: "AudioVideo does not imply music",
entry: { name: "Kodi", genericName: "Media Center", comment: "Entertainment hub", categories: "AudioVideo;Player;" },
role: "music",
expected: false
},
{
name: "Graphics does not imply image handler",
entry: { name: "Document Scanner", genericName: "Document Scanner", comment: "Scan documents", categories: ["Graphics"] },
role: "images",
expected: false
},
{
name: "Viewer does not imply image handler",
entry: { name: "Papers", genericName: "Document Viewer", comment: "Read documents", categories: "Office;Viewer;" },
role: "images",
expected: false
},
{
name: "comment does not nominate a default handler",
entry: { name: "Settings", genericName: "System Settings", comment: "Configure your video player", categories: ["System"] },
role: "video",
expected: false
},
{
name: "exact audio player categories match music",
entry: { name: "Rhythmbox", genericName: "Music Player", comment: "Play music", categories: "AudioVideo;Audio;Player;" },
role: "music",
expected: true
},
{
name: "exact video category matches video",
entry: { name: "Videos", genericName: "Video Player", comment: "Play movies", categories: ["AudioVideo", "Video", "Player"] },
role: "video",
expected: true
},
{
name: "descriptive metadata matches image handler",
entry: { name: "Loupe", genericName: "Image Viewer", comment: "Browse pictures", categories: "Graphics;Viewer;" },
role: "images",
expected: true
}
];
for (const fixture of fixtures) {
const actual = matchesRole(fixture.entry, role(fixture.role));
if (actual !== fixture.expected) {
console.error(`applications settings contract: ${fixture.name}: expected ${fixture.expected}, got ${actual}`);
process.exit(1);
}
}
'
if rg --quiet 'Component\.onCompleted|DesktopEntries\.(byId|heuristicLookup)' "$page"; then
fail 'page snapshots or performs a one-time desktop-entry lookup'
fi
if rg -F --quiet 'label: "Could not apply the change"' "$page"; then
fail 'error heading incorrectly describes read failures as apply failures'
fi
if rg --quiet '#[0-9A-Fa-f]{3,8}' "$page"; then
fail 'page introduces a color literal instead of the shared visual system'
fi
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|| fail 'default and autostart rows are not both whole-row activatable'
printf 'applications settings contract: PASS\n'
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
printf 'default apps contract: %s\n' "$1" >&2
exit 1
}
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$project_root/config/dot/quickshell/scripts/panama-default-apps"
service="$project_root/config/dot/quickshell/services/DefaultApps.qml"
test_root="$(mktemp -d /tmp/panama-default-apps.XXXXXX)"
trap 'rm -rf "$test_root"' EXIT
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
[[ -f "$service" ]] || fail 'DefaultApps service is missing'
assert_service_contains() {
rg -F --quiet "$1" "$service" || fail "service is missing: $1"
}
assert_service_contains 'pragma Singleton'
assert_service_contains 'property var handlers'
assert_service_contains 'property var autostartEntries'
assert_service_contains 'property var luaAutostartEntries'
assert_service_contains 'readonly property bool busy'
assert_service_contains 'property string lastError'
assert_service_contains 'function refresh(): void'
assert_service_contains 'function setDefault(role: string, desktopId: string): void'
assert_service_contains 'function setAutostart(desktopId: string, enabled: bool): void'
assert_service_contains 'DesktopEntries.applications.values'
if rg --quiet 'command\s*:\s*"' "$service"; then
fail 'Process command must be an argument array'
fi
config_home="$test_root/config"
data_home="$test_root/data"
data_dirs="$test_root/data-dirs"
fake_bin="$test_root/bin"
call_log="$test_root/calls"
lua_fixture="$test_root/autostart.lua"
mkdir -p "$config_home/autostart" "$data_home/applications" "$data_dirs" "$fake_bin"
write_application() {
local desktop_id="$1"
local name="$2"
local generic_name="$3"
local categories="$4"
cat >"$data_home/applications/$desktop_id" <<EOF
[Desktop Entry]
Type=Application
Name=$name
GenericName=$generic_name
Categories=$categories
Exec=/usr/bin/true
EOF
}
write_application org.mozilla.firefox.desktop Firefox 'Web Browser' 'Network;WebBrowser;'
write_application org.gnome.Geary.desktop Geary 'Mail Client' 'Network;Email;'
write_application org.gnome.Nautilus.desktop Files 'File Manager' 'System;FileManager;'
write_application org.gnome.Ptyxis.desktop Ptyxis Terminal 'System;TerminalEmulator;'
write_application org.gnome.Rhythmbox3.desktop Rhythmbox 'Music Player' 'AudioVideo;Audio;Player;'
write_application org.gnome.Loupe.desktop Loupe 'Image Viewer' 'Graphics;Viewer;'
write_application org.gnome.Totem.desktop Videos 'Video Player' 'AudioVideo;Video;Player;'
cat >"$config_home/autostart/nextcloud.desktop" <<'EOF'
[Desktop Entry]
Type=Application
Name=Nextcloud
Exec=nextcloud --background
Hidden=true
EOF
cat >"$lua_fixture" <<'EOF'
hl.on("hyprland.start", function()
hl.exec_cmd("quickshell --daemonize")
hl.exec_cmd("nextcloud --background")
end)
hl.on("hyprland.shutdown", function()
hl.exec_cmd("systemctl --user stop hyprland-session.target")
end)
EOF
cat >"$fake_bin/xdg-settings" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$@" >>"$PANAMA_CALL_LOG"
if [[ "$1" == "get" && "$2" == "default-web-browser" ]]; then
printf '%s\n' 'org.mozilla.firefox.desktop'
exit 0
fi
EOF
chmod +x "$fake_bin/xdg-settings"
cat >"$fake_bin/xdg-mime" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$@" >>"$PANAMA_CALL_LOG"
if [[ "$1" == "query" && "$2" == "default" ]]; then
case "$3" in
x-scheme-handler/mailto) printf '%s\n' 'org.gnome.Geary.desktop' ;;
inode/directory) printf '%s\n' 'org.gnome.Nautilus.desktop' ;;
x-scheme-handler/terminal) printf '%s\n' 'org.gnome.Ptyxis.desktop' ;;
audio/mpeg) printf '%s\n' 'org.gnome.Rhythmbox3.desktop' ;;
image/png) printf '%s\n' 'org.gnome.Loupe.desktop' ;;
video/mp4) printf '%s\n' 'org.gnome.Totem.desktop' ;;
*) exit 91 ;;
esac
exit 0
fi
EOF
chmod +x "$fake_bin/xdg-mime"
export XDG_CONFIG_HOME="$config_home"
export XDG_DATA_HOME="$data_home"
export XDG_DATA_DIRS="$data_dirs"
export PANAMA_HYPR_AUTOSTART="$lua_fixture"
export PANAMA_CALL_LOG="$call_log"
export PATH="$fake_bin:$PATH"
snapshot="$($helper snapshot)" || fail 'snapshot command failed'
[[ "$(rg --count '^get$' "$call_log")" == "1" ]] \
|| fail 'browser handler was queried more than once'
[[ "$(rg --count '^query$' "$call_log")" == "6" ]] \
|| fail 'MIME handlers were queried more than once'
jq -e '
.handlers == {
browser: "org.mozilla.firefox.desktop",
mail: "org.gnome.Geary.desktop",
files: "org.gnome.Nautilus.desktop",
terminal: "org.gnome.Ptyxis.desktop",
music: "org.gnome.Rhythmbox3.desktop",
images: "org.gnome.Loupe.desktop",
video: "org.gnome.Totem.desktop"
} and
.autostartEntries == [{id: "nextcloud.desktop", name: "Nextcloud", enabled: false}] and
(.luaAutostartEntries | length == 2) and
([.luaAutostartEntries[] |
.enabled == true and .readOnly == true and
.source == "config/dot/hypr/autostart.lua" and
(.id | startswith("hyprland:")) and
(.name | length > 0) and (.command | length > 0)
] | all) and
([.luaAutostartEntries[].command] |
index("systemctl --user stop hyprland-session.target") == null)
' <<<"$snapshot" >/dev/null || fail 'snapshot shape, handlers, or autostart parsing is incorrect'
assert_call() {
local expected="$1"
local actual
actual="$(cat "$call_log")"
[[ "$actual" == "$expected" ]] || {
printf 'expected argv:\n%s\nactual argv:\n%s\n' "$expected" "$actual" >&2
fail 'setter did not pass separate arguments'
}
}
: >"$call_log"
$helper set-default browser org.mozilla.firefox.desktop
assert_call $'set\ndefault-web-browser\norg.mozilla.firefox.desktop'
roles=(mail files terminal music images video)
desktop_ids=(
org.gnome.Geary.desktop
org.gnome.Nautilus.desktop
org.gnome.Ptyxis.desktop
org.gnome.Rhythmbox3.desktop
org.gnome.Loupe.desktop
org.gnome.Totem.desktop
)
mime_types=(
x-scheme-handler/mailto
inode/directory
x-scheme-handler/terminal
audio/mpeg
image/png
video/mp4
)
for index in "${!roles[@]}"; do
: >"$call_log"
$helper set-default "${roles[$index]}" "${desktop_ids[$index]}"
assert_call $'default\n'"${desktop_ids[$index]}"$'\n'"${mime_types[$index]}"
done
: >"$call_log"
if $helper set-default unknown org.mozilla.firefox.desktop >/dev/null 2>&1; then
fail 'unknown role was accepted'
fi
[[ ! -s "$call_log" ]] || fail 'unknown role reached an xdg command'
if $helper set-default browser org.example.Missing.desktop >/dev/null 2>&1; then
fail 'undiscovered desktop id was accepted'
fi
if $helper set-default browser ../escape.desktop >/dev/null 2>&1; then
fail 'unsafe desktop id was accepted'
fi
$helper set-autostart nextcloud.desktop true
rg --quiet '^Hidden=false$' "$config_home/autostart/nextcloud.desktop" \
|| fail 'enabling autostart did not set Hidden=false'
[[ "$(rg --count '^Hidden=' "$config_home/autostart/nextcloud.desktop")" == "1" ]] \
|| fail 'enabling autostart duplicated Hidden'
rg --quiet '^Exec=nextcloud --background$' "$config_home/autostart/nextcloud.desktop" \
|| fail 'autostart update damaged another desktop key'
jq -e '.autostartEntries == [{id: "nextcloud.desktop", name: "Nextcloud", enabled: true}]' \
<<<"$($helper snapshot)" >/dev/null || fail 'enabled state did not round-trip'
$helper set-autostart nextcloud.desktop false
rg --quiet '^Hidden=true$' "$config_home/autostart/nextcloud.desktop" \
|| fail 'disabling autostart did not set Hidden=true'
outside_entry="$test_root/outside.desktop"
cp "$config_home/autostart/nextcloud.desktop" "$outside_entry"
ln -s "$outside_entry" "$config_home/autostart/linked.desktop"
if $helper set-autostart linked.desktop true >/dev/null 2>&1; then
fail 'autostart symlink escaping XDG config was accepted'
fi
rg --quiet '^Hidden=true$' "$outside_entry" || fail 'outside autostart file was modified'
if $helper set-autostart missing.desktop true >/dev/null 2>&1; then
fail 'unknown autostart desktop id was accepted'
fi
if $helper set-autostart 'hyprland:1' false >/dev/null 2>&1; then
fail 'read-only compositor entry was accepted for mutation'
fi
printf 'default apps contract: PASS\n'
@@ -80,8 +80,12 @@ system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
[[ -f "$home_page" ]] || fail 'HomePhonePage.qml is missing' [[ -f "$home_page" ]] || fail 'HomePhonePage.qml is missing'
[[ -f "$favorite_card" ]] || fail 'HomeFavoriteCard.qml is missing' [[ -f "$favorite_card" ]] || fail 'HomeFavoriteCard.qml is missing'
[[ -f "$available_row" ]] || fail 'AvailableLightRow.qml is missing' [[ -f "$available_row" ]] || fail 'AvailableLightRow.qml is missing'
assert_contains 'text: "Home & Phone"' "$home_page" assert_contains 'SettingsPage {' "$home_page"
assert_contains 'text: "Choose what appears in Control Center and keep phone continuity close at hand."' "$home_page" assert_contains 'title: "Home & Phone"' "$home_page"
assert_contains 'lede: "Choose what appears in Control Center and keep phone continuity close at hand."' "$home_page"
if rg -q '^\s*Flickable \{' "$home_page"; then
fail 'HomePhonePage.qml still owns a copied Flickable scaffold'
fi
assert_contains 'Connected · ' "$home_page" assert_contains 'Connected · ' "$home_page"
assert_contains 'Last update unavailable · showing saved controls' "$home_page" assert_contains 'Last update unavailable · showing saved controls' "$home_page"
assert_contains 'Authentication required' "$home_page" assert_contains 'Authentication required' "$home_page"
+25 -3
View File
@@ -4,6 +4,7 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/home-preferences-harness.qml" harness="$repo_dir/config/dot/quickshell/home-preferences-harness.qml"
preferences="$repo_dir/config/dot/quickshell/config/HomePreferences.qml"
state_home="$(mktemp -d /tmp/panama-home-preferences-state.XXXXXX)" state_home="$(mktemp -d /tmp/panama-home-preferences-state.XXXXXX)"
fail() { fail() {
@@ -73,9 +74,7 @@ wait_for_file_content() {
for _ in $(seq 1 40); do for _ in $(seq 1 40); do
state_file="$(find "$state_home" -name panama-home.json -print -quit)" state_file="$(find "$state_home" -name panama-home.json -print -quit)"
if [[ -n "$state_file" ]] \ if [[ -n "$state_file" ]] \
&& jq -e --argjson expected "$expected" \ && jq -e --argjson expected "$expected" '. == $expected' "$state_file" >/dev/null; then
'.initialized == $expected.initialized and .favorites == $expected.favorites' \
"$state_file" >/dev/null; then
return return
fi fi
sleep 0.1 sleep 0.1
@@ -83,6 +82,15 @@ wait_for_file_content() {
fail 'preferences file did not contain the complete atomic update' fail 'preferences file did not contain the complete atomic update'
} }
assert_reset_persists_without_debounce() {
local expected=$' function resetHomeDefaults(): void {\n persistTimer.stop();\n values.favorites = [];\n values.initialized = false;\n root.saveError = "";\n preferencesFile.writeAdapter();\n }'
local actual
actual="$(sed -n '/^ function resetHomeDefaults(): void {$/,/^ }$/p' "$preferences")"
[[ "$actual" == "$expected" ]] \
|| fail 'resetHomeDefaults must stop debounce before directly writing the default state'
}
# A leading JSON whitespace prevents qs from expanding the array into IPC # A leading JSON whitespace prevents qs from expanding the array into IPC
# positional arguments; JSON.parse() intentionally accepts that whitespace. # positional arguments; JSON.parse() intentionally accepts that whitespace.
initial_ids=' ["light.kitchen","light.hall","light.desk"]' initial_ids=' ["light.kitchen","light.hall","light.desk"]'
@@ -90,7 +98,10 @@ expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":
expected_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}]}' expected_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}]}'
empty_expected='{"initialized":true,"favorites":[],"saveError":""}' empty_expected='{"initialized":true,"favorites":[],"saveError":""}'
empty_file='{"initialized":true,"favorites":[]}' empty_file='{"initialized":true,"favorites":[]}'
reset_expected='{"initialized":false,"favorites":[],"saveError":""}'
reset_file='{"initialized":false,"favorites":[]}'
assert_reset_persists_without_debounce
start_harness start_harness
qs_for_harness ipc call home-pref-test initialize "$initial_ids" >/dev/null 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 alias light.kitchen ' Island ' >/dev/null
@@ -99,9 +110,20 @@ qs_for_harness ipc call home-pref-test remove light.hall >/dev/null
wait_for_status "$expected" wait_for_status "$expected"
wait_for_file_content "$expected_file" wait_for_file_content "$expected_file"
qs_for_harness ipc call home-pref-test reset >/dev/null
wait_for_status "$reset_expected"
wait_for_file_content "$reset_file"
stop_harness stop_harness
start_harness start_harness
wait_for_status "$reset_expected"
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_status "$expected"
wait_for_file_content "$expected_file"
qs_for_harness ipc call home-pref-test remove light.desk >/dev/null 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 qs_for_harness ipc call home-pref-test remove light.kitchen >/dev/null
+26 -1
View File
@@ -17,13 +17,25 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml" harness="$repo_dir/config/dot/quickshell/keybinds-harness.qml"
config_home="$(mktemp -d /tmp/panama-rebind-config.XXXXXX)" service="$repo_dir/config/dot/quickshell/services/Keybinds.qml"
fail() { fail() {
printf 'keybind rebind contract: %s\n' "$1" >&2 printf 'keybind rebind contract: %s\n' "$1" >&2
exit 1 exit 1
} }
rg -Fq 'function overrideOccupantFor(chord: string, exceptShipped: string): string' "$service" \
|| fail 'resetBind has no override collision guard'
rg -Fq 'root.overrideOccupantFor(shipped, shipped)' "$service" \
|| fail 'resetBind does not check whether another override occupies its shipped chord'
if [[ "${PANAMA_KEYBINDS_STATIC_ONLY:-0}" == "1" ]]; then
printf 'keybind rebind contract: PASS (static)\n'
exit 0
fi
config_home="$(mktemp -d /tmp/panama-rebind-config.XXXXXX)"
# The compositor is the live one -- that is the point -- but preferences are # The compositor is the live one -- that is the point -- but preferences are
# isolated so this cannot leave an override in the user's real settings. # isolated so this cannot leave an override in the user's real settings.
# hyprctl reload re-reads the real settings file, so the compositor is only # hyprctl reload re-reads the real settings file, so the compositor is only
@@ -87,6 +99,19 @@ sleep 0.5
[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \ [[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \
|| fail 'resetAll left overrides behind' || fail 'resetAll left overrides behind'
# Terminal moved away from its shipped chord, then Files moved into it.
# Resetting Terminal must refuse instead of producing two binds on one chord.
qs_for_harness ipc call keybinds-test seedResetCollision \
"$terminal" "SUPER + SHIFT + F9" "$files" >/dev/null
sleep 0.2
[[ "$(qs_for_harness ipc call keybinds-test resetBind "SUPER + SHIFT + F9")" == "false" ]] \
|| fail 'resetBind reclaimed a shipped chord occupied by another override'
collision_state="$(qs_for_harness ipc call keybinds-test overrideState)"
jq -e --arg terminal "$terminal" --arg files "$files" \
'.count == 2 and .overrides[$terminal] == "SUPER + SHIFT + F9" and .overrides[$files] == $terminal and (.lastError | length > 0)' \
<<<"$collision_state" >/dev/null \
|| fail "a refused reset changed overrides or gave no explanation: $collision_state"
trap - EXIT trap - EXIT
cleanup cleanup
printf 'keybind rebind contract: PASS\n' printf 'keybind rebind contract: PASS\n'
@@ -19,6 +19,7 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml" harness="$repo_dir/config/dot/quickshell/settings-system-harness.qml"
system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
# Preferences are committed to $XDG_CONFIG_HOME, and the Home store lives under # Preferences are committed to $XDG_CONFIG_HOME, and the Home store lives under
# $XDG_STATE_HOME. Both are isolated so this contract cannot touch the real # $XDG_STATE_HOME. Both are isolated so this contract cannot touch the real
@@ -31,6 +32,12 @@ fail() {
exit 1 exit 1
} }
rg -Fq 'HomePreferences.resetHomeDefaults();' "$system_settings" \
|| fail 'restoreDefaults does not use the durable Home reset boundary'
if rg -q 'HomePreferences\.(favorites|initialized)\s*=' "$system_settings"; then
fail 'restoreDefaults mutates Home aliases instead of using resetHomeDefaults'
fi
qs_for_harness() { qs_for_harness() {
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@" XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
} }
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Public Settings.qml values are the compatibility surface consumed throughout
# the shell. Once a value becomes user-configurable, this file must read it from
# DesktopPreferences rather than keeping a second hardcoded source of truth.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
settings="$repo_dir/config/dot/quickshell/config/Settings.qml"
fail() {
printf 'settings hardcoded values contract: %s\n' "$1" >&2
exit 1
}
properties=(
temperatureUnit
weatherRefreshMinutes
vitalsIntervalMs
notificationTimeoutMs
notificationTimeoutCriticalMs
notificationHistoryLimit
maxVisibleToasts
screenshotDir
recordingDir
recorderArgs
)
for property in "${properties[@]}"; do
count="$(rg -c \
"^[[:space:]]*readonly property [A-Za-z]+ ${property}: DesktopPreferences\\.get\\(\"${property}\"\\)[[:space:]]*(//.*)?$" \
"$settings" || true)"
[[ "$count" == "1" ]] \
|| fail "$property must use DesktopPreferences.get(\"$property\") exactly once"
done
# dockPinned was already migrated on the shared branch. Pinning it here keeps a
# later bulk edit from accidentally restoring the old hardcoded app list.
rg -q '^[[:space:]]*readonly property var dockPinned: DesktopPreferences\.get\("dockPinned"\)[[:space:]]*$' "$settings" \
|| fail 'dockPinned no longer reads DesktopPreferences exactly'
printf 'settings hardcoded values contract: PASS\n'
+79 -5
View File
@@ -3,6 +3,85 @@
set -euo pipefail set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
fail() {
printf 'settings pages contract: %s\n' "$1" >&2
exit 1
}
pages=(Home Displays Connectivity Sound Notifications ScreenIntelligence Services About)
for page in "${pages[@]}"; do
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
root_type="$(awk '
/^import / { next }
/^[[:space:]]*\/\// { next }
/^[[:space:]]*$/ { next }
match($0, /^[[:space:]]*([A-Za-z][A-Za-z0-9]*)[[:space:]]*\{/, found) {
print found[1]
exit
}
' "$page_file")"
[[ "$root_type" == "SettingsPage" ]] \
|| fail "${page}Page.qml root is ${root_type:-unknown}, expected SettingsPage"
! rg -q '^[[:space:]]*Flickable[[:space:]]*\{' "$page_file" \
|| fail "${page}Page.qml still copies the page Flickable scaffold"
done
require_row() {
local file="$1"
local row_type="$2"
local setting="$3"
python3 - "$file" "$row_type" "$setting" <<'PY' || \
fail "$(basename "$file") is missing $row_type for $setting"
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
row_type = re.escape(sys.argv[2])
setting = re.escape(sys.argv[3])
pattern = rf"{row_type}\s*\{{(?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{{).)*?setting\s*:\s*\"{setting}\""
raise SystemExit(0 if re.search(pattern, text, re.S) else 1)
PY
}
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePage.qml"
require_row "$home_page" ChoiceRow temperatureUnit
require_row "$home_page" SliderRow weatherRefreshMinutes
require_row "$home_page" SliderRow vitalsIntervalMs
notifications_page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
for setting in notificationTimeoutMs notificationTimeoutCriticalMs notificationHistoryLimit maxVisibleToasts; do
require_row "$notifications_page" SliderRow "$setting"
done
python3 - "$notifications_page" <<'PY' || fail 'critical notification timeout does not render zero as Never'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
block = re.search(
r'SliderRow\s*\{(?:(?!\n\s*[A-Z][A-Za-z0-9]*\s*\{).)*?'
r'setting\s*:\s*"notificationTimeoutCriticalMs"(?P<tail>.*?)\n\s*\}',
text,
re.S,
)
raise SystemExit(0 if block and re.search(r'zeroLabel\s*:\s*"Never"', block.group(0)) else 1)
PY
intelligence_page="$repo_dir/config/dot/quickshell/modules/settings/ScreenIntelligencePage.qml"
require_row "$intelligence_page" ChoiceRow screenshotDir
require_row "$intelligence_page" ChoiceRow recordingDir
require_row "$intelligence_page" ChoiceRow recorderArgs
# The source-only contract is safe during a shared Quickshell quiet window.
# The existing compositor integration checks remain available explicitly.
if [[ "${PANAMA_SETTINGS_STATIC_ONLY:-0}" == "1" ]]; then
printf 'settings pages contract: PASS (static)\n'
exit 0
fi
state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)" state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)"
source_config_path="$repo_dir/config/dot/quickshell" source_config_path="$repo_dir/config/dot/quickshell"
config_path="$state_home/quickshell" config_path="$state_home/quickshell"
@@ -58,11 +137,6 @@ exit 97
EOF EOF
chmod +x "$test_bin/flatpak" chmod +x "$test_bin/flatpak"
fail() {
printf 'settings pages contract: %s\n' "$1" >&2
exit 1
}
qs_for_test() { qs_for_test() {
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \ PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
qs -p "$config_path" "$@" qs -p "$config_path" "$@"
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
sidebar="$repo_dir/config/dot/quickshell/modules/settings/SettingsSidebar.qml"
fail() {
printf 'settings sidebar layout contract: %s\n' "$1" >&2
exit 1
}
python3 - "$sidebar" <<'PY' || fail 'sidebar does not keep its header and footer pinned around one vertical scroll surface'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
def object_block(type_name: str, object_id: str) -> tuple[int, int, str]:
pattern = re.compile(
rf"\b{re.escape(type_name)}\s*\{{(?:(?!\n\s*[A-Za-z][A-Za-z0-9.]*\s*\{{).)*?"
rf"\bid\s*:\s*{re.escape(object_id)}\b",
re.S,
)
match = pattern.search(text)
if not match:
raise AssertionError(f"missing {type_name} id {object_id}")
start = match.start()
opening = text.index("{", start)
depth = 0
in_string = False
escaped = False
index = opening
while index < len(text):
character = text[index]
if in_string:
if escaped:
escaped = False
elif character == "\\":
escaped = True
elif character == '"':
in_string = False
elif character == '"':
in_string = True
elif character == "{":
depth += 1
elif character == "}":
depth -= 1
if depth == 0:
return start, index + 1, text[start:index + 1]
index += 1
raise AssertionError(f"unterminated {type_name} id {object_id}")
try:
header_start, header_end, header = object_block("Column", "sidebarHeader")
scroll_start, scroll_end, scroll = object_block("Flickable", "sidebarScroll")
footer_start, _, footer = object_block("Rectangle", "healthFooter")
assert header_start < scroll_start < scroll_end < footer_start
assert re.search(r"anchors\.top\s*:\s*parent\.top", header)
assert re.search(r"\bid\s*:\s*searchInput\b", header)
assert re.search(r"anchors\.top\s*:\s*sidebarHeader\.bottom", scroll)
assert re.search(r"anchors\.bottom\s*:\s*healthFooter\.top", scroll)
assert re.search(r"contentWidth\s*:\s*width", scroll)
assert re.search(r"contentHeight\s*:\s*scrollContent\.implicitHeight", scroll)
assert re.search(r"flickableDirection\s*:\s*Flickable\.VerticalFlick", scroll)
assert re.search(r"boundsBehavior\s*:\s*Flickable\.StopAtBounds", scroll)
assert re.search(r"clip\s*:\s*true", scroll)
assert scroll.count("Flickable {") == 1
assert re.search(r"\bid\s*:\s*scrollContent\b", scroll)
assert re.search(r"\bid\s*:\s*searchResults\b", scroll)
assert re.search(r"\bid\s*:\s*navigationList\b", scroll)
assert re.search(r"visible\s*:\s*root\.query\s*!==\s*\"\"", scroll)
assert re.search(r"visible\s*:\s*root\.query\s*===\s*\"\"", scroll)
assert re.search(r"anchors\.bottom\s*:\s*parent\.bottom", footer)
except AssertionError as error:
print(error, file=sys.stderr)
raise SystemExit(1)
PY
printf 'settings sidebar layout contract: PASS\n'