Compare commits
10
Commits
634a9ebe07
...
cae72b5179
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cae72b5179 | ||
|
|
b3b8e0d66d | ||
|
|
787d2b121a | ||
|
|
1b323c2fa5 | ||
|
|
1ca571458c | ||
|
|
768801dbe4 | ||
|
|
375ecfcd95 | ||
|
|
ce95b34d19 | ||
|
|
9d430a3079 | ||
|
|
bac68d2bfb |
@@ -44,6 +44,30 @@ Validate any change without leaving your session:
|
||||
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
|
||||
|
||||
`~/.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
|
||||
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
|
||||
|
||||
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
|
||||
|
||||
@@ -82,6 +82,14 @@ Singleton {
|
||||
values.initialized = true;
|
||||
}
|
||||
|
||||
function resetHomeDefaults(): void {
|
||||
persistTimer.stop();
|
||||
values.favorites = [];
|
||||
values.initialized = false;
|
||||
root.saveError = "";
|
||||
preferencesFile.writeAdapter();
|
||||
}
|
||||
|
||||
function isSelected(entityId: string): bool {
|
||||
for (var index = 0; index < values.favorites.length; index++) {
|
||||
if (values.favorites[index].id === entityId) {
|
||||
|
||||
@@ -28,13 +28,13 @@ Singleton {
|
||||
// label deliberately general rather than exposing precise coordinates in
|
||||
// the UI or guessing at a city from them.
|
||||
readonly property string weatherLocation: "Local weather"
|
||||
readonly property string temperatureUnit: "fahrenheit"
|
||||
readonly property int weatherRefreshMinutes: 20
|
||||
readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
|
||||
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
|
||||
|
||||
// ── Vitals ──────────────────────────────────────────────────────────────
|
||||
// The GNOME Vitals extension showed processor usage, memory usage and GPU
|
||||
// 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 showMemory: DesktopPreferences.get("showMemory")
|
||||
readonly property bool showGpu: DesktopPreferences.get("showGpu")
|
||||
@@ -51,10 +51,10 @@ Singleton {
|
||||
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
|
||||
|
||||
// ── Notifications ───────────────────────────────────────────────────────
|
||||
readonly property int notificationTimeoutMs: 5000
|
||||
readonly property int notificationTimeoutCriticalMs: 0 // 0 = never auto-expire
|
||||
readonly property int notificationHistoryLimit: 100
|
||||
readonly property int maxVisibleToasts: 4
|
||||
readonly property int notificationTimeoutMs: DesktopPreferences.get("notificationTimeoutMs")
|
||||
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
|
||||
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
|
||||
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
|
||||
|
||||
// ── Focus ──────────────────────────────────────────────────────────────
|
||||
// One deliberate default rather than a preset picker: quick settings and
|
||||
@@ -79,9 +79,9 @@ Singleton {
|
||||
readonly property int dockHideDelayMs: DesktopPreferences.get("dockHideDelayMs")
|
||||
|
||||
// ── Capture ─────────────────────────────────────────────────────────────
|
||||
readonly property string screenshotDir: "Pictures/Screenshots"
|
||||
readonly property string recordingDir: "Videos/Recordings"
|
||||
readonly property string screenshotDir: DesktopPreferences.get("screenshotDir")
|
||||
readonly property string recordingDir: DesktopPreferences.get("recordingDir")
|
||||
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
|
||||
// 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 move(id: string, index: int): void { HomePreferences.move(id, index); }
|
||||
function remove(id: string): void { HomePreferences.remove(id); }
|
||||
function reset(): void { HomePreferences.resetHomeDefaults(); }
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
initialized: HomePreferences.initialized,
|
||||
|
||||
@@ -13,9 +13,16 @@ ShellRoot {
|
||||
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 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 {
|
||||
const found = Keybinds.binds.find(bind => bind.description === description);
|
||||
return found ? found.luaChord : "";
|
||||
@@ -24,7 +31,8 @@ ShellRoot {
|
||||
function overrideState(): string {
|
||||
return JSON.stringify({
|
||||
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.services
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text { text: "About Panama"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
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 }
|
||||
SettingsPage {
|
||||
title: "About Panama"
|
||||
lede: "A curated Hyprland desktop built around focus, speed, and good taste."
|
||||
|
||||
SettingsCard {
|
||||
title: "Panama Desktop"
|
||||
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 }
|
||||
|
||||
TextRow {
|
||||
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 {
|
||||
title: "Design principles"
|
||||
SettingRow { label: "Curated by default"; 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: "Curated by default"
|
||||
detail: "Strong choices instead of an incoherent matrix of switches"
|
||||
}
|
||||
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.
|
||||
//
|
||||
// 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.
|
||||
// Applications and session startup.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "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 {
|
||||
title: "Being built"
|
||||
subtitle: "Default browser, mail, files, and terminal, plus the autostart list, are on their way."
|
||||
title: "Default applications"
|
||||
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.modules.quicksettings
|
||||
|
||||
Item {
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Network & Devices"
|
||||
lede: "Connect graphically—no terminal workflow required."
|
||||
|
||||
readonly property var wifiDevice: {
|
||||
for (const device of Networking.devices.values) {
|
||||
if (device.type === DeviceType.Wifi)
|
||||
@@ -17,30 +20,15 @@ Item {
|
||||
}
|
||||
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text { text: "Network & Devices"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "Connect graphically—no terminal workflow required."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
|
||||
SettingsCard {
|
||||
title: "Wi‑Fi"
|
||||
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off"
|
||||
|
||||
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
|
||||
@@ -49,40 +37,56 @@ Item {
|
||||
onToggled: value => Networking.wifiEnabled = value
|
||||
}
|
||||
}
|
||||
WifiList { width: parent.width; device: root.wifiDevice; active: true; maxHeight: 240 }
|
||||
SettingRow {
|
||||
|
||||
WifiList {
|
||||
width: parent.width
|
||||
device: root.wifiDevice
|
||||
active: true
|
||||
maxHeight: 240
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
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") }
|
||||
action: "Open panel"
|
||||
onTriggered: SystemSettings.openGnomePanel("network")
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Bluetooth"
|
||||
subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off"
|
||||
|
||||
SettingRow {
|
||||
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; }
|
||||
onToggled: value => {
|
||||
if (root.bluetoothAdapter)
|
||||
root.bluetoothAdapter.enabled = value;
|
||||
}
|
||||
}
|
||||
BluetoothList { width: parent.width; active: true; maxHeight: 220 }
|
||||
SettingRow {
|
||||
}
|
||||
|
||||
BluetoothList {
|
||||
width: parent.width
|
||||
active: true
|
||||
maxHeight: 220
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Advanced Bluetooth settings"
|
||||
detail: "Device details and system-level options"
|
||||
divider: false
|
||||
controlWidth: 104
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("bluetooth") }
|
||||
}
|
||||
}
|
||||
action: "Open panel"
|
||||
onTriggered: SystemSettings.openGnomePanel("bluetooth")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,29 +3,15 @@ import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text { text: "Displays"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: SystemSettings.monitorDescription || "Reading the active display…"; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
SettingsPage {
|
||||
title: "Displays"
|
||||
lede: SystemSettings.monitorDescription || "Reading the active display…"
|
||||
|
||||
SettingsCard {
|
||||
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 }
|
||||
TextRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
|
||||
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 }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
@@ -93,5 +79,3 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,54 +2,32 @@ import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
readonly property int openedHour: new Date().getHours()
|
||||
readonly property string greeting: openedHour < 12 ? "Good morning" : (openedHour < 18 ? "Good afternoon" : "Good evening")
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text {
|
||||
text: `${root.greeting}, Gabriel`
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 27
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Your Panama desktop is configured and ready."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
bottomPadding: 6
|
||||
}
|
||||
title: `${root.greeting}, Gabriel`
|
||||
lede: "Your Panama desktop is configured and ready."
|
||||
|
||||
SettingsCard {
|
||||
title: SystemSettings.monitorDescription || "Active display"
|
||||
subtitle: SystemSettings.monitorName || "Detecting your display…"
|
||||
|
||||
Row {
|
||||
Grid {
|
||||
id: monitorLayout
|
||||
|
||||
width: parent.width
|
||||
height: 164
|
||||
spacing: 28
|
||||
columns: width >= 620 ? 2 : 1
|
||||
columnSpacing: 28
|
||||
rowSpacing: 16
|
||||
|
||||
Item {
|
||||
width: parent.width * 0.47
|
||||
height: parent.height
|
||||
width: monitorLayout.columns === 2
|
||||
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
|
||||
: monitorLayout.width
|
||||
height: 164
|
||||
|
||||
Rectangle {
|
||||
width: Math.min(parent.width - 24, 260)
|
||||
@@ -85,8 +63,10 @@ Item {
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width * 0.47
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: monitorLayout.columns === 2
|
||||
? (monitorLayout.width - monitorLayout.columnSpacing) * 0.47
|
||||
: monitorLayout.width
|
||||
height: monitorLayout.columns === 2 ? 164 : implicitHeight
|
||||
spacing: 13
|
||||
|
||||
Text {
|
||||
@@ -120,12 +100,31 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
SettingsCard {
|
||||
title: "Weather"
|
||||
subtitle: "Local conditions in the date menu"
|
||||
ChoiceRow { setting: "temperatureUnit" }
|
||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: (parent.width - parent.spacing) / 2
|
||||
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"
|
||||
|
||||
@@ -144,11 +143,13 @@ Item {
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: (parent.width - parent.spacing) / 2
|
||||
width: summaryCards.columns === 2
|
||||
? (summaryCards.width - summaryCards.columnSpacing) / 2
|
||||
: summaryCards.width
|
||||
title: "Desktop services"
|
||||
subtitle: "The essentials are running"
|
||||
|
||||
SettingRow {
|
||||
TextRow {
|
||||
label: "Sync & remote access"
|
||||
detail: `${SystemSettings.nextcloudActive ? "Nextcloud ready" : "Nextcloud stopped"} · ${SystemSettings.rustdeskActive ? "RustDesk ready" : "RustDesk stopped"}`
|
||||
divider: false
|
||||
@@ -157,5 +158,3 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
SettingsPage {
|
||||
id: root
|
||||
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()
|
||||
|
||||
@@ -41,36 +43,6 @@ Item {
|
||||
return "Home Assistant is unavailable";
|
||||
}
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text {
|
||||
text: "Home & Phone"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 27
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Choose what appears in Control Center and keep phone continuity close at hand."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
bottomPadding: 6
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Home Assistant"
|
||||
subtitle: root.homeStatus()
|
||||
@@ -312,5 +284,3 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,69 +2,90 @@ import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text { text: "Notifications & Focus"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "Control interruptions without losing useful history."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
SettingsPage {
|
||||
title: "Notifications & Focus"
|
||||
lede: "Control interruptions without losing useful history."
|
||||
|
||||
SettingsCard {
|
||||
title: "Notifications"
|
||||
|
||||
SettingRow {
|
||||
label: "Do Not Disturb"
|
||||
detail: "Keep notifications in the center but suppress banners"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: Notifs.doNotDisturb; onToggled: value => Notifs.doNotDisturb = value }
|
||||
|
||||
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 {
|
||||
}
|
||||
|
||||
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
|
||||
controlWidth: 94
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Clear all"; enabled: Notifs.history.length > 0; onClicked: Notifs.dismissAll() }
|
||||
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]
|
||||
|
||||
SettingsButton {
|
||||
required property int modelData
|
||||
|
||||
text: `${modelData}m`
|
||||
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
|
||||
onClicked: DesktopPreferences.set("focusDurationMinutes", modelData)
|
||||
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
|
||||
@@ -75,5 +96,3 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.services
|
||||
|
||||
Item {
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Screen Intelligence"
|
||||
lede: "Turn text and codes on screen into content you can use."
|
||||
|
||||
Component.onCompleted: ScreenIntelligence.refresh()
|
||||
|
||||
Timer {
|
||||
@@ -13,35 +16,6 @@ Item {
|
||||
onTriggered: Capture.openIntelligence()
|
||||
}
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text {
|
||||
text: "Screen Intelligence"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 27
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
text: "Turn text and codes on screen into content you can use."
|
||||
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."
|
||||
@@ -52,6 +26,7 @@ Item {
|
||||
detail: "Copy, search, translate, or open detected links"
|
||||
controlWidth: 160
|
||||
divider: false
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -66,21 +41,30 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
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."
|
||||
|
||||
SettingRow {
|
||||
TextRow {
|
||||
label: "Text recognition"
|
||||
detail: "Tesseract with the English language model"
|
||||
value: ScreenIntelligence.ocrReady && ScreenIntelligence.englishReady ? "Ready" : "Needs install"
|
||||
}
|
||||
SettingRow {
|
||||
TextRow {
|
||||
label: "QR & barcodes"
|
||||
detail: "ZBar recognizes codes alongside ordinary text"
|
||||
value: ScreenIntelligence.codeReady ? "Ready" : "Needs install"
|
||||
}
|
||||
SettingRow {
|
||||
TextRow {
|
||||
label: "Privacy"
|
||||
detail: "Only Search, Translate, and Open send the selected result to another application or service"
|
||||
value: "Local first"
|
||||
@@ -90,14 +74,15 @@ Item {
|
||||
|
||||
SettingsCard {
|
||||
title: "Shortcut"
|
||||
SettingRow {
|
||||
|
||||
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
|
||||
}
|
||||
SettingRow {
|
||||
TextRow {
|
||||
visible: !ScreenIntelligence.ocrReady || !ScreenIntelligence.codeReady || !ScreenIntelligence.englishReady
|
||||
label: "Install recognition engines"
|
||||
detail: "sudo dnf install -y tesseract zbar"
|
||||
@@ -106,5 +91,3 @@ Item {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,89 +2,132 @@ import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
function status(active: bool): string { return active ? "Running" : "Stopped"; }
|
||||
SettingsPage {
|
||||
title: "Startup & Services"
|
||||
lede: "A clear view of the background tools that make the desktop feel complete."
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
function status(active: bool): string {
|
||||
return active ? "Running" : "Stopped";
|
||||
}
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
Item {
|
||||
width: parent.width
|
||||
implicitHeight: refresh.implicitHeight
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
Text { width: parent.width - refresh.width; text: "Startup & Services"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
SettingsButton { id: refresh; text: SystemSettings.busy ? "Refreshing…" : "Refresh"; enabled: !SystemSettings.busy; onClicked: SystemSettings.refresh() }
|
||||
SettingsButton {
|
||||
id: refresh
|
||||
anchors.right: parent.right
|
||||
text: SystemSettings.busy ? "Refreshing…" : "Refresh"
|
||||
enabled: !SystemSettings.busy
|
||||
onClicked: SystemSettings.refresh()
|
||||
}
|
||||
}
|
||||
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 }
|
||||
|
||||
SettingsCard {
|
||||
title: "Your services"
|
||||
|
||||
SettingRow {
|
||||
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") }
|
||||
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 {
|
||||
label: "RustDesk"
|
||||
detail: "Remote access through the enabled system service"
|
||||
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") }
|
||||
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") }
|
||||
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"
|
||||
SettingRow { label: "Hyprpaper"; detail: "Wallpaper service"; value: status(SystemSettings.hyprpaperActive) }
|
||||
SettingRow { label: "Hypridle"; detail: "Idle and lock policy"; value: status(SystemSettings.hypridleActive) }
|
||||
SettingRow { label: "Vicinae"; detail: "Spotlight-style launcher daemon"; value: status(SystemSettings.vicinaeActive); divider: false }
|
||||
|
||||
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."
|
||||
SettingRow {
|
||||
|
||||
ActionRow {
|
||||
label: "Network, Bluetooth, printers, users, and accounts"
|
||||
detail: "GNOME Settings remains searchable from the launcher too"
|
||||
divider: false
|
||||
controlWidth: 122
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open network"; onClicked: SystemSettings.openGnomePanel("network") }
|
||||
}
|
||||
}
|
||||
action: "Open network"
|
||||
onTriggered: SystemSettings.openGnomePanel("network")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ Rectangle {
|
||||
|
||||
readonly property var results: SettingsSearch.search(root.query)
|
||||
|
||||
onQueryChanged: {
|
||||
if (sidebarScroll)
|
||||
sidebarScroll.contentY = 0;
|
||||
}
|
||||
|
||||
function pageLabel(page: string): string {
|
||||
const found = root.destinations.find(item => item.page === page);
|
||||
return found ? found.label : "Settings";
|
||||
@@ -40,8 +45,15 @@ Rectangle {
|
||||
border.width: 0
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 18
|
||||
id: sidebarHeader
|
||||
|
||||
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
|
||||
|
||||
Text {
|
||||
@@ -103,12 +115,35 @@ Rectangle {
|
||||
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 {
|
||||
id: scrollContent
|
||||
|
||||
width: sidebarScroll.width
|
||||
|
||||
// ── Search results ──────────────────────────────────────────────
|
||||
// Typing searches the settings themselves, not page names.
|
||||
Column {
|
||||
id: searchResults
|
||||
|
||||
width: parent.width
|
||||
spacing: 3
|
||||
visible: root.query !== ""
|
||||
@@ -183,6 +218,8 @@ Rectangle {
|
||||
}
|
||||
|
||||
Column {
|
||||
id: navigationList
|
||||
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
visible: root.query === ""
|
||||
@@ -251,8 +288,11 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: healthFooter
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
|
||||
@@ -4,50 +4,61 @@ import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Text { text: "Sound"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "Live PipeWire output, input, and device selection."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
SettingsPage {
|
||||
title: "Sound"
|
||||
lede: "Live PipeWire output, input, and device selection."
|
||||
|
||||
SettingsCard {
|
||||
title: "Output"
|
||||
subtitle: Pipewire.defaultAudioSink?.description ?? "No output 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 }
|
||||
|
||||
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 {
|
||||
title: "Input"
|
||||
subtitle: Pipewire.defaultAudioSource?.description ?? "No input device"
|
||||
AudioSlider { width: parent.width; node: Pipewire.defaultAudioSource; output: false }
|
||||
Rectangle { width: parent.width; height: 1; color: Theme.alpha(Theme.fg, 0.06) }
|
||||
AudioDeviceList { width: parent.width; output: false; maxHeight: 160 }
|
||||
|
||||
AudioSlider {
|
||||
width: parent.width
|
||||
node: Pipewire.defaultAudioSource
|
||||
output: false
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
color: Theme.alpha(Theme.fg, 0.06)
|
||||
}
|
||||
AudioDeviceList {
|
||||
width: parent.width
|
||||
output: false
|
||||
maxHeight: 160
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Advanced sound"
|
||||
SettingRow {
|
||||
|
||||
ActionRow {
|
||||
label: "Application volumes and profiles"
|
||||
detail: "Open Fedora's complete sound panel"
|
||||
divider: false
|
||||
controlWidth: 104
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open panel"; onClicked: SystemSettings.openGnomePanel("sound") }
|
||||
}
|
||||
}
|
||||
action: "Open panel"
|
||||
onTriggered: SystemSettings.openGnomePanel("sound")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+275
@@ -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()
|
||||
}
|
||||
@@ -108,6 +108,17 @@ Singleton {
|
||||
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 {
|
||||
if (newChord === "" || newChord === currentChord)
|
||||
return false;
|
||||
@@ -133,14 +144,25 @@ Singleton {
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetBind(currentChord: string): void {
|
||||
function resetBind(currentChord: string): bool {
|
||||
const shipped = root.shippedChordFor(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);
|
||||
delete next[shipped];
|
||||
DesktopPreferences.set("keybindOverrides", next);
|
||||
if (!DesktopPreferences.set("keybindOverrides", next)) {
|
||||
root.lastError = "That shortcut could not be reset.";
|
||||
return false;
|
||||
}
|
||||
root.applyReload();
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetAll(): void {
|
||||
|
||||
@@ -396,12 +396,9 @@ Singleton {
|
||||
// that only cleared the schema store would silently leave a customised
|
||||
// favourites list behind while claiming to restore Panama's defaults.
|
||||
//
|
||||
// Done through HomePreferences' public writable aliases rather than a
|
||||
// reset function of its own: clearing `favorites` and returning
|
||||
// `initialized` to false is exactly the state a fresh install has, and
|
||||
// it lets initialize() seed the list again on next use.
|
||||
HomePreferences.favorites = [];
|
||||
HomePreferences.initialized = false;
|
||||
// HomePreferences owns the write-through boundary so the state file is
|
||||
// rewritten before this reset can be considered complete.
|
||||
HomePreferences.resetHomeDefaults();
|
||||
|
||||
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
|
||||
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
@@ -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'
|
||||
Executable
+230
@@ -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 "$favorite_card" ]] || fail 'HomeFavoriteCard.qml is missing'
|
||||
[[ -f "$available_row" ]] || fail 'AvailableLightRow.qml is missing'
|
||||
assert_contains 'text: "Home & Phone"' "$home_page"
|
||||
assert_contains 'text: "Choose what appears in Control Center and keep phone continuity close at hand."' "$home_page"
|
||||
assert_contains 'SettingsPage {' "$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 'Last update unavailable · showing saved controls' "$home_page"
|
||||
assert_contains 'Authentication required' "$home_page"
|
||||
|
||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
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)"
|
||||
|
||||
fail() {
|
||||
@@ -73,9 +74,7 @@ wait_for_file_content() {
|
||||
for _ in $(seq 1 40); do
|
||||
state_file="$(find "$state_home" -name panama-home.json -print -quit)"
|
||||
if [[ -n "$state_file" ]] \
|
||||
&& jq -e --argjson expected "$expected" \
|
||||
'.initialized == $expected.initialized and .favorites == $expected.favorites' \
|
||||
"$state_file" >/dev/null; then
|
||||
&& jq -e --argjson expected "$expected" '. == $expected' "$state_file" >/dev/null; then
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
@@ -83,6 +82,15 @@ wait_for_file_content() {
|
||||
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
|
||||
# positional arguments; JSON.parse() intentionally accepts that whitespace.
|
||||
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"}]}'
|
||||
empty_expected='{"initialized":true,"favorites":[],"saveError":""}'
|
||||
empty_file='{"initialized":true,"favorites":[]}'
|
||||
reset_expected='{"initialized":false,"favorites":[],"saveError":""}'
|
||||
reset_file='{"initialized":false,"favorites":[]}'
|
||||
|
||||
assert_reset_persists_without_debounce
|
||||
start_harness
|
||||
qs_for_harness ipc call home-pref-test initialize "$initial_ids" >/dev/null
|
||||
qs_for_harness ipc call home-pref-test alias light.kitchen ' Island ' >/dev/null
|
||||
@@ -99,9 +110,20 @@ qs_for_harness ipc call home-pref-test remove light.hall >/dev/null
|
||||
wait_for_status "$expected"
|
||||
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
|
||||
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_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.kitchen >/dev/null
|
||||
|
||||
@@ -17,13 +17,25 @@ set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
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() {
|
||||
printf 'keybind rebind contract: %s\n' "$1" >&2
|
||||
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
|
||||
# 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
|
||||
@@ -87,6 +99,19 @@ sleep 0.5
|
||||
[[ "$(qs_for_harness ipc call keybinds-test overrideState | jq -r .count)" == "0" ]] \
|
||||
|| 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
|
||||
cleanup
|
||||
printf 'keybind rebind contract: PASS\n'
|
||||
|
||||
@@ -19,6 +19,7 @@ set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
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
|
||||
# $XDG_STATE_HOME. Both are isolated so this contract cannot touch the real
|
||||
@@ -31,6 +32,12 @@ fail() {
|
||||
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() {
|
||||
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
+43
@@ -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'
|
||||
@@ -3,6 +3,85 @@
|
||||
set -euo pipefail
|
||||
|
||||
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)"
|
||||
source_config_path="$repo_dir/config/dot/quickshell"
|
||||
config_path="$state_home/quickshell"
|
||||
@@ -58,11 +137,6 @@ exit 97
|
||||
EOF
|
||||
chmod +x "$test_bin/flatpak"
|
||||
|
||||
fail() {
|
||||
printf 'settings pages contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
qs_for_test() {
|
||||
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
|
||||
qs -p "$config_path" "$@"
|
||||
|
||||
+87
@@ -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'
|
||||
Reference in New Issue
Block a user